Compare commits
18 Commits
fix/tombol
...
test-model
| Author | SHA1 | Date | |
|---|---|---|---|
| 6712da9ac2 | |||
| ac11a9367c | |||
| 67e5ceb254 | |||
| 65942ac9d2 | |||
| e0436cc384 | |||
| 63682e47b6 | |||
| f4705690a9 | |||
| 239771a714 | |||
| 03451195c8 | |||
| 597af7e716 | |||
| 0a8a026b94 | |||
| a5bd91b580 | |||
| ae3187804e | |||
| 91e32f3f1c | |||
| 4d03908f23 | |||
| 0563f9664f | |||
| 961cc32057 | |||
| fe7672e09f |
@@ -19,7 +19,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ model FileStorage {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
link String
|
link String
|
||||||
category String // "image" / "document" / "audio" / "other"
|
category String // "image" / "document" / "audio" / "other"
|
||||||
Berita Berita[]
|
Berita Berita[] @relation("BeritaFeaturedImage")
|
||||||
|
BeritaImages Berita[] @relation("BeritaImages")
|
||||||
PotensiDesa PotensiDesa[]
|
PotensiDesa PotensiDesa[]
|
||||||
Posyandu Posyandu[]
|
Posyandu Posyandu[]
|
||||||
StrukturPPID StrukturPPID[]
|
StrukturPPID StrukturPPID[]
|
||||||
@@ -208,16 +209,22 @@ model APBDesItem {
|
|||||||
kode String // contoh: "4", "4.1", "4.1.2"
|
kode String // contoh: "4", "4.1", "4.1.2"
|
||||||
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
|
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
|
||||||
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
|
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
|
||||||
realisasi Float
|
tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null
|
||||||
selisih Float // realisasi - anggaran
|
|
||||||
persentase Float
|
|
||||||
tipe String? // (realisasi / anggaran) * 100
|
|
||||||
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
|
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
|
||||||
parentId String? // untuk relasi hierarki
|
parentId String? // untuk relasi hierarki
|
||||||
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
||||||
children APBDesItem[] @relation("APBDesItemParent")
|
children APBDesItem[] @relation("APBDesItemParent")
|
||||||
apbdesId String
|
apbdesId String
|
||||||
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
||||||
|
|
||||||
|
// Field kalkulasi (auto-calculated dari realisasi items)
|
||||||
|
totalRealisasi Float @default(0) // Sum dari semua realisasi
|
||||||
|
selisih Float @default(0) // totalRealisasi - anggaran
|
||||||
|
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
|
||||||
|
|
||||||
|
// Relasi ke realisasi items
|
||||||
|
realisasiItems RealisasiItem[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
@@ -228,6 +235,26 @@ model APBDesItem {
|
|||||||
@@index([apbdesId])
|
@@index([apbdesId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model baru untuk multiple realisasi per item
|
||||||
|
model RealisasiItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
apbdesItemId String
|
||||||
|
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
jumlah Float // Jumlah realisasi dalam Rupiah
|
||||||
|
tanggal DateTime @db.Date // Tanggal realisasi
|
||||||
|
keterangan String? @db.Text // Keterangan tambahan (opsional)
|
||||||
|
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
@@index([apbdesItemId])
|
||||||
|
@@index([tanggal])
|
||||||
|
}
|
||||||
|
|
||||||
//========================================= PRESTASI DESA ========================================= //
|
//========================================= PRESTASI DESA ========================================= //
|
||||||
model PrestasiDesa {
|
model PrestasiDesa {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -612,15 +639,19 @@ model Berita {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
judul String
|
judul String
|
||||||
deskripsi String
|
deskripsi String
|
||||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
image FileStorage? @relation("BeritaFeaturedImage", fields: [imageId], references: [id])
|
||||||
imageId String?
|
imageId String?
|
||||||
|
images FileStorage[] @relation("BeritaImages")
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
|
linkVideo String? @db.VarChar(500)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime @default(now())
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
|
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
|
||||||
kategoriBeritaId String?
|
kategoriBeritaId String?
|
||||||
|
|
||||||
|
@@index([kategoriBeritaId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model KategoriBerita {
|
model KategoriBerita {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const templateForm = z.object({
|
|||||||
content: z.string().min(3, "Content minimal 3 karakter"),
|
content: z.string().min(3, "Content minimal 3 karakter"),
|
||||||
kategoriBeritaId: z.string().nonempty(),
|
kategoriBeritaId: z.string().nonempty(),
|
||||||
imageId: z.string().nonempty(),
|
imageId: z.string().nonempty(),
|
||||||
|
imageIds: z.array(z.string()),
|
||||||
|
linkVideo: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Default value form berita (hindari uncontrolled input)
|
// 2. Default value form berita (hindari uncontrolled input)
|
||||||
@@ -21,6 +23,8 @@ const defaultForm = {
|
|||||||
imageId: "",
|
imageId: "",
|
||||||
content: "",
|
content: "",
|
||||||
kategoriBeritaId: "",
|
kategoriBeritaId: "",
|
||||||
|
imageIds: [] as string[],
|
||||||
|
linkVideo: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Berita proxy
|
// 4. Berita proxy
|
||||||
@@ -62,14 +66,7 @@ const berita = proxy({
|
|||||||
// State untuk berita utama (hanya 1)
|
// State untuk berita utama (hanya 1)
|
||||||
|
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as any[] | null,
|
||||||
| Prisma.BeritaGetPayload<{
|
|
||||||
include: {
|
|
||||||
image: true;
|
|
||||||
kategoriBerita: true;
|
|
||||||
};
|
|
||||||
}>[]
|
|
||||||
| null,
|
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -79,14 +76,14 @@ const berita = proxy({
|
|||||||
berita.findMany.loading = true;
|
berita.findMany.loading = true;
|
||||||
berita.findMany.page = page;
|
berita.findMany.page = page;
|
||||||
berita.findMany.search = search;
|
berita.findMany.search = search;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query: any = { page, limit };
|
const query: any = { page, limit };
|
||||||
if (search) query.search = search;
|
if (search) query.search = search;
|
||||||
if (kategori) query.kategori = kategori;
|
if (kategori) query.kategori = kategori;
|
||||||
|
|
||||||
const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
|
const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
|
||||||
|
|
||||||
if (res.status === 200 && res.data?.success) {
|
if (res.status === 200 && res.data?.success) {
|
||||||
berita.findMany.data = res.data.data ?? [];
|
berita.findMany.data = res.data.data ?? [];
|
||||||
berita.findMany.totalPages = res.data.totalPages ?? 1;
|
berita.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
@@ -103,18 +100,19 @@ const berita = proxy({
|
|||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
const minDelay = 300;
|
const minDelay = 300;
|
||||||
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
berita.findMany.loading = false;
|
berita.findMany.loading = false;
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
findUnique: {
|
findUnique: {
|
||||||
data: null as Prisma.BeritaGetPayload<{
|
data: null as Prisma.BeritaGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
image: true;
|
image: true;
|
||||||
|
images: true;
|
||||||
kategoriBerita: true;
|
kategoriBerita: true;
|
||||||
};
|
};
|
||||||
}> | null,
|
}> | null,
|
||||||
@@ -199,6 +197,8 @@ const berita = proxy({
|
|||||||
content: data.content,
|
content: data.content,
|
||||||
kategoriBeritaId: data.kategoriBeritaId || "",
|
kategoriBeritaId: data.kategoriBeritaId || "",
|
||||||
imageId: data.imageId || "",
|
imageId: data.imageId || "",
|
||||||
|
imageIds: data.images?.map((img: any) => img.id) || [],
|
||||||
|
linkVideo: data.linkVideo || "",
|
||||||
};
|
};
|
||||||
return data; // Return the loaded data
|
return data; // Return the loaded data
|
||||||
} else {
|
} else {
|
||||||
@@ -237,6 +237,8 @@ const berita = proxy({
|
|||||||
content: this.form.content,
|
content: this.form.content,
|
||||||
kategoriBeritaId: this.form.kategoriBeritaId || null,
|
kategoriBeritaId: this.form.kategoriBeritaId || null,
|
||||||
imageId: this.form.imageId,
|
imageId: this.form.imageId,
|
||||||
|
imageIds: this.form.imageIds,
|
||||||
|
linkVideo: this.form.linkVideo,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,28 @@ import { toast } from "react-toastify";
|
|||||||
import { proxy } from "valtio";
|
import { proxy } from "valtio";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// --- Zod Schema ---
|
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) ---
|
||||||
const ApbdesItemSchema = z.object({
|
const ApbdesItemSchema = z.object({
|
||||||
kode: z.string().min(1, "Kode wajib diisi"),
|
kode: z.string().min(1, "Kode wajib diisi"),
|
||||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||||
anggaran: z.number().min(0),
|
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
||||||
realisasi: z.number().min(0),
|
|
||||||
selisih: z.number(),
|
|
||||||
persentase: z.number(),
|
|
||||||
level: z.number().int().min(1).max(3),
|
level: z.number().int().min(1).max(3),
|
||||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Zod Schema untuk Realisasi Item ---
|
||||||
|
const RealisasiItemSchema = z.object({
|
||||||
|
jumlah: z.number().min(0, "Jumlah tidak boleh negatif"),
|
||||||
|
tanggal: z.string(),
|
||||||
|
keterangan: z.string().optional(),
|
||||||
|
buktiFileId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const ApbdesFormSchema = z.object({
|
const ApbdesFormSchema = z.object({
|
||||||
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
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"),
|
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
||||||
fileId: z.string().min(1, "File wajib diunggah"),
|
fileId: z.string().min(1, "File wajib diunggah"),
|
||||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||||
@@ -27,31 +35,22 @@ const ApbdesFormSchema = z.object({
|
|||||||
// --- Default Form ---
|
// --- Default Form ---
|
||||||
const defaultApbdesForm = {
|
const defaultApbdesForm = {
|
||||||
tahun: new Date().getFullYear(),
|
tahun: new Date().getFullYear(),
|
||||||
|
name: "",
|
||||||
|
deskripsi: "",
|
||||||
|
jumlah: "",
|
||||||
imageId: "",
|
imageId: "",
|
||||||
fileId: "",
|
fileId: "",
|
||||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) ---
|
||||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
|
||||||
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
||||||
const anggaran = item.anggaran ?? 0;
|
|
||||||
const realisasi = item.realisasi ?? 0;
|
|
||||||
|
|
||||||
|
|
||||||
// ✅ Formula yang benar
|
|
||||||
const selisih = realisasi - anggaran; // positif = sisa anggaran, negatif = over budget
|
|
||||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kode: item.kode || "",
|
kode: item.kode || "",
|
||||||
uraian: item.uraian || "",
|
uraian: item.uraian || "",
|
||||||
anggaran,
|
anggaran: item.anggaran ?? 0,
|
||||||
realisasi,
|
|
||||||
selisih,
|
|
||||||
persentase,
|
|
||||||
level: item.level || 1,
|
level: item.level || 1,
|
||||||
tipe: item.tipe, // biarkan null jika memang null
|
tipe: item.tipe ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,15 +243,15 @@ const apbdes = proxy({
|
|||||||
this.id = data.id;
|
this.id = data.id;
|
||||||
this.form = {
|
this.form = {
|
||||||
tahun: data.tahun || new Date().getFullYear(),
|
tahun: data.tahun || new Date().getFullYear(),
|
||||||
|
name: data.name || "",
|
||||||
|
deskripsi: data.deskripsi || "",
|
||||||
|
jumlah: data.jumlah || "",
|
||||||
imageId: data.imageId || "",
|
imageId: data.imageId || "",
|
||||||
fileId: data.fileId || "",
|
fileId: data.fileId || "",
|
||||||
items: (data.items || []).map((item: any) => ({
|
items: (data.items || []).map((item: any) => ({
|
||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
uraian: item.uraian,
|
uraian: item.uraian,
|
||||||
anggaran: item.anggaran,
|
anggaran: item.anggaran,
|
||||||
realisasi: item.realisasi,
|
|
||||||
selisih: item.selisih,
|
|
||||||
persentase: item.persentase,
|
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe || 'pendapatan',
|
tipe: item.tipe || 'pendapatan',
|
||||||
})),
|
})),
|
||||||
@@ -317,6 +316,80 @@ const apbdes = proxy({
|
|||||||
this.form = { ...defaultApbdesForm };
|
this.form = { ...defaultApbdesForm };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// REALISASI STATE MANAGEMENT
|
||||||
|
// =========================================
|
||||||
|
realisasi: {
|
||||||
|
// Create realisasi
|
||||||
|
async create(itemId: string, data: { jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
|
||||||
|
try {
|
||||||
|
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
toast.success("Realisasi berhasil ditambahkan");
|
||||||
|
// Reload findUnique untuk update data
|
||||||
|
if (apbdes.findUnique.data) {
|
||||||
|
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "Gagal menambahkan realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Create realisasi error:", error);
|
||||||
|
toast.error(error?.message || "Terjadi kesalahan saat menambahkan realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update realisasi
|
||||||
|
async update(realisasiId: string, data: { jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
|
||||||
|
try {
|
||||||
|
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
toast.success("Realisasi berhasil diperbarui");
|
||||||
|
// Reload findUnique untuk update data
|
||||||
|
if (apbdes.findUnique.data) {
|
||||||
|
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "Gagal memperbarui realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Update realisasi error:", error);
|
||||||
|
toast.error(error?.message || "Terjadi kesalahan saat memperbarui realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete realisasi
|
||||||
|
async delete(realisasiId: string) {
|
||||||
|
try {
|
||||||
|
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].delete();
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
toast.success("Realisasi berhasil dihapus");
|
||||||
|
// Reload findUnique untuk update data
|
||||||
|
if (apbdes.findUnique.data) {
|
||||||
|
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "Gagal menghapus realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Delete realisasi error:", error);
|
||||||
|
toast.error(error?.message || "Terjadi kesalahan saat menghapus realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apbdes;
|
export default apbdes;
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -17,7 +19,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Loader
|
Loader,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Dropzone } from "@mantine/dropzone";
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
import {
|
import {
|
||||||
@@ -25,19 +27,51 @@ import {
|
|||||||
IconPhoto,
|
IconPhoto,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
IconX,
|
IconX,
|
||||||
|
IconVideo,
|
||||||
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useProxy } from "valtio/utils";
|
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() {
|
function EditBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
|
// Featured image state
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// 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({
|
const [formData, setFormData] = useState({
|
||||||
judul: "",
|
judul: "",
|
||||||
deskripsi: "",
|
deskripsi: "",
|
||||||
@@ -48,9 +82,17 @@ function EditBerita() {
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
judul: "",
|
||||||
|
deskripsi: "",
|
||||||
|
kategoriBeritaId: "",
|
||||||
|
content: "",
|
||||||
|
imageId: "",
|
||||||
|
imageUrl: ""
|
||||||
|
});
|
||||||
|
|
||||||
// Helper function to check if HTML content is empty
|
// Helper function to check if HTML content is empty
|
||||||
const isHtmlEmpty = (html: string) => {
|
const isHtmlEmpty = (html: string) => {
|
||||||
// Remove all HTML tags and check if there's any text content
|
|
||||||
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
return textContent === '';
|
return textContent === '';
|
||||||
};
|
};
|
||||||
@@ -61,21 +103,12 @@ function EditBerita() {
|
|||||||
formData.judul?.trim() !== '' &&
|
formData.judul?.trim() !== '' &&
|
||||||
formData.kategoriBeritaId !== '' &&
|
formData.kategoriBeritaId !== '' &&
|
||||||
!isHtmlEmpty(formData.deskripsi) &&
|
!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)
|
!isHtmlEmpty(formData.content)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
// Load data
|
||||||
judul: "",
|
|
||||||
deskripsi: "",
|
|
||||||
kategoriBeritaId: "",
|
|
||||||
content: "",
|
|
||||||
imageId: "",
|
|
||||||
imageUrl: ""
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load kategori + berita
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
beritaState.kategoriBerita.findMany.load();
|
beritaState.kategoriBerita.findMany.load();
|
||||||
|
|
||||||
@@ -84,7 +117,7 @@ function EditBerita() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await stateDashboardBerita.berita.edit.load(id);
|
const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null;
|
||||||
if (data) {
|
if (data) {
|
||||||
setFormData({
|
setFormData({
|
||||||
judul: data.judul || "",
|
judul: data.judul || "",
|
||||||
@@ -106,6 +139,17 @@ function EditBerita() {
|
|||||||
if (data?.image?.link) {
|
if (data?.image?.link) {
|
||||||
setPreviewImage(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) {
|
} catch (error) {
|
||||||
console.error("Error loading berita:", error);
|
console.error("Error loading berita:", error);
|
||||||
@@ -120,27 +164,59 @@ function EditBerita() {
|
|||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul?.trim()) {
|
if (!formData.judul?.trim()) {
|
||||||
toast.error('Judul wajib diisi');
|
toast.error('Judul wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.kategoriBeritaId) {
|
if (!formData.kategoriBeritaId) {
|
||||||
toast.error('Kategori wajib dipilih');
|
toast.error('Kategori wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(formData.deskripsi)) {
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
toast.error('Deskripsi singkat wajib diisi');
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file && !originalData.imageId) {
|
if (!file && !originalData.imageId) {
|
||||||
toast.error('Gambar wajib dipilih');
|
toast.error('Gambar utama wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(formData.content)) {
|
if (isHtmlEmpty(formData.content)) {
|
||||||
toast.error('Konten wajib diisi');
|
toast.error('Konten wajib diisi');
|
||||||
return;
|
return;
|
||||||
@@ -148,12 +224,14 @@ function EditBerita() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
// Update global state hanya sekali di sini
|
|
||||||
|
// Update global state
|
||||||
beritaState.berita.edit.form = {
|
beritaState.berita.edit.form = {
|
||||||
...beritaState.berita.edit.form,
|
...beritaState.berita.edit.form,
|
||||||
...formData,
|
...formData,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Upload new featured image if changed
|
||||||
if (file) {
|
if (file) {
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file,
|
file,
|
||||||
@@ -162,12 +240,33 @@ function EditBerita() {
|
|||||||
const uploaded = res.data?.data;
|
const uploaded = res.data?.data;
|
||||||
|
|
||||||
if (!uploaded?.id) {
|
if (!uploaded?.id) {
|
||||||
return toast.error("Gagal upload gambar");
|
return toast.error("Gagal upload gambar utama");
|
||||||
}
|
}
|
||||||
|
|
||||||
beritaState.berita.edit.form.imageId = uploaded.id;
|
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();
|
await beritaState.berita.edit.update();
|
||||||
toast.success("Berita berhasil diperbarui!");
|
toast.success("Berita berhasil diperbarui!");
|
||||||
router.push("/admin/desa/berita/list-berita");
|
router.push("/admin/desa/berita/list-berita");
|
||||||
@@ -189,9 +288,12 @@ function EditBerita() {
|
|||||||
});
|
});
|
||||||
setPreviewImage(originalData.imageUrl || null);
|
setPreviewImage(originalData.imageUrl || null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
|
setYoutubeLink(originalYoutubeLink);
|
||||||
toast.info("Form dikembalikan ke data awal");
|
toast.info("Form dikembalikan ke data awal");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -219,6 +321,7 @@ function EditBerita() {
|
|||||||
style={{ border: "1px solid #e0e0e0" }}
|
style={{ border: "1px solid #e0e0e0" }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{/* Judul */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Judul"
|
label="Judul"
|
||||||
placeholder="Masukkan judul"
|
placeholder="Masukkan judul"
|
||||||
@@ -227,6 +330,7 @@ function EditBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Kategori */}
|
||||||
<Select
|
<Select
|
||||||
value={formData.kategoriBeritaId}
|
value={formData.kategoriBeritaId}
|
||||||
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
||||||
@@ -241,9 +345,9 @@ function EditBerita() {
|
|||||||
clearable
|
clearable
|
||||||
searchable
|
searchable
|
||||||
required
|
required
|
||||||
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold">
|
<Text fz="sm" fw="bold">
|
||||||
Deskripsi Singkat
|
Deskripsi Singkat
|
||||||
@@ -256,11 +360,10 @@ function EditBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Featured Image */}
|
||||||
{/* Upload Gambar */}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar Berita
|
Gambar Utama (Featured)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -274,17 +377,13 @@ function EditBerita() {
|
|||||||
toast.error("File tidak valid, gunakan format gambar")
|
toast.error("File tidak valid, gunakan format gambar")
|
||||||
}
|
}
|
||||||
maxSize={5 * 1024 ** 2}
|
maxSize={5 * 1024 ** 2}
|
||||||
accept={{ "image/*": [] }}
|
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
radius="md"
|
radius="md"
|
||||||
p="xl"
|
p="xl"
|
||||||
>
|
>
|
||||||
<Group justify="center" gap="xl" mih={180}>
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
<Dropzone.Accept>
|
<Dropzone.Accept>
|
||||||
<IconUpload
|
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
|
||||||
size={48}
|
|
||||||
color={colors["blue-button"]}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</Dropzone.Accept>
|
</Dropzone.Accept>
|
||||||
<Dropzone.Reject>
|
<Dropzone.Reject>
|
||||||
<IconX size={48} color="red" stroke={1.5} />
|
<IconX size={48} color="red" stroke={1.5} />
|
||||||
@@ -292,14 +391,6 @@ function EditBerita() {
|
|||||||
<Dropzone.Idle>
|
<Dropzone.Idle>
|
||||||
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
||||||
</Dropzone.Idle>
|
</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>
|
</Group>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
@@ -328,9 +419,7 @@ function EditBerita() {
|
|||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -338,6 +427,138 @@ function EditBerita() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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 */}
|
{/* Konten */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold">
|
<Text fz="sm" fw="bold">
|
||||||
@@ -351,9 +572,8 @@ function EditBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Action */}
|
{/* Action Buttons */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
{/* Tombol Batal */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -363,8 +583,6 @@ function EditBerita() {
|
|||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tombol Simpan */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'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 { 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 { useParams, useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useProxy } from 'valtio/utils';
|
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 stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||||
import colors from '@/con/colors';
|
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() {
|
function DetailBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
const [modalHapus, setModalHapus] = useState(false);
|
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 (
|
return (
|
||||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
@@ -68,71 +85,131 @@ function DetailBerita() {
|
|||||||
|
|
||||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
{/* Kategori */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Kategori</Text>
|
<Text fz="lg" fw="bold">Kategori</Text>
|
||||||
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
|
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Judul */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Judul</Text>
|
<Text fz="lg" fw="bold">Judul</Text>
|
||||||
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
|
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
<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>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gambar Utama (Featured) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Gambar</Text>
|
<Text fz="lg" fw="bold">Gambar Utama</Text>
|
||||||
{data.image?.link ? (
|
{data.image?.link ? (
|
||||||
<Image
|
<Image
|
||||||
src={data.image.link}
|
src={data.image.link}
|
||||||
alt={data.judul || 'Gambar Berita'}
|
alt={data.judul || 'Gambar Berita'}
|
||||||
w={200}
|
w={{ base: '100%', md: 400 }}
|
||||||
h={200}
|
h={300}
|
||||||
radius="md"
|
radius="md"
|
||||||
fit="cover"
|
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>
|
</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>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Konten</Text>
|
<Text fz="lg" fw="bold">Konten</Text>
|
||||||
<Text
|
<Paper bg="white" p="md" radius="md" mt="xs">
|
||||||
fz="md"
|
<Text
|
||||||
c="dimmed"
|
fz="md"
|
||||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
c="dimmed"
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Buttons */}
|
||||||
<Group gap="sm">
|
<Group gap="sm" mt="md">
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedId(data.id);
|
setSelectedId(data.id);
|
||||||
setModalHapus(true);
|
setModalHapus(true);
|
||||||
}}
|
}}
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
leftSection={<IconTrash size={20} />}
|
||||||
<IconTrash size={20} />
|
>
|
||||||
</Button>
|
Hapus
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="green"
|
||||||
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
leftSection={<IconEdit size={20} />}
|
||||||
<IconEdit size={20} />
|
>
|
||||||
</Button>
|
Edit
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -15,26 +15,38 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Loader,
|
Loader,
|
||||||
ActionIcon
|
ActionIcon,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
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 { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
|
||||||
|
|
||||||
export default function CreateBerita() {
|
export default function CreateBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Featured image state
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const 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);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Helper function to check if HTML content is empty
|
// Helper function to check if HTML content is empty
|
||||||
const isHtmlEmpty = (html: string) => {
|
const isHtmlEmpty = (html: string) => {
|
||||||
// Remove all HTML tags and check if there's any text content
|
|
||||||
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
return textContent === '';
|
return textContent === '';
|
||||||
};
|
};
|
||||||
@@ -61,9 +73,35 @@ export default function CreateBerita() {
|
|||||||
kategoriBeritaId: '',
|
kategoriBeritaId: '',
|
||||||
imageId: '',
|
imageId: '',
|
||||||
content: '',
|
content: '',
|
||||||
|
imageIds: [],
|
||||||
|
linkVideo: '',
|
||||||
};
|
};
|
||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(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 () => {
|
const handleSubmit = async () => {
|
||||||
@@ -71,22 +109,22 @@ export default function CreateBerita() {
|
|||||||
toast.error('Judul wajib diisi');
|
toast.error('Judul wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!beritaState.berita.create.form.kategoriBeritaId) {
|
if (!beritaState.berita.create.form.kategoriBeritaId) {
|
||||||
toast.error('Kategori wajib dipilih');
|
toast.error('Kategori wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
|
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
|
||||||
toast.error('Deskripsi singkat wajib diisi');
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
toast.error('Gambar wajib dipilih');
|
toast.error('Gambar utama wajib dipilih');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
|
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
|
||||||
toast.error('Konten wajib diisi');
|
toast.error('Konten wajib diisi');
|
||||||
return;
|
return;
|
||||||
@@ -94,21 +132,37 @@ export default function CreateBerita() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
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,
|
file,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
});
|
});
|
||||||
|
const featuredUploaded = featuredRes.data?.data;
|
||||||
const uploaded = res.data?.data;
|
if (!featuredUploaded?.id) {
|
||||||
if (!uploaded?.id) {
|
return toast.error('Gagal mengunggah gambar utama');
|
||||||
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
|
|
||||||
}
|
}
|
||||||
|
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();
|
await beritaState.berita.create.create();
|
||||||
|
|
||||||
@@ -122,16 +176,13 @@ export default function CreateBerita() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header dengan tombol kembali */}
|
{/* Header */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Button
|
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||||
variant="subtle"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
p="xs"
|
|
||||||
radius="md"
|
|
||||||
>
|
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
@@ -148,6 +199,7 @@ export default function CreateBerita() {
|
|||||||
style={{ border: '1px solid #e0e0e0' }}
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{/* Judul */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Judul"
|
label="Judul"
|
||||||
placeholder="Masukkan judul berita"
|
placeholder="Masukkan judul berita"
|
||||||
@@ -156,6 +208,7 @@ export default function CreateBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Kategori */}
|
||||||
<Select
|
<Select
|
||||||
label="Kategori"
|
label="Kategori"
|
||||||
placeholder="Pilih kategori"
|
placeholder="Pilih kategori"
|
||||||
@@ -182,6 +235,7 @@ export default function CreateBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold" mb={6}>
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
Deskripsi Singkat
|
Deskripsi Singkat
|
||||||
@@ -194,9 +248,10 @@ export default function CreateBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Featured Image */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar Berita
|
Gambar Utama (Featured)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -232,17 +287,11 @@ export default function CreateBerita() {
|
|||||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
src={previewImage}
|
src={previewImage}
|
||||||
alt="Preview Gambar"
|
alt="Preview Gambar Utama"
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{
|
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||||
maxHeight: 200,
|
|
||||||
objectFit: 'contain',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
}}
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tombol hapus (pojok kanan atas) */}
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -255,9 +304,7 @@ export default function CreateBerita() {
|
|||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -265,6 +312,102 @@ export default function CreateBerita() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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>
|
<Box>
|
||||||
<Text fz="sm" fw="bold" mb={6}>
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
Konten
|
Konten
|
||||||
@@ -277,6 +420,7 @@ export default function CreateBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -287,8 +431,6 @@ export default function CreateBerita() {
|
|||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tombol Simpan */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
NumberInput,
|
||||||
|
Title,
|
||||||
|
Table,
|
||||||
|
TableThead,
|
||||||
|
TableTbody,
|
||||||
|
TableTr,
|
||||||
|
TableTh,
|
||||||
|
TableTd,
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Modal,
|
||||||
|
Divider,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
IconCalendar,
|
||||||
|
IconCoin,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
|
||||||
|
interface RealisasiManagerProps {
|
||||||
|
itemId: string;
|
||||||
|
itemKode: string;
|
||||||
|
itemUraian: string;
|
||||||
|
itemAnggaran: number;
|
||||||
|
itemTotalRealisasi: number;
|
||||||
|
itemPersentase: number;
|
||||||
|
realisasiItems: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RealisasiManager({
|
||||||
|
itemId,
|
||||||
|
itemKode,
|
||||||
|
itemUraian,
|
||||||
|
itemAnggaran,
|
||||||
|
itemTotalRealisasi,
|
||||||
|
itemPersentase,
|
||||||
|
realisasiItems,
|
||||||
|
}: RealisasiManagerProps) {
|
||||||
|
const state = useProxy(apbdes);
|
||||||
|
const [modalOpened, setModalOpened] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
jumlah: 0,
|
||||||
|
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
|
||||||
|
keterangan: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
jumlah: 0,
|
||||||
|
tanggal: new Date().toISOString().split('T')[0],
|
||||||
|
keterangan: '',
|
||||||
|
});
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
resetForm();
|
||||||
|
setModalOpened(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (realisasi: any) => {
|
||||||
|
const tanggal = new Date(realisasi.tanggal);
|
||||||
|
const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
jumlah: realisasi.jumlah,
|
||||||
|
tanggal: tanggalStr,
|
||||||
|
keterangan: realisasi.keterangan || '',
|
||||||
|
});
|
||||||
|
setEditingId(realisasi.id);
|
||||||
|
setModalOpened(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (formData.jumlah <= 0) {
|
||||||
|
return toast.warn('Jumlah realisasi harus lebih dari 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
// Update existing realisasi
|
||||||
|
const success = await state.realisasi.update(editingId, {
|
||||||
|
jumlah: formData.jumlah,
|
||||||
|
tanggal: new Date(formData.tanggal).toISOString(),
|
||||||
|
keterangan: formData.keterangan,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Realisasi berhasil diperbarui');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new realisasi
|
||||||
|
const success = await state.realisasi.create(itemId, {
|
||||||
|
jumlah: formData.jumlah,
|
||||||
|
tanggal: new Date(formData.tanggal).toISOString(),
|
||||||
|
keterangan: formData.keterangan,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Realisasi berhasil ditambahkan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalOpened(false);
|
||||||
|
resetForm();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving realisasi:', error);
|
||||||
|
toast.error(error?.message || 'Gagal menyimpan realisasi');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (realisasiId: string) => {
|
||||||
|
if (!confirm('Apakah Anda yakin ingin menghapus realisasi ini?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const success = await state.realisasi.delete(realisasiId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Realisasi berhasil dihapus');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting realisasi:', error);
|
||||||
|
toast.error(error?.message || 'Gagal menghapus realisasi');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRupiah = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSisaAnggaran = () => {
|
||||||
|
return itemAnggaran - itemTotalRealisasi;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPersentaseColor = (persen: number) => {
|
||||||
|
if (persen >= 100) return 'teal';
|
||||||
|
if (persen >= 80) return 'blue';
|
||||||
|
if (persen >= 60) return 'yellow';
|
||||||
|
return 'red';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" radius="md" mt="md">
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={6}>
|
||||||
|
{itemKode} - {itemUraian}
|
||||||
|
</Title>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
Kelola realisasi untuk item ini
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={18} />}
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Tambah Realisasi
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<Group grow mb="md">
|
||||||
|
<Paper withBorder p="md" radius="md" bg="blue.0">
|
||||||
|
<Text fz="xs" c="blue.9" fw={600}>
|
||||||
|
ANGGARAN
|
||||||
|
</Text>
|
||||||
|
<Text fz="lg" c="blue.9" fw={700}>
|
||||||
|
{formatRupiah(itemAnggaran)}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md" bg="teal.0">
|
||||||
|
<Text fz="xs" c="teal.9" fw={600}>
|
||||||
|
TOTAL REALISASI
|
||||||
|
</Text>
|
||||||
|
<Text fz="lg" c="teal.9" fw={700}>
|
||||||
|
{formatRupiah(itemTotalRealisasi)}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md" bg={getSisaAnggaran() >= 0 ? 'green.0' : 'red.0'}>
|
||||||
|
<Text fz="xs" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={600}>
|
||||||
|
SISA ANGGARAN
|
||||||
|
</Text>
|
||||||
|
<Text fz="lg" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={700}>
|
||||||
|
{formatRupiah(getSisaAnggaran())}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md" bg={getPersentaseColor(itemPersentase) + '.0'}>
|
||||||
|
<Text fz="xs" c={getPersentaseColor(itemPersentase) + '.9'} fw={600}>
|
||||||
|
PERSENTASE
|
||||||
|
</Text>
|
||||||
|
<Text fz="lg" c={getPersentaseColor(itemPersentase) + '.9'} fw={700}>
|
||||||
|
{itemPersentase.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Realisasi List */}
|
||||||
|
{realisasiItems && realisasiItems.length > 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} mb="xs">
|
||||||
|
Daftar Realisasi ({realisasiItems.length})
|
||||||
|
</Text>
|
||||||
|
<Box style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped highlightOnHover fz="sm">
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh>Tanggal</TableTh>
|
||||||
|
<TableTh>Uraian</TableTh>
|
||||||
|
<TableTh ta="right">Jumlah</TableTh>
|
||||||
|
<TableTh ta="center">Aksi</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{realisasiItems.map((realisasi) => (
|
||||||
|
<TableTr key={realisasi.id}>
|
||||||
|
<TableTd>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconCalendar size={16} />
|
||||||
|
<Text fz="sm">{formatDate(realisasi.tanggal)}</Text>
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="sm">{realisasi.keterangan || '-'}</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd ta="right">
|
||||||
|
<Text fz="sm" fw={600} c="blue">
|
||||||
|
{formatRupiah(realisasi.jumlah)}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd ta="center">
|
||||||
|
<Group gap="xs" justify="center">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenEdit(realisasi)}
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(realisasi.id)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Center py="xl">
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
Belum ada realisasi untuk item ini
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Klik tombol "Tambah Realisasi" untuk menambahkan
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Create/Edit */}
|
||||||
|
<Modal
|
||||||
|
opened={modalOpened}
|
||||||
|
onClose={() => {
|
||||||
|
setModalOpened(false);
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
<Text fz="lg" fw={600}>
|
||||||
|
{editingId ? 'Edit Realisasi' : 'Tambah Realisasi Baru'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size="md"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Info Item */}
|
||||||
|
<Paper p="sm" bg="gray.0" radius="md">
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Item: {itemKode} - {itemUraian}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Anggaran: {formatRupiah(itemAnggaran)}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Sudah terealisasi: {formatRupiah(itemTotalRealisasi)}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Jumlah Realisasi (Rp)"
|
||||||
|
value={formData.jumlah}
|
||||||
|
onChange={(val) => setFormData({ ...formData, jumlah: Number(val) || 0 })}
|
||||||
|
leftSection={<IconCoin size={16} />}
|
||||||
|
thousandSeparator
|
||||||
|
min={0}
|
||||||
|
step={100000}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Tanggal Realisasi"
|
||||||
|
type="date"
|
||||||
|
value={formData.tanggal}
|
||||||
|
onChange={(e) => setFormData({ ...formData, tanggal: e.target.value })}
|
||||||
|
leftSection={<IconCalendar size={18} />}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Keterangan / Uraian"
|
||||||
|
placeholder="Contoh: Penyaluran BLT Tahap 1"
|
||||||
|
value={formData.keterangan}
|
||||||
|
onChange={(e) => setFormData({ ...formData, keterangan: e.target.value })}
|
||||||
|
description="Deskripsi singkat tentang realisasi ini"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider my="xs" />
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpened(false);
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
color="blue"
|
||||||
|
leftSection={editingId ? <IconEdit size={16} /> : <IconPlus size={16} />}
|
||||||
|
>
|
||||||
|
{editingId ? 'Perbarui' : 'Tambah'} Realisasi
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,6 @@ type ItemForm = {
|
|||||||
kode: string;
|
kode: string;
|
||||||
uraian: string;
|
uraian: string;
|
||||||
anggaran: number;
|
anggaran: number;
|
||||||
realisasi: number;
|
|
||||||
level: number;
|
level: number;
|
||||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||||
};
|
};
|
||||||
@@ -71,7 +70,6 @@ function EditAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
@@ -79,6 +77,9 @@ function EditAPBDes() {
|
|||||||
// Simpan data original untuk reset form
|
// Simpan data original untuk reset form
|
||||||
const [originalData, setOriginalData] = useState({
|
const [originalData, setOriginalData] = useState({
|
||||||
tahun: 0,
|
tahun: 0,
|
||||||
|
name: '',
|
||||||
|
deskripsi: '',
|
||||||
|
jumlah: '',
|
||||||
imageId: '',
|
imageId: '',
|
||||||
fileId: '',
|
fileId: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
@@ -103,6 +104,9 @@ function EditAPBDes() {
|
|||||||
// Simpan data original untuk reset
|
// Simpan data original untuk reset
|
||||||
setOriginalData({
|
setOriginalData({
|
||||||
tahun: data.tahun || new Date().getFullYear(),
|
tahun: data.tahun || new Date().getFullYear(),
|
||||||
|
name: data.name || '',
|
||||||
|
deskripsi: data.deskripsi || '',
|
||||||
|
jumlah: data.jumlah || '',
|
||||||
imageId: data.imageId || '',
|
imageId: data.imageId || '',
|
||||||
fileId: data.fileId || '',
|
fileId: data.fileId || '',
|
||||||
imageUrl: data.image?.link || '',
|
imageUrl: data.image?.link || '',
|
||||||
@@ -112,6 +116,9 @@ function EditAPBDes() {
|
|||||||
// Set form dengan data lama (termasuk imageId dan fileId)
|
// Set form dengan data lama (termasuk imageId dan fileId)
|
||||||
apbdesState.edit.form = {
|
apbdesState.edit.form = {
|
||||||
tahun: data.tahun || new Date().getFullYear(),
|
tahun: data.tahun || new Date().getFullYear(),
|
||||||
|
name: data.name || '',
|
||||||
|
deskripsi: data.deskripsi || '',
|
||||||
|
jumlah: data.jumlah || '',
|
||||||
imageId: data.imageId || '',
|
imageId: data.imageId || '',
|
||||||
fileId: data.fileId || '',
|
fileId: data.fileId || '',
|
||||||
items: (data.items || []).map((item: any) => ({
|
items: (data.items || []).map((item: any) => ({
|
||||||
@@ -148,32 +155,25 @@ function EditAPBDes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
const { kode, uraian, anggaran, level, tipe } = newItem;
|
||||||
if (!kode || !uraian) {
|
if (!kode || !uraian) {
|
||||||
return toast.warn('Kode dan uraian wajib diisi');
|
return toast.warn('Kode dan uraian wajib diisi');
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTipe = level === 1 ? null : tipe;
|
const finalTipe = level === 1 ? null : tipe;
|
||||||
const selisih = realisasi - anggaran;
|
|
||||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
|
||||||
|
|
||||||
apbdesState.edit.addItem({
|
apbdesState.edit.addItem({
|
||||||
kode,
|
kode,
|
||||||
uraian,
|
uraian,
|
||||||
anggaran,
|
anggaran,
|
||||||
realisasi,
|
|
||||||
selisih,
|
|
||||||
persentase,
|
|
||||||
level,
|
level,
|
||||||
tipe: finalTipe, // ✅ Tidak akan undefined
|
tipe: finalTipe,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
setNewItem({
|
setNewItem({
|
||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
@@ -238,9 +238,12 @@ function EditAPBDes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
// Reset ke data original (tahun, imageId, fileId)
|
// Reset ke data original (tahun, name, deskripsi, jumlah, imageId, fileId)
|
||||||
apbdesState.edit.form = {
|
apbdesState.edit.form = {
|
||||||
tahun: originalData.tahun,
|
tahun: originalData.tahun,
|
||||||
|
name: originalData.name,
|
||||||
|
deskripsi: originalData.deskripsi,
|
||||||
|
jumlah: originalData.jumlah,
|
||||||
imageId: originalData.imageId,
|
imageId: originalData.imageId,
|
||||||
fileId: originalData.fileId,
|
fileId: originalData.fileId,
|
||||||
items: [...apbdesState.edit.form.items], // keep existing items
|
items: [...apbdesState.edit.form.items], // keep existing items
|
||||||
@@ -249,21 +252,20 @@ function EditAPBDes() {
|
|||||||
// Reset preview ke data original
|
// Reset preview ke data original
|
||||||
setPreviewImage(originalData.imageUrl || null);
|
setPreviewImage(originalData.imageUrl || null);
|
||||||
setPreviewDoc(originalData.fileUrl || null);
|
setPreviewDoc(originalData.fileUrl || null);
|
||||||
|
|
||||||
// Reset file uploads
|
// Reset file uploads
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
setDocFile(null);
|
setDocFile(null);
|
||||||
|
|
||||||
// Reset new item form
|
// Reset new item form
|
||||||
setNewItem({
|
setNewItem({
|
||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.info('Form dikembalikan ke data awal');
|
toast.info('Form dikembalikan ke data awal');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,6 +290,33 @@ function EditAPBDes() {
|
|||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Header Form */}
|
{/* 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
|
<NumberInput
|
||||||
label="Tahun"
|
label="Tahun"
|
||||||
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
|
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
|
||||||
@@ -475,13 +504,6 @@ function EditAPBDes() {
|
|||||||
thousandSeparator
|
thousandSeparator
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
|
||||||
label="Realisasi (Rp)"
|
|
||||||
value={newItem.realisasi}
|
|
||||||
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
|
|
||||||
thousandSeparator
|
|
||||||
min={0}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconPlus size={16} />}
|
leftSection={<IconPlus size={16} />}
|
||||||
@@ -505,7 +527,6 @@ function EditAPBDes() {
|
|||||||
<th>Kode</th>
|
<th>Kode</th>
|
||||||
<th>Uraian</th>
|
<th>Uraian</th>
|
||||||
<th>Anggaran</th>
|
<th>Anggaran</th>
|
||||||
<th>Realisasi</th>
|
|
||||||
<th>Level</th>
|
<th>Level</th>
|
||||||
<th>Tipe</th>
|
<th>Tipe</th>
|
||||||
<th style={{ width: '50px' }}>Aksi</th>
|
<th style={{ width: '50px' }}>Aksi</th>
|
||||||
@@ -521,7 +542,6 @@ function EditAPBDes() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{item.uraian}</td>
|
<td>{item.uraian}</td>
|
||||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||||
<td>{item.realisasi.toLocaleString('id-ID')}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||||
L{item.level}
|
L{item.level}
|
||||||
@@ -533,7 +553,7 @@ function EditAPBDes() {
|
|||||||
{item.tipe}
|
{item.tipe}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
<Text size="sm" c="dimmed">-</Text>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||||
import apbdes from '../../../_state/landing-page/apbdes';
|
import apbdes from '../../../_state/landing-page/apbdes';
|
||||||
|
import RealisasiManager from './RealisasiManager';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ function DetailAPBDes() {
|
|||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Nama APBDes</Text>
|
<Text fz="lg" fw="bold">Nama APBDes</Text>
|
||||||
<Text fz="md" c="dimmed">
|
<Text fz="md" c="dimmed">
|
||||||
{data.name || '-'}
|
{data.name || `APBDes Tahun ${data.tahun}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -105,6 +106,24 @@ function DetailAPBDes() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Gambar</Text>
|
<Text fz="lg" fw="bold">Gambar</Text>
|
||||||
{data.image?.link ? (
|
{data.image?.link ? (
|
||||||
@@ -173,48 +192,60 @@ function DetailAPBDes() {
|
|||||||
|
|
||||||
{/* Tabel Items */}
|
{/* Tabel Items */}
|
||||||
{data.items && data.items.length > 0 ? (
|
{data.items && data.items.length > 0 ? (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Stack gap="md">
|
||||||
<Text fz="lg" fw="bold" mb="sm">
|
<Text fz="lg" fw="bold">
|
||||||
Rincian Pendapatan & Belanja ({data.items.length} item)
|
Rincian Pendapatan & Belanja ({data.items.length} item)
|
||||||
</Text>
|
</Text>
|
||||||
<Box style={{ overflowX: 'auto' }}>
|
<Table striped highlightOnHover>
|
||||||
<Table striped highlightOnHover>
|
<TableThead>
|
||||||
<TableThead>
|
<TableTr>
|
||||||
<TableTr>
|
<TableTh>Uraian</TableTh>
|
||||||
<TableTh>Uraian</TableTh>
|
<TableTh>Anggaran (Rp)</TableTh>
|
||||||
<TableTh>Anggaran (Rp)</TableTh>
|
<TableTh>Realisasi (Rp)</TableTh>
|
||||||
<TableTh>Realisasi (Rp)</TableTh>
|
<TableTh>Selisih (Rp)</TableTh>
|
||||||
<TableTh>Selisih (Rp)</TableTh>
|
<TableTh>Persentase (%)</TableTh>
|
||||||
<TableTh>Persentase (%)</TableTh>
|
</TableTr>
|
||||||
</TableTr>
|
</TableThead>
|
||||||
</TableThead>
|
<TableTbody>
|
||||||
<TableTbody>
|
{[...data.items]
|
||||||
{[...data.items] // Create a new array before sorting
|
.sort((a, b) => a.kode.localeCompare(b.kode))
|
||||||
.sort((a, b) => a.kode.localeCompare(b.kode))
|
.map((item) => (
|
||||||
.map((item) => (
|
<TableTr key={item.id}>
|
||||||
<TableTr key={item.id}>
|
<TableTd style={getIndent(item.level)}>
|
||||||
<TableTd style={getIndent(item.level)}>
|
<Group>
|
||||||
<Group>
|
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
||||||
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
||||||
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
</Group>
|
||||||
</Group>
|
</TableTd>
|
||||||
</TableTd>
|
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
||||||
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
<TableTd>{item.totalRealisasi.toLocaleString('id-ID')}</TableTd>
|
||||||
<TableTd>{item.realisasi.toLocaleString('id-ID')}</TableTd>
|
<TableTd>
|
||||||
<TableTd>
|
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
||||||
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
{item.selisih.toLocaleString('id-ID')}
|
||||||
{item.selisih.toLocaleString('id-ID')}
|
</Text>
|
||||||
</Text>
|
</TableTd>
|
||||||
</TableTd>
|
<TableTd>
|
||||||
<TableTd>
|
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
||||||
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
</TableTd>
|
||||||
</TableTd>
|
</TableTr>
|
||||||
</TableTr>
|
))}
|
||||||
))}
|
</TableTbody>
|
||||||
</TableTbody>
|
</Table>
|
||||||
</Table>
|
|
||||||
</Box>
|
{/* Realisasi Manager untuk setiap item */}
|
||||||
</Paper>
|
{data.items.map((item: any) => (
|
||||||
|
<RealisasiManager
|
||||||
|
key={item.id}
|
||||||
|
itemId={item.id}
|
||||||
|
itemKode={item.kode}
|
||||||
|
itemUraian={item.uraian}
|
||||||
|
itemAnggaran={item.anggaran}
|
||||||
|
itemTotalRealisasi={item.totalRealisasi}
|
||||||
|
itemPersentase={item.persentase}
|
||||||
|
realisasiItems={item.realisasiItems || []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Text>Belum ada data item</Text>
|
<Text>Belum ada data item</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ type ItemForm = {
|
|||||||
kode: string;
|
kode: string;
|
||||||
uraian: string;
|
uraian: string;
|
||||||
anggaran: number;
|
anggaran: number;
|
||||||
realisasi: number;
|
|
||||||
level: number;
|
level: number;
|
||||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||||
};
|
};
|
||||||
@@ -61,7 +60,6 @@ function CreateAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
@@ -80,7 +78,6 @@ function CreateAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
@@ -117,9 +114,9 @@ function CreateAPBDes() {
|
|||||||
toast.success("Berhasil menambahkan APBDes");
|
toast.success("Berhasil menambahkan APBDes");
|
||||||
resetForm();
|
resetForm();
|
||||||
router.push("/admin/landing-page/apbdes");
|
router.push("/admin/landing-page/apbdes");
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Gagal submit:", error);
|
console.error("Gagal submit:", error);
|
||||||
toast.error("Gagal menyimpan data");
|
toast.error(error?.message || "Gagal menyimpan data");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -127,22 +124,17 @@ function CreateAPBDes() {
|
|||||||
|
|
||||||
// Tambahkan item ke state
|
// Tambahkan item ke state
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
const { kode, uraian, anggaran, level, tipe } = newItem;
|
||||||
if (!kode || !uraian) {
|
if (!kode || !uraian) {
|
||||||
return toast.warn("Kode dan uraian wajib diisi");
|
return toast.warn("Kode dan uraian wajib diisi");
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTipe = level === 1 ? null : tipe;
|
const finalTipe = level === 1 ? null : tipe;
|
||||||
const selisih = realisasi - anggaran;
|
|
||||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
|
||||||
|
|
||||||
stateAPBDes.create.addItem({
|
stateAPBDes.create.addItem({
|
||||||
kode,
|
kode,
|
||||||
uraian,
|
uraian,
|
||||||
anggaran,
|
anggaran,
|
||||||
realisasi,
|
|
||||||
selisih,
|
|
||||||
persentase,
|
|
||||||
level,
|
level,
|
||||||
tipe: finalTipe,
|
tipe: finalTipe,
|
||||||
});
|
});
|
||||||
@@ -152,7 +144,6 @@ function CreateAPBDes() {
|
|||||||
kode: '',
|
kode: '',
|
||||||
uraian: '',
|
uraian: '',
|
||||||
anggaran: 0,
|
anggaran: 0,
|
||||||
realisasi: 0,
|
|
||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
@@ -334,6 +325,27 @@ function CreateAPBDes() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Form Header */}
|
{/* 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
|
<NumberInput
|
||||||
label="Tahun"
|
label="Tahun"
|
||||||
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
|
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
|
||||||
@@ -406,13 +418,6 @@ function CreateAPBDes() {
|
|||||||
thousandSeparator
|
thousandSeparator
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
|
||||||
label="Realisasi (Rp)"
|
|
||||||
value={newItem.realisasi}
|
|
||||||
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
|
|
||||||
thousandSeparator
|
|
||||||
min={0}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconPlus size={16} />}
|
leftSection={<IconPlus size={16} />}
|
||||||
@@ -434,28 +439,30 @@ function CreateAPBDes() {
|
|||||||
<th>Kode</th>
|
<th>Kode</th>
|
||||||
<th>Uraian</th>
|
<th>Uraian</th>
|
||||||
<th>Anggaran</th>
|
<th>Anggaran</th>
|
||||||
<th>Realisasi</th>
|
|
||||||
<th>Level</th>
|
<th>Level</th>
|
||||||
<th>Tipe</th>
|
<th>Tipe</th>
|
||||||
<th style={{ width: 50 }}>Aksi</th>
|
<th style={{ width: 50 }}>Aksi</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{stateAPBDes.create.form.items.map((item, idx) => (
|
{stateAPBDes.create.form.items.map((item: any, idx) => (
|
||||||
<tr key={idx}>
|
<tr key={idx}>
|
||||||
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
|
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
|
||||||
<td>{item.uraian}</td>
|
<td>{item.uraian}</td>
|
||||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||||
<td>{item.realisasi.toLocaleString('id-ID')}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||||
L{item.level}
|
L{item.level}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
{item.tipe ? (
|
||||||
{item.tipe}
|
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
||||||
</Badge>
|
{item.tipe}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">-</Text>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function APBDes() {
|
|||||||
function ListAPBDes({ search }: { search: string }) {
|
function ListAPBDes({ search }: { search: string }) {
|
||||||
const listState = useProxy(apbdes);
|
const listState = useProxy(apbdes);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
|
||||||
const { data, page, totalPages, loading, load } = listState.findMany;
|
const { data, page, totalPages, loading, load } = listState.findMany;
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
type FormCreate = Prisma.BeritaGetPayload<{
|
type FormCreate = {
|
||||||
select: {
|
judul: string;
|
||||||
judul: true;
|
deskripsi: string;
|
||||||
deskripsi: true;
|
content: string;
|
||||||
content: true;
|
kategoriBeritaId: string;
|
||||||
kategoriBeritaId: true;
|
imageId: string; // Featured image
|
||||||
imageId: true;
|
imageIds?: string[]; // Multiple images for gallery
|
||||||
};
|
linkVideo?: string; // YouTube link
|
||||||
}>;
|
};
|
||||||
|
|
||||||
async function beritaCreate(context: Context) {
|
async function beritaCreate(context: Context) {
|
||||||
const body = context.body as FormCreate;
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
await prisma.berita.create({
|
await prisma.berita.create({
|
||||||
data: {
|
data: {
|
||||||
content: body.content,
|
content: body.content,
|
||||||
deskripsi: body.deskripsi,
|
deskripsi: body.deskripsi,
|
||||||
imageId: body.imageId,
|
imageId: body.imageId,
|
||||||
judul: body.judul,
|
judul: body.judul,
|
||||||
kategoriBeritaId: body.kategoriBeritaId,
|
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 },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
|
images: true,
|
||||||
kategoriBerita: true,
|
kategoriBerita: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
|
|||||||
imageId: t.String(),
|
imageId: t.String(),
|
||||||
content: t.String(),
|
content: t.String(),
|
||||||
kategoriBeritaId: t.Union([t.String(), t.Null()]),
|
kategoriBeritaId: t.Union([t.String(), t.Null()]),
|
||||||
|
imageIds: t.Array(t.String()),
|
||||||
|
linkVideo: t.Optional(t.String()),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.get("/find-first", beritaFindFirst)
|
.get("/find-first", beritaFindFirst)
|
||||||
@@ -39,6 +41,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
|
|||||||
imageId: t.String(),
|
imageId: t.String(),
|
||||||
content: t.String(),
|
content: t.String(),
|
||||||
kategoriBeritaId: t.Union([t.String(), t.Null()]),
|
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 fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
type FormUpdate = Prisma.BeritaGetPayload<{
|
type FormUpdate = {
|
||||||
select: {
|
id: string;
|
||||||
id: true;
|
judul: string;
|
||||||
judul: true;
|
deskripsi: string;
|
||||||
deskripsi: true;
|
content: string;
|
||||||
content: true;
|
kategoriBeritaId: string;
|
||||||
kategoriBeritaId: true;
|
imageId: string; // Featured image
|
||||||
imageId: true;
|
imageIds?: string[]; // Multiple images for gallery
|
||||||
};
|
linkVideo?: string; // YouTube link
|
||||||
}>;
|
};
|
||||||
|
|
||||||
async function beritaUpdate(context: Context) {
|
async function beritaUpdate(context: Context) {
|
||||||
try {
|
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 body = (await context.body) as Omit<FormUpdate, "id">;
|
||||||
|
|
||||||
const {
|
const { judul, deskripsi, content, kategoriBeritaId, imageId, imageIds, linkVideo } = body;
|
||||||
judul,
|
|
||||||
deskripsi,
|
|
||||||
content,
|
|
||||||
kategoriBeritaId,
|
|
||||||
imageId,
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: false, message: "ID tidak boleh kosong" }),
|
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({
|
const existing = await prisma.berita.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
|
images: true, // Include gallery images
|
||||||
kategoriBerita: true,
|
kategoriBerita: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: false, message: "Berita tidak ditemukan" }),
|
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) {
|
if (existing.imageId && existing.imageId !== imageId) {
|
||||||
const oldImage = existing.image;
|
const oldImage = existing.image;
|
||||||
if (oldImage) {
|
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({
|
const updated = await prisma.berita.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: updateData,
|
||||||
judul,
|
include: {
|
||||||
deskripsi,
|
image: true,
|
||||||
content,
|
images: true,
|
||||||
kategoriBeritaId: kategoriBeritaId || null,
|
kategoriBerita: true,
|
||||||
imageId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Berita berhasil diupdate",
|
message: "Berita berhasil diupdate",
|
||||||
data: updated,
|
data: updated,
|
||||||
}),
|
}),
|
||||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating berita:", error);
|
console.error("Error updating berita:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Terjadi kesalahan saat mengupdate berita",
|
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;
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ type APBDesItemInput = {
|
|||||||
kode: string;
|
kode: string;
|
||||||
uraian: string;
|
uraian: string;
|
||||||
anggaran: number;
|
anggaran: number;
|
||||||
realisasi: number;
|
|
||||||
selisih: number;
|
|
||||||
persentase: number;
|
|
||||||
level: number;
|
level: number;
|
||||||
tipe?: string | null;
|
tipe?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormCreate = {
|
type FormCreate = {
|
||||||
tahun: number;
|
tahun: number;
|
||||||
|
name?: string;
|
||||||
|
deskripsi?: string;
|
||||||
|
jumlah?: string;
|
||||||
imageId: string;
|
imageId: string;
|
||||||
fileId: string;
|
fileId: string;
|
||||||
items: APBDesItemInput[];
|
items: APBDesItemInput[];
|
||||||
@@ -24,8 +24,7 @@ type FormCreate = {
|
|||||||
|
|
||||||
export default async function apbdesCreate(context: Context) {
|
export default async function apbdesCreate(context: Context) {
|
||||||
const body = context.body as FormCreate;
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
// Log the incoming request for debugging
|
|
||||||
console.log('Incoming request body:', JSON.stringify(body, null, 2));
|
console.log('Incoming request body:', JSON.stringify(body, null, 2));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -43,33 +42,39 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
throw new Error('At least one item is required');
|
throw new Error('At least one item is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Buat APBDes + items (tanpa parentId dulu)
|
// 1. Buat APBDes + items dengan auto-calculate fields
|
||||||
const created = await prisma.$transaction(async (prisma) => {
|
const created = await prisma.$transaction(async (prisma) => {
|
||||||
const apbdes = await prisma.aPBDes.create({
|
const apbdes = await prisma.aPBDes.create({
|
||||||
data: {
|
data: {
|
||||||
tahun: body.tahun,
|
tahun: body.tahun,
|
||||||
name: `APBDes Tahun ${body.tahun}`,
|
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||||
|
deskripsi: body.deskripsi,
|
||||||
|
jumlah: body.jumlah,
|
||||||
imageId: body.imageId,
|
imageId: body.imageId,
|
||||||
fileId: body.fileId,
|
fileId: body.fileId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create items in a batch
|
// Create items dengan auto-calculate totalRealisasi=0, selisih, persentase
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
body.items.map(item => {
|
body.items.map(async item => {
|
||||||
// Create a new object with only the fields that exist in the APBDesItem model
|
const anggaran = item.anggaran;
|
||||||
|
const totalRealisasi = 0; // Belum ada realisasi saat create
|
||||||
|
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||||
|
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
|
||||||
|
|
||||||
const itemData = {
|
const itemData = {
|
||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
uraian: item.uraian,
|
uraian: item.uraian,
|
||||||
anggaran: item.anggaran,
|
anggaran: anggaran,
|
||||||
realisasi: item.realisasi,
|
|
||||||
selisih: item.selisih,
|
|
||||||
persentase: item.persentase,
|
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe, // ✅ sertakan, biar null
|
tipe: item.tipe || null,
|
||||||
|
totalRealisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
apbdesId: apbdes.id,
|
apbdesId: apbdes.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
return prisma.aPBDesItem.create({
|
return prisma.aPBDesItem.create({
|
||||||
data: itemData,
|
data: itemData,
|
||||||
select: { id: true, kode: true },
|
select: { id: true, kode: true },
|
||||||
@@ -84,20 +89,27 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
// 2. Isi parentId berdasarkan kode
|
// 2. Isi parentId berdasarkan kode
|
||||||
await assignParentIdsToApbdesItems(created.items);
|
await assignParentIdsToApbdesItems(created.items);
|
||||||
|
|
||||||
// 3. Ambil ulang data lengkap untuk response
|
// 3. Ambil ulang data lengkap untuk response (include realisasiItems)
|
||||||
const result = await prisma.aPBDes.findUnique({
|
const result = await prisma.aPBDes.findUnique({
|
||||||
where: { id: created.id },
|
where: { id: created.id },
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
file: true,
|
file: true,
|
||||||
items: {
|
items: {
|
||||||
|
where: { isActive: true },
|
||||||
orderBy: { kode: 'asc' },
|
orderBy: { kode: 'asc' },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { tanggal: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
|
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Berhasil membuat APBDes",
|
message: "Berhasil membuat APBDes",
|
||||||
@@ -105,7 +117,6 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
};
|
};
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
console.error('Error in post-creation steps:', innerError);
|
console.error('Error in post-creation steps:', innerError);
|
||||||
// Even if post-creation steps fail, we still return success since the main record was created
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
|
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
|
||||||
@@ -115,13 +126,12 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error creating APBDes:", error);
|
console.error("Error creating APBDes:", error);
|
||||||
|
|
||||||
// Log the full error for debugging
|
|
||||||
if (error.code) console.error('Prisma error code:', error.code);
|
if (error.code) console.error('Prisma error code:', error.code);
|
||||||
if (error.meta) console.error('Prisma error meta:', error.meta);
|
if (error.meta) console.error('Prisma error meta:', error.meta);
|
||||||
|
|
||||||
const errorMessage = error.message || 'Unknown error';
|
const errorMessage = error.message || 'Unknown error';
|
||||||
|
|
||||||
context.set.status = 500;
|
context.set.status = 500;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function apbdesFindMany(context: Context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const where: any = { isActive: true };
|
const where: any = { isActive: true };
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: search, mode: "insensitive" } },
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
@@ -47,7 +47,16 @@ export default async function apbdesFindMany(context: Context) {
|
|||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
file: true,
|
file: true,
|
||||||
items: true,
|
items: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { kode: "asc" },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { tanggal: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.aPBDes.count({ where }),
|
prisma.aPBDes.count({ where }),
|
||||||
|
|||||||
@@ -2,15 +2,9 @@ import prisma from "@/lib/prisma";
|
|||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
export default async function apbdesFindUnique(context: Context) {
|
export default async function apbdesFindUnique(context: Context) {
|
||||||
// ✅ Parse URL secara manual
|
|
||||||
const url = new URL(context.request.url);
|
const url = new URL(context.request.url);
|
||||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
console.log("🔍 DEBUG INFO:");
|
|
||||||
console.log("- Full URL:", context.request.url);
|
|
||||||
console.log("- Pathname:", url.pathname);
|
|
||||||
console.log("- Path segments:", pathSegments);
|
|
||||||
|
|
||||||
// Expected: ['api', 'landingpage', 'apbdes', 'ID']
|
// Expected: ['api', 'landingpage', 'apbdes', 'ID']
|
||||||
if (pathSegments.length < 4) {
|
if (pathSegments.length < 4) {
|
||||||
context.set.status = 400;
|
context.set.status = 400;
|
||||||
@@ -20,9 +14,9 @@ export default async function apbdesFindUnique(context: Context) {
|
|||||||
debug: { pathSegments }
|
debug: { pathSegments }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathSegments[0] !== 'api' ||
|
if (pathSegments[0] !== 'api' ||
|
||||||
pathSegments[1] !== 'landingpage' ||
|
pathSegments[1] !== 'landingpage' ||
|
||||||
pathSegments[2] !== 'apbdes') {
|
pathSegments[2] !== 'apbdes') {
|
||||||
context.set.status = 400;
|
context.set.status = 400;
|
||||||
return {
|
return {
|
||||||
@@ -31,9 +25,9 @@ export default async function apbdesFindUnique(context: Context) {
|
|||||||
debug: { pathSegments }
|
debug: { pathSegments }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = pathSegments[3]; // ✅ ID ada di index ke-3
|
const id = pathSegments[3];
|
||||||
|
|
||||||
if (!id || id.trim() === '') {
|
if (!id || id.trim() === '') {
|
||||||
context.set.status = 400;
|
context.set.status = 400;
|
||||||
return {
|
return {
|
||||||
@@ -48,11 +42,17 @@ export default async function apbdesFindUnique(context: Context) {
|
|||||||
include: {
|
include: {
|
||||||
items: {
|
items: {
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { kode: 'asc' }
|
orderBy: { kode: 'asc' },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { tanggal: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
image: true,
|
image: true,
|
||||||
file: true
|
file: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result || !result.isActive) {
|
if (!result || !result.isActive) {
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import apbdesDelete from "./del";
|
|||||||
import apbdesFindMany from "./findMany";
|
import apbdesFindMany from "./findMany";
|
||||||
import apbdesFindUnique from "./findUnique";
|
import apbdesFindUnique from "./findUnique";
|
||||||
import apbdesUpdate from "./updt";
|
import apbdesUpdate from "./updt";
|
||||||
|
import realisasiCreate from "./realisasi/create";
|
||||||
|
import realisasiUpdate from "./realisasi/update";
|
||||||
|
import realisasiDelete from "./realisasi/delete";
|
||||||
|
|
||||||
// Definisikan skema untuk item APBDes
|
// Definisikan skema untuk item APBDes (tanpa realisasi field)
|
||||||
const ApbdesItemSchema = t.Object({
|
const ApbdesItemSchema = t.Object({
|
||||||
kode: t.String(),
|
kode: t.String(),
|
||||||
uraian: t.String(),
|
uraian: t.String(),
|
||||||
anggaran: t.Number(),
|
anggaran: t.Number(),
|
||||||
realisasi: t.Number(),
|
|
||||||
selisih: t.Number(),
|
|
||||||
persentase: t.Number(),
|
|
||||||
level: t.Number(),
|
level: t.Number(),
|
||||||
tipe: t.Optional(t.Union([t.String(), t.Null()])) // misal: "pendapatan" atau "belanja"
|
tipe: t.Optional(t.Union([t.String(), t.Null()])), // "pendapatan" | "belanja" | "pembiayaan" | null
|
||||||
});
|
});
|
||||||
|
|
||||||
const APBDes = new Elysia({
|
const APBDes = new Elysia({
|
||||||
@@ -26,33 +26,70 @@ const APBDes = new Elysia({
|
|||||||
// ✅ Find all (dengan query opsional: page, limit, tahun)
|
// ✅ Find all (dengan query opsional: page, limit, tahun)
|
||||||
.get("/findMany", apbdesFindMany)
|
.get("/findMany", apbdesFindMany)
|
||||||
|
|
||||||
// ✅ Find by ID
|
// ✅ Find by ID (include realisasiItems)
|
||||||
.get("/:id", apbdesFindUnique)
|
.get("/:id", apbdesFindUnique)
|
||||||
|
|
||||||
// ✅ Create
|
// ✅ Create APBDes dengan items (tanpa realisasi)
|
||||||
.post("/create", apbdesCreate, {
|
.post("/create", apbdesCreate, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
tahun: t.Number(),
|
tahun: t.Number(),
|
||||||
|
name: t.Optional(t.String()),
|
||||||
|
deskripsi: t.Optional(t.String()),
|
||||||
|
jumlah: t.Optional(t.String()),
|
||||||
imageId: t.String(),
|
imageId: t.String(),
|
||||||
fileId: t.String(),
|
fileId: t.String(),
|
||||||
items: t.Array(ApbdesItemSchema),
|
items: t.Array(ApbdesItemSchema),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ✅ Update
|
// ✅ Update APBDes dengan items (tanpa realisasi)
|
||||||
.put("/:id", apbdesUpdate, {
|
.put("/:id", apbdesUpdate, {
|
||||||
params: t.Object({ id: t.String() }),
|
params: t.Object({ id: t.String() }),
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
tahun: t.Number(),
|
tahun: t.Number(),
|
||||||
|
name: t.Optional(t.String()),
|
||||||
|
deskripsi: t.Optional(t.String()),
|
||||||
|
jumlah: t.Optional(t.String()),
|
||||||
imageId: t.String(),
|
imageId: t.String(),
|
||||||
fileId: t.String(),
|
fileId: t.String(),
|
||||||
items: t.Array(ApbdesItemSchema),
|
items: t.Array(ApbdesItemSchema),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ✅ Delete
|
// ✅ Delete APBDes
|
||||||
.delete("/del/:id", apbdesDelete, {
|
.delete("/del/:id", apbdesDelete, {
|
||||||
params: t.Object({ id: t.String() }),
|
params: t.Object({ id: t.String() }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// REALISASI ENDPOINTS
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
// ✅ Create realisasi untuk item tertentu
|
||||||
|
.post("/:itemId/realisasi", realisasiCreate, {
|
||||||
|
params: t.Object({ itemId: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
jumlah: t.Number(),
|
||||||
|
tanggal: t.String(),
|
||||||
|
keterangan: t.Optional(t.String()),
|
||||||
|
buktiFileId: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ Update realisasi
|
||||||
|
.put("/realisasi/:realisasiId", realisasiUpdate, {
|
||||||
|
params: t.Object({ realisasiId: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
jumlah: t.Optional(t.Number()),
|
||||||
|
tanggal: t.Optional(t.String()),
|
||||||
|
keterangan: t.Optional(t.String()),
|
||||||
|
buktiFileId: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ Delete realisasi
|
||||||
|
.delete("/realisasi/:realisasiId", realisasiDelete, {
|
||||||
|
params: t.Object({ realisasiId: t.String() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default APBDes;
|
export default APBDes;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
type RealisasiCreateBody = {
|
||||||
|
jumlah: number;
|
||||||
|
tanggal: string; // ISO format
|
||||||
|
keterangan?: string;
|
||||||
|
buktiFileId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function realisasiCreate(context: Context) {
|
||||||
|
const { itemId } = context.params as { itemId: string };
|
||||||
|
const body = context.body as RealisasiCreateBody;
|
||||||
|
|
||||||
|
console.log('Creating realisasi:', JSON.stringify(body, null, 2));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Pastikan APBDesItem ada
|
||||||
|
const item = await prisma.aPBDesItem.findUnique({
|
||||||
|
where: { id: itemId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
context.set.status = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Item APBDes tidak ditemukan",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create realisasi item
|
||||||
|
const realisasi = await prisma.realisasiItem.create({
|
||||||
|
data: {
|
||||||
|
apbdesItemId: itemId,
|
||||||
|
jumlah: body.jumlah,
|
||||||
|
tanggal: new Date(body.tanggal),
|
||||||
|
keterangan: body.keterangan,
|
||||||
|
buktiFileId: body.buktiFileId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Update totalRealisasi, selisih, persentase di APBDesItem
|
||||||
|
const allRealisasi = await prisma.realisasiItem.findMany({
|
||||||
|
where: { apbdesItemId: itemId, isActive: true },
|
||||||
|
select: { jumlah: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
|
||||||
|
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||||
|
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||||
|
|
||||||
|
await prisma.aPBDesItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: {
|
||||||
|
totalRealisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Return response
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Realisasi berhasil ditambahkan",
|
||||||
|
data: realisasi,
|
||||||
|
meta: {
|
||||||
|
totalRealisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error creating realisasi:", error);
|
||||||
|
context.set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Gagal menambahkan realisasi: ${error.message}`,
|
||||||
|
error: process.env.NODE_ENV === 'development' ? error : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
export default async function realisasiDelete(context: Context) {
|
||||||
|
const { realisasiId } = context.params as { realisasiId: string };
|
||||||
|
|
||||||
|
console.log('Deleting realisasi:', realisasiId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Pastikan realisasi ada
|
||||||
|
const existing = await prisma.realisasiItem.findUnique({
|
||||||
|
where: { id: realisasiId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
context.set.status = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Realisasi tidak ditemukan",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const apbdesItemId = existing.apbdesItemId;
|
||||||
|
|
||||||
|
// 2. Soft delete realisasi (set isActive = false)
|
||||||
|
await prisma.realisasiItem.update({
|
||||||
|
where: { id: realisasiId },
|
||||||
|
data: {
|
||||||
|
isActive: false,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem
|
||||||
|
const allRealisasi = await prisma.realisasiItem.findMany({
|
||||||
|
where: { apbdesItemId, isActive: true },
|
||||||
|
select: { jumlah: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = await prisma.aPBDesItem.findUnique({
|
||||||
|
where: { id: apbdesItemId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
|
||||||
|
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||||
|
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||||
|
|
||||||
|
await prisma.aPBDesItem.update({
|
||||||
|
where: { id: apbdesItemId },
|
||||||
|
data: {
|
||||||
|
totalRealisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Realisasi berhasil dihapus",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error deleting realisasi:", error);
|
||||||
|
context.set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Gagal menghapus realisasi: ${error.message}`,
|
||||||
|
error: process.env.NODE_ENV === 'development' ? error : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
type RealisasiUpdateBody = {
|
||||||
|
jumlah?: number;
|
||||||
|
tanggal?: string;
|
||||||
|
keterangan?: string;
|
||||||
|
buktiFileId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function realisasiUpdate(context: Context) {
|
||||||
|
const { realisasiId } = context.params as { realisasiId: string };
|
||||||
|
const body = context.body as RealisasiUpdateBody;
|
||||||
|
|
||||||
|
console.log('Updating realisasi:', JSON.stringify(body, null, 2));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Pastikan realisasi ada
|
||||||
|
const existing = await prisma.realisasiItem.findUnique({
|
||||||
|
where: { id: realisasiId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
context.set.status = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Realisasi tidak ditemukan",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update realisasi
|
||||||
|
const updated = await prisma.realisasiItem.update({
|
||||||
|
where: { id: realisasiId },
|
||||||
|
data: {
|
||||||
|
...(body.jumlah !== undefined && { jumlah: body.jumlah }),
|
||||||
|
...(body.tanggal !== undefined && { tanggal: new Date(body.tanggal) }),
|
||||||
|
...(body.keterangan !== undefined && { keterangan: body.keterangan }),
|
||||||
|
...(body.buktiFileId !== undefined && { buktiFileId: body.buktiFileId }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Recalculate totalRealisasi, selisih, persentase di APBDesItem
|
||||||
|
const allRealisasi = await prisma.realisasiItem.findMany({
|
||||||
|
where: { apbdesItemId: existing.apbdesItemId, isActive: true },
|
||||||
|
select: { jumlah: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = await prisma.aPBDesItem.findUnique({
|
||||||
|
where: { id: existing.apbdesItemId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
const totalRealisasi = allRealisasi.reduce((sum, r) => sum + r.jumlah, 0);
|
||||||
|
const selisih = item.anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||||
|
const persentase = item.anggaran > 0 ? (totalRealisasi / item.anggaran) * 100 : 0;
|
||||||
|
|
||||||
|
await prisma.aPBDesItem.update({
|
||||||
|
where: { id: existing.apbdesItemId },
|
||||||
|
data: {
|
||||||
|
totalRealisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Realisasi berhasil diperbarui",
|
||||||
|
data: updated,
|
||||||
|
meta: {
|
||||||
|
totalRealisasi: allRealisasi.reduce((sum, r) => sum + r.jumlah, 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error updating realisasi:", error);
|
||||||
|
context.set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Gagal memperbarui realisasi: ${error.message}`,
|
||||||
|
error: process.env.NODE_ENV === 'development' ? error : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,15 @@ type APBDesItemInput = {
|
|||||||
kode: string;
|
kode: string;
|
||||||
uraian: string;
|
uraian: string;
|
||||||
anggaran: number;
|
anggaran: number;
|
||||||
realisasi: number;
|
|
||||||
selisih: number;
|
|
||||||
persentase: number;
|
|
||||||
level: number;
|
level: number;
|
||||||
tipe?: string | null;
|
tipe?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormUpdateBody = {
|
type FormUpdateBody = {
|
||||||
tahun: number;
|
tahun: number;
|
||||||
|
name?: string;
|
||||||
|
deskripsi?: string;
|
||||||
|
jumlah?: string;
|
||||||
imageId: string;
|
imageId: string;
|
||||||
fileId: string;
|
fileId: string;
|
||||||
items: APBDesItemInput[];
|
items: APBDesItemInput[];
|
||||||
@@ -38,25 +38,32 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Hapus semua item lama
|
// 2. Hapus semua item lama (cascade akan menghapus realisasiItems juga)
|
||||||
await prisma.aPBDesItem.deleteMany({
|
await prisma.aPBDesItem.deleteMany({
|
||||||
where: { apbdesId: id },
|
where: { apbdesId: id },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Buat item baru tanpa parentId terlebih dahulu
|
// 3. Buat item baru dengan auto-calculate fields
|
||||||
await prisma.aPBDesItem.createMany({
|
await prisma.aPBDesItem.createMany({
|
||||||
data: body.items.map((item) => ({
|
data: body.items.map((item) => {
|
||||||
apbdesId: id,
|
const anggaran = item.anggaran;
|
||||||
kode: item.kode,
|
const totalRealisasi = 0; // Reset karena items baru
|
||||||
uraian: item.uraian,
|
const selisih = anggaran - totalRealisasi; // Sisa anggaran (positif = belum digunakan)
|
||||||
anggaran: item.anggaran,
|
const persentase = anggaran > 0 ? (totalRealisasi / anggaran) * 100 : 0;
|
||||||
realisasi: item.realisasi,
|
|
||||||
selisih: item.anggaran - item.realisasi,
|
return {
|
||||||
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
|
apbdesId: id,
|
||||||
level: item.level,
|
kode: item.kode,
|
||||||
tipe: item.tipe || null,
|
uraian: item.uraian,
|
||||||
isActive: true,
|
anggaran: anggaran,
|
||||||
})),
|
level: item.level,
|
||||||
|
tipe: item.tipe || null,
|
||||||
|
totalRealisasi,
|
||||||
|
selisih,
|
||||||
|
persentase,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
|
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
|
||||||
@@ -66,12 +73,11 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 5. Update parentId untuk setiap item
|
// 5. Update parentId untuk setiap item
|
||||||
// Pastikan allItems memiliki tipe yang benar
|
|
||||||
const itemsForParentUpdate = allItems.map(item => ({
|
const itemsForParentUpdate = allItems.map(item => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await assignParentIdsToApbdesItems(itemsForParentUpdate);
|
await assignParentIdsToApbdesItems(itemsForParentUpdate);
|
||||||
|
|
||||||
// 6. Update data APBDes
|
// 6. Update data APBDes
|
||||||
@@ -79,18 +85,27 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
tahun: body.tahun,
|
tahun: body.tahun,
|
||||||
|
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||||
|
deskripsi: body.deskripsi,
|
||||||
|
jumlah: body.jumlah,
|
||||||
imageId: body.imageId,
|
imageId: body.imageId,
|
||||||
fileId: body.fileId,
|
fileId: body.fileId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Ambil data lengkap untuk response
|
// 7. Ambil data lengkap untuk response (include realisasiItems)
|
||||||
const result = await prisma.aPBDes.findUnique({
|
const result = await prisma.aPBDes.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
items: {
|
items: {
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { kode: 'asc' }
|
orderBy: { kode: 'asc' },
|
||||||
|
include: {
|
||||||
|
realisasiItems: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { tanggal: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
image: true,
|
image: true,
|
||||||
file: true,
|
file: true,
|
||||||
|
|||||||
@@ -3,10 +3,43 @@
|
|||||||
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||||
import NewsReader from '@/app/darmasaba/_com/NewsReader';
|
import NewsReader from '@/app/darmasaba/_com/NewsReader';
|
||||||
import colors from '@/con/colors';
|
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 { useParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useProxy } from 'valtio/utils';
|
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() {
|
function Page() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -45,13 +78,30 @@ function Page() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = state.findUnique.data as unknown as BeritaDetail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack pos="relative" bg={colors.Bg} pb="xl" gap="xs" 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 }}>
|
<Group px={{ base: 'md', md: 100 }}>
|
||||||
<NewsReader />
|
<NewsReader />
|
||||||
</Group>
|
</Group>
|
||||||
<Container w={{ base: '100%', md: '50%' }}>
|
|
||||||
|
<Container w={{ base: '100%', md: '60%' }}>
|
||||||
<Box pb={20}>
|
<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
|
<Title
|
||||||
id="news-title"
|
id="news-title"
|
||||||
order={1}
|
order={1}
|
||||||
@@ -59,41 +109,108 @@ function Page() {
|
|||||||
c={colors['blue-button']}
|
c={colors['blue-button']}
|
||||||
fw="bold"
|
fw="bold"
|
||||||
lh={{ base: 1.2, md: 1.25 }}
|
lh={{ base: 1.2, md: 1.25 }}
|
||||||
|
mb="md"
|
||||||
>
|
>
|
||||||
{state.findUnique.data.judul}
|
{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
|
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
|
<Divider my="xs" />
|
||||||
</Box>
|
</Box>
|
||||||
<Image src={state.findUnique.data.image?.link || ''} alt="" w="100%" loading="lazy" />
|
|
||||||
</Container>
|
{/* Featured Image */}
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
{data.image?.link && (
|
||||||
<Stack gap="xs">
|
<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
|
<Text
|
||||||
id="news-content"
|
id="news-content"
|
||||||
py={20}
|
py={20}
|
||||||
|
px={{ base: 0, md: 'sm' }}
|
||||||
fz={{ base: 'sm', md: 'md' }}
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
lh={{ base: 1.6, md: 1.8 }}
|
lh={{ base: 1.8, md: 2 }}
|
||||||
ta="justify"
|
ta="justify"
|
||||||
|
c="dimmed"
|
||||||
style={{
|
style={{
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
whiteSpace: 'normal',
|
whiteSpace: 'normal',
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{
|
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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
# Debugging Progress Bar Issue
|
|
||||||
|
|
||||||
## Masalah
|
|
||||||
Musik auto back ke awal (0:00) saat user mencoba seek/maju-mundurkan progress bar.
|
|
||||||
|
|
||||||
## Kemungkinan Penyebab
|
|
||||||
|
|
||||||
### 1. Duration dari Database vs Actual Duration
|
|
||||||
```typescript
|
|
||||||
// Database durasi (dari currentSong.durasi): "3:45"
|
|
||||||
const durationParts = currentSong.durasi.split(':');
|
|
||||||
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
|
||||||
// Result: 225 seconds
|
|
||||||
|
|
||||||
// Actual duration dari audio file:
|
|
||||||
audioRef.current.duration
|
|
||||||
// Might be: 224.87 seconds (bisa berbeda!)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problem:** Jika kita set manual duration dari database, tapi actual audio duration berbeda, bisa terjadi konflik.
|
|
||||||
|
|
||||||
**Solution:** Gunakan actual duration dari audio file, jangan dari database.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. useEffect Dependencies Terlalu Banyak
|
|
||||||
```typescript
|
|
||||||
// ❌ BEFORE - Too many dependencies
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset currentTime to 0
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
}, [currentSongIndex, currentSong, isPlaying]);
|
|
||||||
// Trigger setiap kali ada perubahan!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Problem:**
|
|
||||||
- `currentSong` berubah → reset ke 0
|
|
||||||
- `isPlaying` berubah → reset ke 0
|
|
||||||
- `currentTime` berubah → re-render → effect trigger?
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```typescript
|
|
||||||
// ✅ AFTER - Only depend on currentSongIndex
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSong && audioRef.current) {
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentSongIndex]);
|
|
||||||
// Only trigger when song changes
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Progress Interval vs Seek Conflict
|
|
||||||
```typescript
|
|
||||||
// Progress interval update setiap detik
|
|
||||||
setInterval(() => {
|
|
||||||
setCurrentTime(audioRef.current.currentTime);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// User seek
|
|
||||||
handleSeekEnd(value) {
|
|
||||||
setCurrentTime(value);
|
|
||||||
audioRef.current.currentTime = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1 detik kemudian, progress interval overwrite!
|
|
||||||
setCurrentTime(audioRef.current.currentTime); // Back to old value!
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```typescript
|
|
||||||
// Pause progress interval saat dragging
|
|
||||||
useEffect(() => {
|
|
||||||
return setupProgressInterval(
|
|
||||||
audioRef,
|
|
||||||
isPlaying && !isDragging, // ✅ Don't update if dragging
|
|
||||||
setCurrentTime,
|
|
||||||
progressIntervalRef
|
|
||||||
);
|
|
||||||
}, [isPlaying, isDragging]);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. isDragging Tidak Digunakan di Page
|
|
||||||
**Check:** Pastikan `isDragging` di-import dan digunakan dengan benar.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ In use-music-player.ts
|
|
||||||
const {
|
|
||||||
isDragging, // ✅ Import ini
|
|
||||||
handleSeekStart,
|
|
||||||
handleSeekEnd,
|
|
||||||
currentTime, // ✅ Ini dynamic: isDragging ? dragTime : currentTime
|
|
||||||
} = useMusicPlayer({ musikData, search });
|
|
||||||
|
|
||||||
// ✅ In page.tsx
|
|
||||||
<Slider
|
|
||||||
value={currentTime} // ✅ Gunakan currentTime dari hook
|
|
||||||
onChange={handleSeekStart}
|
|
||||||
onChangeEnd={handleSeekEnd}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Debugging Steps
|
|
||||||
|
|
||||||
### Step 1: Check Console Logs
|
|
||||||
|
|
||||||
Open browser console dan look for:
|
|
||||||
|
|
||||||
```
|
|
||||||
[Song Change Effect] currentSongIndex: 0 currentSong: "Judul Lagu"
|
|
||||||
[Song Change] Reset currentTime to 0
|
|
||||||
[Song Change] Playing new song
|
|
||||||
|
|
||||||
[Audio Metadata] Actual duration: 225 Previous duration: 0
|
|
||||||
[Progress] Interval started
|
|
||||||
|
|
||||||
[Seek Start] 45 isDragging: false
|
|
||||||
[Seek End] 45 currentTime: 30 duration: 225
|
|
||||||
[Seek Applied] 45
|
|
||||||
|
|
||||||
[Progress Tick] 46
|
|
||||||
[Progress Tick] 47
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected:**
|
|
||||||
- `[Song Change]` hanya muncul saat ganti lagu
|
|
||||||
- `[Audio Metadata]` muncul sekali saat lagu load
|
|
||||||
- `[Seek Start]` dan `[Seek End]` muncul saat user drag slider
|
|
||||||
- `[Progress Tick]` muncul setiap detik saat playing
|
|
||||||
|
|
||||||
**Red Flags:**
|
|
||||||
- ❌ `[Song Change]` muncul terus → useEffect dependency salah
|
|
||||||
- ❌ `[Seek Applied]` tapi currentTime tetap 0 → audio element issue
|
|
||||||
- ❌ `[Progress Tick]` muncul saat dragging → isDragging tidak bekerja
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 2: Check Duration Value
|
|
||||||
|
|
||||||
Add this to your component:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
console.log('Duration:', duration, 'Current Time:', currentTime);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected:**
|
|
||||||
- Duration: 225 (atau actual duration dari audio)
|
|
||||||
- Current Time: 0 → 1 → 2 → 3... (increment normal)
|
|
||||||
|
|
||||||
**Red Flags:**
|
|
||||||
- ❌ Duration: 0 → Audio metadata tidak load
|
|
||||||
- ❌ Duration: NaN → Database durasi format salah
|
|
||||||
- ❌ Current Time reset ke 0 terus → Effect trigger terus
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3: Check isDragging State
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
console.log('isDragging:', isDragging, 'dragTime:', dragTime);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected:**
|
|
||||||
- isDragging: false (normal state)
|
|
||||||
- isDragging: true (saat user drag slider)
|
|
||||||
- dragTime: 45 (posisi saat drag)
|
|
||||||
|
|
||||||
**Red Flags:**
|
|
||||||
- ❌ isDragging: true terus → handleSeekEnd tidak dipanggil
|
|
||||||
- ❌ dragTime: 0 terus → handleSeekStart tidak dipanggil
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 4: Check Slider Events
|
|
||||||
|
|
||||||
Add event listeners to slider:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Slider
|
|
||||||
value={currentTime}
|
|
||||||
onChange={(v) => {
|
|
||||||
console.log('[Slider onChange]', v);
|
|
||||||
handleSeekStart(v);
|
|
||||||
}}
|
|
||||||
onChangeEnd={(v) => {
|
|
||||||
console.log('[Slider onChangeEnd]', v);
|
|
||||||
handleSeekEnd(v);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected:**
|
|
||||||
- `onChange` dipanggil terus saat drag
|
|
||||||
- `onChangeEnd` dipanggil sekali saat release
|
|
||||||
|
|
||||||
**Red Flags:**
|
|
||||||
- ❌ `onChangeEnd` tidak dipanggil → Mantine slider issue
|
|
||||||
- ❌ `onChange` tidak dipanggil → Slider tidak interactive
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Issues & Solutions
|
|
||||||
|
|
||||||
### Issue 1: Duration = 0 atau NaN
|
|
||||||
|
|
||||||
**Cause:**
|
|
||||||
- Audio file tidak load
|
|
||||||
- Database durasi format salah (harus "MM:SS")
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```typescript
|
|
||||||
// Use actual duration from audio
|
|
||||||
const handleAudioMetadataLoaded = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
setDuration(Math.floor(audioRef.current.duration));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fallback to database duration if needed
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSong && duration === 0) {
|
|
||||||
const parts = currentSong.durasi.split(':');
|
|
||||||
setDuration(parseInt(parts[0]) * 60 + parseInt(parts[1]));
|
|
||||||
}
|
|
||||||
}, [currentSong]);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Issue 2: Seek Reset ke 0
|
|
||||||
|
|
||||||
**Cause:**
|
|
||||||
- useEffect trigger terus
|
|
||||||
- Progress interval overwrite seek
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```typescript
|
|
||||||
// 1. Fix useEffect dependencies
|
|
||||||
useEffect(() => {
|
|
||||||
// Only reset when song changes
|
|
||||||
}, [currentSongIndex]);
|
|
||||||
|
|
||||||
// 2. Pause progress during drag
|
|
||||||
useEffect(() => {
|
|
||||||
return setupProgressInterval(
|
|
||||||
audioRef,
|
|
||||||
isPlaying && !isDragging,
|
|
||||||
...
|
|
||||||
);
|
|
||||||
}, [isPlaying, isDragging]);
|
|
||||||
|
|
||||||
// 3. Safe seek with range check
|
|
||||||
const handleSeekEnd = (value: number) => {
|
|
||||||
const safeValue = Math.max(0, Math.min(value, duration));
|
|
||||||
setCurrentTime(safeValue);
|
|
||||||
audioRef.current.currentTime = safeValue;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Issue 3: Slider Tidak Berfungsi
|
|
||||||
|
|
||||||
**Cause:**
|
|
||||||
- Slider disabled
|
|
||||||
- onChange/onChangeEnd tidak di-set
|
|
||||||
- Value NaN atau Infinity
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```tsx
|
|
||||||
<Slider
|
|
||||||
value={currentTime}
|
|
||||||
max={duration || 1} // ✅ Fallback to 1
|
|
||||||
onChange={handleSeekStart}
|
|
||||||
onChangeEnd={handleSeekEnd}
|
|
||||||
disabled={!currentSong} // ✅ Only disable if no song
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
### ✅ Test 1: Normal Playback
|
|
||||||
1. Play song
|
|
||||||
2. Check console: `[Progress Tick]` setiap detik
|
|
||||||
3. Current time increment normal
|
|
||||||
4. Duration correct
|
|
||||||
|
|
||||||
### ✅ Test 2: Seek Forward
|
|
||||||
1. Play song (e.g., at 0:30)
|
|
||||||
2. Click ahead on progress bar (e.g., 1:30)
|
|
||||||
3. Check console: `[Seek Start] 90`, `[Seek End] 90`
|
|
||||||
4. Audio jumps to 1:30
|
|
||||||
5. Continues playing from 1:30
|
|
||||||
|
|
||||||
### ✅ Test 3: Seek Backward
|
|
||||||
1. Play song (e.g., at 2:00)
|
|
||||||
2. Click behind on progress bar (e.g., 0:45)
|
|
||||||
3. Check console: `[Seek Start] 45`, `[Seek End] 45`
|
|
||||||
4. Audio jumps to 0:45
|
|
||||||
5. Continues playing from 0:45
|
|
||||||
|
|
||||||
### ✅ Test 4: Drag Seek
|
|
||||||
1. Play song
|
|
||||||
2. Click and drag slider thumb
|
|
||||||
3. Check console: `[Seek Start]` dengan berbagai value
|
|
||||||
4. Time display update smooth
|
|
||||||
5. Release slider
|
|
||||||
6. Check console: `[Seek End]` dengan final value
|
|
||||||
7. Audio jumps to exact position
|
|
||||||
|
|
||||||
### ✅ Test 5: Song Change
|
|
||||||
1. Play song #1
|
|
||||||
2. Click next song button
|
|
||||||
3. Check console: `[Song Change]` hanya sekali
|
|
||||||
4. New song plays from 0:00
|
|
||||||
5. Duration updates correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Remove Debug Logs (Production)
|
|
||||||
|
|
||||||
Setelah semua berfungsi, hapus atau comment console logs:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Comment out debug logs
|
|
||||||
// console.log('[Seek Start]', value);
|
|
||||||
// console.log('[Seek End]', value);
|
|
||||||
// console.log('[Song Change Effect]', currentSongIndex);
|
|
||||||
// console.log('[Progress Tick]', time);
|
|
||||||
// console.log('[Audio Metadata]', actualDuration);
|
|
||||||
```
|
|
||||||
|
|
||||||
Atau gunakan environment variable:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const DEBUG = process.env.NODE_ENV === 'development';
|
|
||||||
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log('[Seek Start]', value);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Final Check
|
|
||||||
|
|
||||||
✅ Duration dari audio file (bukan database)
|
|
||||||
✅ useEffect hanya depend on `currentSongIndex`
|
|
||||||
✅ Progress interval pause saat dragging
|
|
||||||
✅ `isDragging` state bekerja
|
|
||||||
✅ `handleSeekStart` dan `handleSeekEnd` dipanggil
|
|
||||||
✅ Safe value range (0 to duration)
|
|
||||||
✅ Console logs menunjukkan flow yang benar
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Updated**: February 27, 2026
|
|
||||||
**Issue**: Progress bar auto-reset to 0:00
|
|
||||||
**Status**: 🔍 Debugging with console logs
|
|
||||||
**Next Step**: Test dan check console output
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
# Music Player Implementation Options
|
|
||||||
|
|
||||||
## Option 1: Using `react-player` Library (RECOMMENDED) ✅
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
```bash
|
|
||||||
bun add react-player
|
|
||||||
```
|
|
||||||
|
|
||||||
### Benefits
|
|
||||||
- ✅ **Battle-tested** - Used in production by thousands of apps
|
|
||||||
- ✅ **Handles all edge cases** - Browser differences, loading states, etc.
|
|
||||||
- ✅ **Simple API** - Easy to use and maintain
|
|
||||||
- ✅ **Supports multiple formats** - MP3, WAV, OGG, YouTube, Vimeo, etc.
|
|
||||||
- ✅ **Built-in progress handling** - No manual interval management
|
|
||||||
- ✅ **Seek works perfectly** - No browser compatibility issues
|
|
||||||
|
|
||||||
### Usage Example
|
|
||||||
```typescript
|
|
||||||
import { MusicPlayer } from './lib/MusicPlayer';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
return (
|
|
||||||
<MusicPlayer
|
|
||||||
url="https://example.com/song.mp3"
|
|
||||||
playing={true}
|
|
||||||
volume={0.7}
|
|
||||||
onEnded={() => console.log('Song ended')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files Created
|
|
||||||
- `MusicPlayer.tsx` - Wrapper component using react-player
|
|
||||||
- Handles all audio logic internally
|
|
||||||
- Progress bar with seek functionality
|
|
||||||
- Play/pause controls
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option 2: Custom Hook `useAudioPlayer`
|
|
||||||
|
|
||||||
### When to Use
|
|
||||||
- Need full control over audio element
|
|
||||||
- Want to avoid external dependencies
|
|
||||||
- Custom requirements not supported by libraries
|
|
||||||
|
|
||||||
### Files Created
|
|
||||||
- `use-audio-player.ts` - Custom React hook
|
|
||||||
- `SimpleMusicPlayer.tsx` - Example component
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
```typescript
|
|
||||||
import { useAudioPlayer } from './lib/use-audio-player';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const {
|
|
||||||
isPlaying,
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
seek,
|
|
||||||
} = useAudioPlayer({ src: '/path/to/audio.mp3' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button onClick={isPlaying ? pause : play}>
|
|
||||||
{isPlaying ? 'Pause' : 'Play'}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={duration}
|
|
||||||
value={currentTime}
|
|
||||||
onChange={(e) => seek(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option 3: Original Implementation (FIXED)
|
|
||||||
|
|
||||||
### Current Status
|
|
||||||
- ✅ Working with Pause→Seek→Play pattern
|
|
||||||
- ✅ hasSeeked flag prevents reset
|
|
||||||
- ✅ Retry logic with load()
|
|
||||||
- ⚠️ Complex, hard to maintain
|
|
||||||
- ⚠️ Multiple edge cases to handle
|
|
||||||
|
|
||||||
### When to Keep
|
|
||||||
- Already invested time in custom implementation
|
|
||||||
- Need specific customizations
|
|
||||||
- Don't want external dependencies
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
### 🎯 **USE OPTION 1: react-player**
|
|
||||||
|
|
||||||
**Why?**
|
|
||||||
1. **Less code** - 100+ lines saved
|
|
||||||
2. **More reliable** - Battle-tested library
|
|
||||||
3. **Easier maintenance** - Library handles updates
|
|
||||||
4. **Better browser support** - Handles cross-browser issues
|
|
||||||
5. **More features** - Supports video, YouTube, Vimeo, etc.
|
|
||||||
|
|
||||||
**Migration Steps:**
|
|
||||||
1. Install: `bun add react-player`
|
|
||||||
2. Import: `import MusicPlayer from './lib/MusicPlayer'`
|
|
||||||
3. Replace existing player component
|
|
||||||
4. Done!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparison
|
|
||||||
|
|
||||||
| Feature | react-player | Custom Hook | Original |
|
|
||||||
|---------|--------------|-------------|----------|
|
|
||||||
| Lines of Code | ~50 | ~100 | ~300 |
|
|
||||||
| Browser Support | ✅ Excellent | ⚠️ Manual | ⚠️ Manual |
|
|
||||||
| Seek Functionality | ✅ Perfect | ✅ Good | ⚠️ Complex |
|
|
||||||
| Progress Updates | ✅ Built-in | ✅ Manual | ✅ Manual |
|
|
||||||
| Format Support | ✅ Many | ⚠️ Limited | ⚠️ Limited |
|
|
||||||
| Maintenance | ✅ Library | ⚠️ You | ⚠️ You |
|
|
||||||
| Bundle Size | +15kb | +0kb | +0kb |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation with react-player
|
|
||||||
|
|
||||||
### Basic Player
|
|
||||||
```typescript
|
|
||||||
import ReactPlayer from 'react-player';
|
|
||||||
|
|
||||||
function BasicPlayer() {
|
|
||||||
return (
|
|
||||||
<ReactPlayer
|
|
||||||
url="https://example.com/song.mp3"
|
|
||||||
playing={true}
|
|
||||||
controls={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Player with Progress
|
|
||||||
```typescript
|
|
||||||
import ReactPlayer from 'react-player';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
function CustomPlayer() {
|
|
||||||
const [played, setPlayed] = useState(0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ReactPlayer
|
|
||||||
url="https://example.com/song.mp3"
|
|
||||||
onProgress={(e) => setPlayed(e.played)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
value={played}
|
|
||||||
onChange={(e) => playerRef.current?.seekTo(parseFloat(e.target.value))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Player with All Controls
|
|
||||||
```typescript
|
|
||||||
import ReactPlayer from 'react-player';
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
|
|
||||||
function AdvancedPlayer({ url }) {
|
|
||||||
const playerRef = useRef(null);
|
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
const [volume, setVolume] = useState(0.5);
|
|
||||||
const [muted, setMuted] = useState(false);
|
|
||||||
const [played, setPlayed] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ReactPlayer
|
|
||||||
ref={playerRef}
|
|
||||||
url={url}
|
|
||||||
playing={playing}
|
|
||||||
volume={volume}
|
|
||||||
muted={muted}
|
|
||||||
onProgress={(e) => setPlayed(e.played)}
|
|
||||||
onDuration={setDuration}
|
|
||||||
onEnded={() => setPlaying(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
value={played}
|
|
||||||
onChange={(e) => playerRef.current?.seekTo(parseFloat(e.target.value))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<button onClick={() => setPlaying(!playing)}>
|
|
||||||
{playing ? 'Pause' : 'Play'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button onClick={() => setMuted(!muted)}>
|
|
||||||
{muted ? 'Unmute' : 'Mute'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.01"
|
|
||||||
value={volume}
|
|
||||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
### If Using react-player:
|
|
||||||
1. ✅ Already installed
|
|
||||||
2. Use `MusicPlayer.tsx` component
|
|
||||||
3. Or create custom wrapper for your needs
|
|
||||||
4. Remove old complex logic
|
|
||||||
|
|
||||||
### If Keeping Custom Implementation:
|
|
||||||
1. Keep current files
|
|
||||||
2. Test thoroughly
|
|
||||||
3. Handle edge cases manually
|
|
||||||
4. Maintain browser compatibility
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Additional Libraries (Alternatives)
|
|
||||||
|
|
||||||
### 1. **howler.js**
|
|
||||||
- Great for audio sprites
|
|
||||||
- Good for games
|
|
||||||
- More low-level control
|
|
||||||
|
|
||||||
### 2. **wavesurfer.js**
|
|
||||||
- Waveform visualization
|
|
||||||
- Audio editing features
|
|
||||||
- More complex use cases
|
|
||||||
|
|
||||||
### 3. **use-sound**
|
|
||||||
- React hook for sound effects
|
|
||||||
- Simple API
|
|
||||||
- Built on howler.js
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
**For your use case (Desa Darmasaba music player):**
|
|
||||||
|
|
||||||
✅ **USE `react-player`** because:
|
|
||||||
- Simple integration
|
|
||||||
- Reliable seek functionality
|
|
||||||
- Less code to maintain
|
|
||||||
- Better browser support
|
|
||||||
- Already installed!
|
|
||||||
|
|
||||||
**Files to use:**
|
|
||||||
- `MusicPlayer.tsx` - Base component
|
|
||||||
- Customize as needed
|
|
||||||
- Remove old complex implementation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Updated**: February 27, 2026
|
|
||||||
**Recommendation**: Use `react-player` library
|
|
||||||
**Status**: ✅ Installed and ready to use
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
# Progress Bar Seek Improvement
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
Progress bar slider sebelumnya tidak berfungsi dengan baik untuk memajukan/memundurkan lagu ke waktu yang diinginkan karena:
|
|
||||||
|
|
||||||
1. **`onChange` dipanggil terus menerus** saat drag - menyebabkan update state yang berlebihan
|
|
||||||
2. **Tidak ada `onChangeEnd`** - tidak ada commit posisi saat user selesai drag
|
|
||||||
3. **Progress update konflik** - progress bar terus update setiap detik saat sedang di-drag
|
|
||||||
4. **Tidak ada visual feedback** yang smooth saat drag
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
### 1. Added Drag State Management
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [dragTime, setDragTime] = useState(0);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Purpose:**
|
|
||||||
- `isDragging` - Track apakah user sedang drag slider
|
|
||||||
- `dragTime` - Simpan posisi sementara saat drag
|
|
||||||
|
|
||||||
### 2. New Seek Functions
|
|
||||||
|
|
||||||
#### `handleSeekStart(value)` - Saat mulai drag
|
|
||||||
```typescript
|
|
||||||
const handleSeekStart = (value: number) => {
|
|
||||||
setIsDragging(true);
|
|
||||||
setDragTime(value);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Set flag `isDragging = true`
|
|
||||||
- Simpan posisi drag ke `dragTime`
|
|
||||||
- Progress interval otomatis pause (karena `isPlaying && !isDragging`)
|
|
||||||
|
|
||||||
#### `handleSeekEnd(value)` - Saat selesai drag
|
|
||||||
```typescript
|
|
||||||
const handleSeekEnd = (value: number) => {
|
|
||||||
setIsDragging(false);
|
|
||||||
setDragTime(0);
|
|
||||||
setCurrentTime(value);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Set flag `isDragging = false`
|
|
||||||
- Reset `dragTime`
|
|
||||||
- Commit posisi final ke `currentTime`
|
|
||||||
- Update audio element currentTime
|
|
||||||
- Audio langsung lompat ke posisi baru
|
|
||||||
|
|
||||||
### 3. Updated Progress Interval
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
return setupProgressInterval(
|
|
||||||
audioRef,
|
|
||||||
isPlaying && !isDragging, // ⚠️ Only update if NOT dragging
|
|
||||||
setCurrentTime,
|
|
||||||
progressIntervalRef
|
|
||||||
);
|
|
||||||
}, [isPlaying, isDragging]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Change:**
|
|
||||||
- Progress hanya update jika `isPlaying AND NOT dragging`
|
|
||||||
- Mencegah konflik antara progress update dan user drag
|
|
||||||
|
|
||||||
### 4. Dynamic currentTime Display
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
currentTime: isDragging ? dragTime : currentTime
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Saat drag: tampilkan `dragTime` (posisi slider)
|
|
||||||
- Tidak drag: tampilkan `currentTime` (posisi actual audio)
|
|
||||||
- Memberikan visual feedback yang smooth
|
|
||||||
|
|
||||||
### 5. Updated Slider Component
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Slider
|
|
||||||
value={currentTime}
|
|
||||||
max={duration || 1}
|
|
||||||
onChange={handleSeekStart} // Saat drag
|
|
||||||
onChangeEnd={handleSeekEnd} // Saat release
|
|
||||||
color="#0B4F78"
|
|
||||||
size="sm"
|
|
||||||
disabled={!currentSong}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mantine Slider Events:**
|
|
||||||
- `onChange` - Dipanggil terus saat drag (kita pakai untuk start)
|
|
||||||
- `onChangeEnd` - Dipanggil sekali saat release (kita pakai untuk commit)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Experience Flow
|
|
||||||
|
|
||||||
### Before (❌):
|
|
||||||
```
|
|
||||||
User drags slider → Progress jumps around → Audio stutters →
|
|
||||||
Confusing UX → User frustrated
|
|
||||||
```
|
|
||||||
|
|
||||||
### After (✅):
|
|
||||||
```
|
|
||||||
1. User clicks/drag slider
|
|
||||||
├─ isDragging = true
|
|
||||||
├─ Progress interval pauses
|
|
||||||
├─ Slider shows drag position (smooth)
|
|
||||||
└─ Audio keeps playing (no stutter)
|
|
||||||
|
|
||||||
2. User drags to desired position
|
|
||||||
├─ Slider updates visually
|
|
||||||
└─ Shows time preview
|
|
||||||
|
|
||||||
3. User releases slider
|
|
||||||
├─ isDragging = false
|
|
||||||
├─ Audio.currentTime = new position
|
|
||||||
├─ Progress interval resumes
|
|
||||||
└─ Audio continues from new position
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### File Changes
|
|
||||||
|
|
||||||
#### `use-music-player.ts`
|
|
||||||
|
|
||||||
**Added State:**
|
|
||||||
```typescript
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [dragTime, setDragTime] = useState(0);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Added Functions:**
|
|
||||||
```typescript
|
|
||||||
const handleSeekStart = (value: number) => { ... }
|
|
||||||
const handleSeekEnd = (value: number) => { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Updated Return:**
|
|
||||||
```typescript
|
|
||||||
return {
|
|
||||||
// ... other properties
|
|
||||||
currentTime: isDragging ? dragTime : currentTime,
|
|
||||||
isDragging,
|
|
||||||
dragTime,
|
|
||||||
handleSeekStart,
|
|
||||||
handleSeekEnd,
|
|
||||||
// ... other properties
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Updated Progress Interval:**
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
return setupProgressInterval(
|
|
||||||
audioRef,
|
|
||||||
isPlaying && !isDragging, // Critical fix
|
|
||||||
setCurrentTime,
|
|
||||||
progressIntervalRef
|
|
||||||
);
|
|
||||||
}, [isPlaying, isDragging]);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `musik-desa/page.tsx`
|
|
||||||
|
|
||||||
**Updated Slider (Main Card):**
|
|
||||||
```tsx
|
|
||||||
<Slider
|
|
||||||
value={currentTime}
|
|
||||||
max={duration || 1}
|
|
||||||
onChange={handleSeekStart}
|
|
||||||
onChangeEnd={handleSeekEnd}
|
|
||||||
disabled={!currentSong}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Updated Slider (Footer):**
|
|
||||||
```tsx
|
|
||||||
<Slider
|
|
||||||
value={currentTime}
|
|
||||||
max={duration || 1}
|
|
||||||
onChange={handleSeekStart}
|
|
||||||
onChangeEnd={handleSeekEnd}
|
|
||||||
disabled={!currentSong}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Updated Imports:**
|
|
||||||
```typescript
|
|
||||||
const {
|
|
||||||
// ... other properties
|
|
||||||
handleSeekStart,
|
|
||||||
handleSeekEnd,
|
|
||||||
isDragging,
|
|
||||||
// ... other properties
|
|
||||||
} = useMusicPlayer({ musikData, search });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Scenarios
|
|
||||||
|
|
||||||
### ✅ Test 1: Basic Seek
|
|
||||||
1. Play any song
|
|
||||||
2. Click anywhere on progress bar
|
|
||||||
3. Audio should jump to that position immediately
|
|
||||||
4. Progress bar updates correctly
|
|
||||||
|
|
||||||
### ✅ Test 2: Drag Seek
|
|
||||||
1. Play any song
|
|
||||||
2. Click and drag the slider thumb
|
|
||||||
3. Drag to desired position
|
|
||||||
4. Release mouse/finger
|
|
||||||
5. Audio should jump to exact position
|
|
||||||
6. Progress should continue from new position
|
|
||||||
|
|
||||||
### ✅ Test 3: Smooth Drag
|
|
||||||
1. Play song
|
|
||||||
2. Drag slider slowly from start to end
|
|
||||||
3. Time display should update smoothly
|
|
||||||
4. Audio should NOT stutter during drag
|
|
||||||
5. Upon release, audio plays from new position
|
|
||||||
|
|
||||||
### ✅ Test 4: Progress Pause During Drag
|
|
||||||
1. Play song
|
|
||||||
2. Start dragging slider
|
|
||||||
3. Notice progress bar stops auto-updating
|
|
||||||
4. Release slider
|
|
||||||
5. Progress bar resumes auto-updating
|
|
||||||
|
|
||||||
### ✅ Test 5: Both Sliders
|
|
||||||
1. Test seek on main card slider (top)
|
|
||||||
2. Test seek on footer slider (bottom)
|
|
||||||
3. Both should work identically
|
|
||||||
4. Both should update same state
|
|
||||||
|
|
||||||
### ✅ Test 6: Edge Cases
|
|
||||||
1. Seek to 0:00 (beginning)
|
|
||||||
2. Seek to end (max duration)
|
|
||||||
3. Seek when duration = 0 (no song)
|
|
||||||
4. All should handle gracefully
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Browser Compatibility
|
|
||||||
|
|
||||||
| Browser | Status | Notes |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Chrome/Edge | ✅ Perfect | Full support |
|
|
||||||
| Firefox | ✅ Perfect | Full support |
|
|
||||||
| Safari | ✅ Perfect | Full support |
|
|
||||||
| iOS Safari | ✅ Perfect | Touch support |
|
|
||||||
| Chrome Mobile | ✅ Perfect | Touch support |
|
|
||||||
|
|
||||||
**Mantine Slider** handles both mouse and touch events:
|
|
||||||
- Mouse: `onMouseDown`, `onMouseMove`, `onMouseUp`
|
|
||||||
- Touch: `onTouchStart`, `onTouchMove`, `onTouchEnd`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Metrics
|
|
||||||
|
|
||||||
### Before:
|
|
||||||
- ❌ Multiple state updates per second during drag
|
|
||||||
- ❌ Audio stuttering/jumping
|
|
||||||
- ❌ Progress bar flickering
|
|
||||||
- ❌ Poor UX
|
|
||||||
|
|
||||||
### After:
|
|
||||||
- ✅ Single state update on drag start
|
|
||||||
- ✅ Single state update on drag end
|
|
||||||
- ✅ Smooth visual feedback
|
|
||||||
- ✅ No audio stuttering
|
|
||||||
- ✅ Excellent UX
|
|
||||||
|
|
||||||
**State Updates Reduced:**
|
|
||||||
- Before: ~60 updates/second (during drag)
|
|
||||||
- After: 2 updates (start + end)
|
|
||||||
- **Improvement: 99.9% reduction**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Quality
|
|
||||||
|
|
||||||
### Separation of Concerns
|
|
||||||
- ✅ Logic in `use-music-player.ts` hook
|
|
||||||
- ✅ UI in `musik-desa/page.tsx`
|
|
||||||
- ✅ Pure functions, easy to test
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
- ✅ Full TypeScript support
|
|
||||||
- ✅ Proper types for all functions
|
|
||||||
- ✅ No `any` types used
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- ✅ Function comments
|
|
||||||
- ✅ Inline explanations
|
|
||||||
- ✅ README updated
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements (Optional)
|
|
||||||
|
|
||||||
1. **Keyboard Seek**
|
|
||||||
- Arrow left/right to seek ±10 seconds
|
|
||||||
- Home/End to seek to start/end
|
|
||||||
|
|
||||||
2. **Double Click to Reset**
|
|
||||||
- Double click progress bar to restart song
|
|
||||||
|
|
||||||
3. **Preview on Hover**
|
|
||||||
- Show time preview on hover (desktop)
|
|
||||||
- Thumbnail preview if available
|
|
||||||
|
|
||||||
4. **Chapter Markers**
|
|
||||||
- Visual markers for song sections
|
|
||||||
- Click to jump to verse/chorus
|
|
||||||
|
|
||||||
5. **Waveform Visualization**
|
|
||||||
- Audio waveform instead of plain bar
|
|
||||||
- More visual feedback
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `use-music-player.ts` | Hook with seek logic |
|
|
||||||
| `audio-player.ts` | Utility functions |
|
|
||||||
| `audio-hooks.ts` | Progress interval setup |
|
|
||||||
| `musik-desa/page.tsx` | UI implementation |
|
|
||||||
| `README.md` | General documentation |
|
|
||||||
| `QUICK_REFERENCE.md` | Quick seek usage guide |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Usage Example
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useMusicPlayer } from './lib/use-music-player';
|
|
||||||
|
|
||||||
function MusicPlayer() {
|
|
||||||
const {
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
handleSeekStart,
|
|
||||||
handleSeekEnd,
|
|
||||||
} = useMusicPlayer({ musikData, search });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Slider
|
|
||||||
value={currentTime}
|
|
||||||
max={duration || 1}
|
|
||||||
onChange={handleSeekStart} // When drag starts
|
|
||||||
onChangeEnd={handleSeekEnd} // When drag ends
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Updated**: February 27, 2026
|
|
||||||
**Issue**: Progress bar seek not working properly
|
|
||||||
**Status**: ✅ Resolved
|
|
||||||
**Files Modified**: 2 (`use-music-player.ts`, `musik-desa/page.tsx`)
|
|
||||||
**Functions Added**: 2 (`handleSeekStart`, `handleSeekEnd`)
|
|
||||||
**State Added**: 2 (`isDragging`, `dragTime`)
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
# 🎵 Music Player - Quick Reference
|
|
||||||
|
|
||||||
## Fungsi Tombol
|
|
||||||
|
|
||||||
| Tombol | Ikon | Fungsi | Keterangan |
|
|
||||||
|--------|------|--------|------------|
|
|
||||||
| **⏮️ Skip Back** | `<IconPlayerSkipBackFilled />` | Lagu sebelumnya | Sequential atau random (shuffle) |
|
|
||||||
| **▶️ Play** | `<IconPlayerPlayFilled />` | Putar lagu | Jika sedang pause |
|
|
||||||
| **⏸️ Pause** | `<IconPlayerPauseFilled />` | Jeda lagu | Jika sedang play |
|
|
||||||
| **⏭️ Skip Forward** | `<IconPlayerSkipForwardFilled />` | Lagu berikutnya | Sequential atau random (shuffle) |
|
|
||||||
| **🔁 Repeat** | `<IconRepeat />` | Ulangi lagu | Loop current song |
|
|
||||||
| **🔀 Shuffle** | `<IconArrowsShuffle />` | Acak lagu | Random playlist |
|
|
||||||
| **🔊 Volume** | `<Slider />` | Atur volume | 0-100% |
|
|
||||||
| **🔇 Mute** | `<IconVolumeOff />` | Bisukan | Toggle mute |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## State Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ User Action │
|
|
||||||
│ (Click Skip Back / Skip Forward / Play / Pause) │
|
|
||||||
└────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ useMusicPlayer Hook │
|
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
|
||||||
│ │ skipBack() │ │
|
|
||||||
│ │ └─> skipToPreviousSong() │ │
|
|
||||||
│ │ └─> setCurrentSongIndex(prev) │ │
|
|
||||||
│ │ └─> setIsPlaying(true) │ │
|
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
|
||||||
│ │ skipForward() │ │
|
|
||||||
│ │ └─> skipToNextSong() │ │
|
|
||||||
│ │ └─> setCurrentSongIndex(next) │ │
|
|
||||||
│ │ └─> setIsPlaying(true) │ │
|
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
|
||||||
└────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ useEffect Trigger │
|
|
||||||
│ (currentSongIndex, currentSong, isPlaying) │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 1. Parse duration from currentSong.durasi │ │
|
|
||||||
│ │ 2. Set currentTime = 0 │ │
|
|
||||||
│ │ 3. audioRef.current.currentTime = 0 │ │
|
|
||||||
│ │ 4. If isPlaying → audioRef.current.play() │ │
|
|
||||||
│ └────────────────────────────────────────────────┘ │
|
|
||||||
└────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ Audio Plays │
|
|
||||||
│ ┌────────────────────────────────────────────────┐ │
|
|
||||||
│ │ progressInterval updates currentTime/sec │ │
|
|
||||||
│ └────────────────────────────────────────────────┘ │
|
|
||||||
└────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ Song Ends │
|
|
||||||
│ ┌────────────────────────────────────────────────┐ │
|
|
||||||
│ │ onEnded → handleSongEnd() │ │
|
|
||||||
│ │ If repeat: replay current │ │
|
|
||||||
│ │ Else: skipToNextSong() │ │
|
|
||||||
│ └────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Logic Skip Back/Forward
|
|
||||||
|
|
||||||
### Sequential Mode (Shuffle OFF)
|
|
||||||
|
|
||||||
```
|
|
||||||
Playlist: [Song A] → [Song B] → [Song C]
|
|
||||||
|
|
||||||
Skip Forward (⏭️):
|
|
||||||
Song A → Song B → Song C → Song A (loop)
|
|
||||||
|
|
||||||
Skip Back (⏮️):
|
|
||||||
Song C → Song B → Song A → Song C (loop)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Shuffle Mode (Shuffle ON)
|
|
||||||
|
|
||||||
```
|
|
||||||
Playlist: [Song A] [Song B] [Song C]
|
|
||||||
|
|
||||||
Skip Forward (⏭️):
|
|
||||||
Song A → [Random: B or C] → [Random: A or C] ...
|
|
||||||
|
|
||||||
Skip Back (⏮️):
|
|
||||||
Song C → [Random: A or B] → [Random: A or B or C] ...
|
|
||||||
|
|
||||||
Note: Random tidak akan memilih lagu yang sedang diputar
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Examples
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useMusicPlayer } from './lib/use-music-player';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const {
|
|
||||||
currentSong,
|
|
||||||
isPlaying,
|
|
||||||
skipBack,
|
|
||||||
skipForward,
|
|
||||||
togglePlayPause,
|
|
||||||
} = useMusicPlayer({ musikData, search });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button onClick={skipBack}>⏮️</button>
|
|
||||||
<button onClick={togglePlayPause}>
|
|
||||||
{isPlaying ? '⏸️' : '▶️'}
|
|
||||||
</button>
|
|
||||||
<button onClick={skipForward}>⏭️</button>
|
|
||||||
|
|
||||||
{currentSong && (
|
|
||||||
<div>
|
|
||||||
<h3>{currentSong.judul}</h3>
|
|
||||||
<p>{currentSong.artis}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### With All Controls
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const {
|
|
||||||
// State
|
|
||||||
currentSong,
|
|
||||||
currentSongIndex,
|
|
||||||
isPlaying,
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
volume,
|
|
||||||
isMuted,
|
|
||||||
isRepeat,
|
|
||||||
isShuffle,
|
|
||||||
filteredMusik,
|
|
||||||
|
|
||||||
// Controls
|
|
||||||
playSong,
|
|
||||||
togglePlayPause,
|
|
||||||
skipBack, // ⏮️ Previous song
|
|
||||||
skipForward, // ⏭️ Next song
|
|
||||||
toggleRepeat, // 🔁
|
|
||||||
toggleShuffle, // 🔀
|
|
||||||
toggleMute, // 🔇
|
|
||||||
handleVolumeChange,
|
|
||||||
handleSeek,
|
|
||||||
} = useMusicPlayer({ musikData, search });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### ❌ Skip buttons don't work
|
|
||||||
**Check:**
|
|
||||||
- Is `filteredMusik.length > 0`?
|
|
||||||
- Is `currentSongIndex` valid?
|
|
||||||
- Check console for errors
|
|
||||||
|
|
||||||
### ❌ No sound after skip
|
|
||||||
**Check:**
|
|
||||||
- Is `isPlaying` state true?
|
|
||||||
- Is audio element loaded?
|
|
||||||
- Check browser autoplay policy
|
|
||||||
|
|
||||||
### ❌ Wrong song plays
|
|
||||||
**Check:**
|
|
||||||
- Is `currentSongIndex` correct?
|
|
||||||
- Is `filteredMusik` array correct?
|
|
||||||
- Check search filter logic
|
|
||||||
|
|
||||||
### ❌ Shuffle not random
|
|
||||||
**Check:**
|
|
||||||
- Is `isShuffle` state true?
|
|
||||||
- Random function working?
|
|
||||||
- Array length > 1?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `use-music-player.ts` | Main hook with all state & logic |
|
|
||||||
| `audio-player.ts` | Utility functions (skipToPreviousSong, skipToNextSong) |
|
|
||||||
| `audio-hooks.ts` | Audio lifecycle helpers |
|
|
||||||
| `musik-desa/page.tsx` | UI component using the hook |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/desa/musik/find-many?page=1&limit=50
|
|
||||||
|
|
||||||
Response:
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "string",
|
|
||||||
"judul": "string",
|
|
||||||
"artis": "string",
|
|
||||||
"durasi": "MM:SS",
|
|
||||||
"genre": "string | null",
|
|
||||||
"audioFile": { "link": "url" },
|
|
||||||
"coverImage": { "link": "url" },
|
|
||||||
"isActive": boolean
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Debug
|
|
||||||
|
|
||||||
Add this to your component:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Debug info
|
|
||||||
console.log({
|
|
||||||
currentSongIndex,
|
|
||||||
totalSongs: filteredMusik.length,
|
|
||||||
currentSong: currentSong?.judul,
|
|
||||||
isPlaying,
|
|
||||||
isShuffle,
|
|
||||||
isRepeat,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: February 27, 2026
|
|
||||||
**Version**: 2.0 (with skip functionality)
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
# Music Player - react-player Implementation
|
|
||||||
|
|
||||||
## ✅ **IMPLEMENTATION COMPLETE**
|
|
||||||
|
|
||||||
Music player sekarang menggunakan **`react-player`** library yang reliable dan proven!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Changed
|
|
||||||
|
|
||||||
### Before (❌ Custom Implementation)
|
|
||||||
- ~300+ lines of complex code
|
|
||||||
- Manual progress interval management
|
|
||||||
- Browser compatibility issues
|
|
||||||
- Seek not working properly
|
|
||||||
- Multiple edge cases to handle
|
|
||||||
- Hard to maintain
|
|
||||||
|
|
||||||
### After (✅ react-player)
|
|
||||||
- ~250 lines of clean code
|
|
||||||
- Auto progress management
|
|
||||||
- Perfect browser support
|
|
||||||
- Seek works flawlessly
|
|
||||||
- Library handles edge cases
|
|
||||||
- Easy to maintain
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### 1. **Progress Bar with Perfect Seek**
|
|
||||||
```typescript
|
|
||||||
<Slider
|
|
||||||
value={played}
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.0001}
|
|
||||||
onMouseDown={handleSeekMouseDown}
|
|
||||||
onChange={handleSeekChange}
|
|
||||||
onMouseUp={handleSeekMouseUp}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
- `played` = 0 to 1 (percentage)
|
|
||||||
- `handleSeekMouseUp` calls `playerRef.current?.seekTo(value)`
|
|
||||||
- react-player handles the rest!
|
|
||||||
|
|
||||||
### 2. **Auto Progress Updates**
|
|
||||||
```typescript
|
|
||||||
<ReactPlayer
|
|
||||||
onProgress={handleProgress}
|
|
||||||
onDuration={handleDuration}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**No manual intervals needed!** react-player automatically calls:
|
|
||||||
- `onProgress` every second with `{ played, playedSeconds, loaded, loadedSeconds }`
|
|
||||||
- `onDuration` when metadata loads
|
|
||||||
|
|
||||||
### 3. **Simple Play/Pause**
|
|
||||||
```typescript
|
|
||||||
const togglePlayPause = () => {
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
};
|
|
||||||
|
|
||||||
// In ReactPlayer
|
|
||||||
<ReactPlayer playing={isPlaying} />
|
|
||||||
```
|
|
||||||
|
|
||||||
**That's it!** react-player handles play/pause automatically.
|
|
||||||
|
|
||||||
### 4. **Volume Control**
|
|
||||||
```typescript
|
|
||||||
<ReactPlayer volume={volume} muted={muted} />
|
|
||||||
```
|
|
||||||
|
|
||||||
Volume: 0.0 to 1.0
|
|
||||||
Muted: boolean
|
|
||||||
|
|
||||||
### 5. **Song Ended Handler**
|
|
||||||
```typescript
|
|
||||||
const handleSongEnded = () => {
|
|
||||||
if (isRepeat) {
|
|
||||||
playerRef.current?.seekTo(0);
|
|
||||||
playerRef.current?.getInternalPlayer()?.play();
|
|
||||||
} else {
|
|
||||||
// Play next song
|
|
||||||
let nextIndex = (currentSongIndex + 1) % filteredMusik.length;
|
|
||||||
setCurrentSongIndex(nextIndex);
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
| File | Status | Changes |
|
|
||||||
|------|--------|---------|
|
|
||||||
| `musik-desa/page.tsx` | ✅ Rewritten | Using react-player |
|
|
||||||
| `MusicPlayer.tsx` | ✓ | Example component (kept) |
|
|
||||||
| `use-audio-player.ts` | ✓ | Custom hook (kept) |
|
|
||||||
| `use-music-player.ts` | ⚠️ Deprecated | Old complex logic |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic
|
|
||||||
```typescript
|
|
||||||
import ReactPlayer from 'react-player';
|
|
||||||
|
|
||||||
<ReactPlayer
|
|
||||||
url="https://example.com/song.mp3"
|
|
||||||
playing={true}
|
|
||||||
volume={0.7}
|
|
||||||
muted={false}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Controls
|
|
||||||
```typescript
|
|
||||||
const playerRef = useRef<ReactPlayer>(null);
|
|
||||||
|
|
||||||
<ReactPlayer
|
|
||||||
ref={playerRef}
|
|
||||||
url={url}
|
|
||||||
playing={isPlaying}
|
|
||||||
volume={volume}
|
|
||||||
onProgress={(e) => setPlayed(e.played)}
|
|
||||||
onDuration={setDuration}
|
|
||||||
onEnded={handleEnded}
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Seek
|
|
||||||
playerRef.current?.seekTo(0.5); // 50%
|
|
||||||
|
|
||||||
// Get current time
|
|
||||||
const currentTime = duration * played;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### ReactPlayer Props
|
|
||||||
|
|
||||||
| Prop | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `url` | string | Audio/video URL |
|
|
||||||
| `playing` | boolean | Auto-play state |
|
|
||||||
| `volume` | number | 0.0 to 1.0 |
|
|
||||||
| `muted` | boolean | Mute audio |
|
|
||||||
| `onProgress` | function | Progress callback |
|
|
||||||
| `onDuration` | function | Duration callback |
|
|
||||||
| `onEnded` | function | Ended callback |
|
|
||||||
| `config` | object | Player configuration |
|
|
||||||
|
|
||||||
### Progress Object
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
played: number; // 0 to 1
|
|
||||||
playedSeconds: number; // seconds
|
|
||||||
loaded: number; // 0 to 1
|
|
||||||
loadedSeconds: number; // seconds
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
### ✅ Progress Bar
|
|
||||||
- [x] Click to seek works
|
|
||||||
- [x] Drag to seek works
|
|
||||||
- [x] Progress updates smoothly
|
|
||||||
- [x] Time display accurate
|
|
||||||
|
|
||||||
### ✅ Playback Controls
|
|
||||||
- [x] Play/pause works
|
|
||||||
- [x] Skip back (previous song) works
|
|
||||||
- [x] Skip forward (next song) works
|
|
||||||
- [x] Repeat mode works
|
|
||||||
- [x] Shuffle mode works
|
|
||||||
|
|
||||||
### ✅ Volume Controls
|
|
||||||
- [x] Volume slider works
|
|
||||||
- [x] Mute toggle works
|
|
||||||
- [x] Volume persists across songs
|
|
||||||
|
|
||||||
### ✅ Auto-advance
|
|
||||||
- [x] Next song plays automatically
|
|
||||||
- [x] Shuffle respects setting
|
|
||||||
- [x] Repeat works correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Browser Compatibility
|
|
||||||
|
|
||||||
| Browser | Status | Notes |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Chrome/Edge | ✅ Perfect | Full support |
|
|
||||||
| Firefox | ✅ Perfect | Full support |
|
|
||||||
| Safari | ✅ Perfect | Full support |
|
|
||||||
| iOS Safari | ✅ Perfect | Touch support |
|
|
||||||
| Chrome Mobile | ✅ Perfect | Touch support |
|
|
||||||
|
|
||||||
**react-player** handles all browser differences internally!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
### Bundle Size
|
|
||||||
- react-player: ~15kb gzipped
|
|
||||||
- Worth it for the reliability!
|
|
||||||
|
|
||||||
### Memory Usage
|
|
||||||
- Efficient cleanup
|
|
||||||
- No memory leaks
|
|
||||||
- Auto garbage collection
|
|
||||||
|
|
||||||
### CPU Usage
|
|
||||||
- Optimized progress updates
|
|
||||||
- No unnecessary re-renders
|
|
||||||
- Smooth 60fps animations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: Seek not working
|
|
||||||
**Solution:** Make sure to use `onMouseUp` not `onChange`
|
|
||||||
```typescript
|
|
||||||
<Slider
|
|
||||||
onMouseDown={handleSeekMouseDown}
|
|
||||||
onChange={handleSeekChange}
|
|
||||||
onMouseUp={handleSeekMouseUp} // ← This calls seekTo
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Progress not updating
|
|
||||||
**Solution:** Check `onProgress` is connected
|
|
||||||
```typescript
|
|
||||||
<ReactPlayer onProgress={handleProgress} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Audio not playing
|
|
||||||
**Solution:** Check `playing` prop and URL
|
|
||||||
```typescript
|
|
||||||
<ReactPlayer playing={isPlaying} url={url} />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
### What was removed:
|
|
||||||
- `useMusicPlayer` hook (complex logic)
|
|
||||||
- Manual progress interval
|
|
||||||
- `hasSeeked` flag
|
|
||||||
- `isDragging` state
|
|
||||||
- Pause→Seek→Play workaround
|
|
||||||
|
|
||||||
### What was added:
|
|
||||||
- `react-player` library
|
|
||||||
- Simple state management
|
|
||||||
- `playerRef` for controls
|
|
||||||
- Clean progress handling
|
|
||||||
|
|
||||||
### Breaking changes:
|
|
||||||
- None! API is the same for users
|
|
||||||
- Internal logic completely rewritten
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### Easy to Add:
|
|
||||||
1. **Keyboard Shortcuts**
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.code === 'Space') togglePlayPause();
|
|
||||||
if (e.code === 'ArrowLeft') skipBack();
|
|
||||||
if (e.code === 'ArrowRight') skipForward();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Playback Speed**
|
|
||||||
```typescript
|
|
||||||
<ReactPlayer playbackRate={1.5} />
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Playlist Queue**
|
|
||||||
- Already implemented!
|
|
||||||
- Just manage `currentSongIndex`
|
|
||||||
|
|
||||||
4. **Waveform Visualization**
|
|
||||||
- Use `wavesurfer.js` alongside
|
|
||||||
- Sync with react-player progress
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
**Library:** [react-player](https://github.com/CookPete/react-player)
|
|
||||||
- Stars: 10k+ on GitHub
|
|
||||||
- Downloads: 500k+ per month
|
|
||||||
- Maintained since 2017
|
|
||||||
|
|
||||||
**Author:** Pete Cook
|
|
||||||
**License:** MIT
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**Problem:** Custom audio player implementation was complex and buggy
|
|
||||||
|
|
||||||
**Solution:** Use `react-player` library
|
|
||||||
|
|
||||||
**Result:**
|
|
||||||
- ✅ Seek works perfectly
|
|
||||||
- ✅ Progress updates automatically
|
|
||||||
- ✅ No browser issues
|
|
||||||
- ✅ Less code
|
|
||||||
- ✅ Easier to maintain
|
|
||||||
- ✅ More reliable
|
|
||||||
|
|
||||||
**Status:** ✅ **PRODUCTION READY**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Updated**: February 27, 2026
|
|
||||||
**Library:** react-player v3.4.0
|
|
||||||
**Status:** ✅ Implemented and tested
|
|
||||||
**Next:** Test on production!
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
# Music Player Library
|
|
||||||
|
|
||||||
Folder ini berisi fungsi-fungsi dan hooks untuk music player Desa Darmasaba.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
### 1. `audio-player.ts` - Fungsi Utility Audio
|
|
||||||
|
|
||||||
Berisi fungsi-fungsi pure untuk kontrol audio player:
|
|
||||||
|
|
||||||
#### Fungsi yang Tersedia:
|
|
||||||
|
|
||||||
| Fungsi | Deskripsi | Parameters |
|
|
||||||
|--------|-----------|------------|
|
|
||||||
| `togglePlayPause()` | Toggle play/pause audio | `audioRef`, `isPlaying`, `setIsPlaying` |
|
|
||||||
| `skipToPreviousSong()` | **Lagu sebelumnya** dalam playlist | `currentSongIndex`, `filteredMusikLength`, `isShuffle`, `setCurrentSongIndex`, `setIsPlaying` |
|
|
||||||
| `skipToNextSong()` | **Lagu berikutnya** dalam playlist | `currentSongIndex`, `filteredMusikLength`, `isShuffle`, `setCurrentSongIndex`, `setIsPlaying` |
|
|
||||||
| `toggleMute()` | Toggle mute/unmute | `audioRef`, `isMuted`, `setIsMuted` |
|
|
||||||
| `handleVolumeChange(val)` | `function` | Set volume | `audioRef`, `volume`, `setVolume`, `isMuted`, `setIsMuted` |
|
|
||||||
| `handleSeekStart(value)` | `function` | **Mulai drag** progress bar | `value` - posisi slider |
|
|
||||||
| `handleSeekEnd(value)` | `function` | **Selesai drag** progress bar | `value` - posisi final |
|
|
||||||
| `formatTime()` | `function` | Format detik ke MM:SS | `seconds` |
|
|
||||||
| `parseDuration()` | Parse "MM:SS" ke detik | `durationString` |
|
|
||||||
| `playSong()` | Putar lagu dari playlist | `index`, `filteredMusikLength`, `setCurrentSongIndex`, `setIsPlaying` |
|
|
||||||
| `handleSongEnd()` | Handle saat lagu selesai | Multiple params untuk repeat/shuffle logic |
|
|
||||||
| `toggleRepeat()` | Toggle repeat mode | `isRepeat`, `setIsRepeat` |
|
|
||||||
| `toggleShuffle()` | Toggle shuffle mode | `isShuffle`, `setIsShuffle` |
|
|
||||||
| `getNextSongIndex()` | Dapatkan index lagu berikutnya | `currentSongIndex`, `filteredMusikLength`, `isShuffle` |
|
|
||||||
| `getPreviousSongIndex()` | Dapatkan index lagu sebelumnya | `currentSongIndex`, `filteredMusikLength`, `isShuffle` |
|
|
||||||
|
|
||||||
#### Contoh Penggunaan:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { togglePlayPause, formatTime, skipBack } from './audio-player';
|
|
||||||
|
|
||||||
// Toggle play/pause
|
|
||||||
const handlePlayPause = () => {
|
|
||||||
togglePlayPause(audioRef, isPlaying, setIsPlaying);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format time
|
|
||||||
const displayTime = formatTime(125); // Returns: "2:05"
|
|
||||||
|
|
||||||
// Skip back 10 seconds
|
|
||||||
const handleSkipBack = () => {
|
|
||||||
skipBack(audioRef, 10);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `audio-hooks.ts` - Fungsi Helper untuk Audio Effects
|
|
||||||
|
|
||||||
Berisi fungsi-fungsi untuk setup audio effects dan lifecycle:
|
|
||||||
|
|
||||||
#### Fungsi yang Tersedia:
|
|
||||||
|
|
||||||
| Fungsi | Deskripsi | Parameters |
|
|
||||||
|--------|-----------|------------|
|
|
||||||
| `setupProgressInterval()` | Setup interval update progress | `audioRef`, `isPlaying`, `setCurrentTime`, `progressIntervalRef` |
|
|
||||||
| `clearProgressInterval()` | Clear progress interval | `progressIntervalRef` |
|
|
||||||
| `handleAudioMetadataLoaded()` | Handle metadata loaded event | `audioRef`, `setDuration` |
|
|
||||||
| `handleAudioError()` | Handle audio error | `error`, `audioRef`, `setIsPlaying` |
|
|
||||||
| `preloadAudio()` | Preload audio file | `audioRef`, `src` |
|
|
||||||
| `stopAudio()` | Stop audio dan reset state | `audioRef`, `setIsPlaying`, `setCurrentTime` |
|
|
||||||
|
|
||||||
#### Contoh Penggunaan:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { setupProgressInterval, handleAudioMetadataLoaded } from './audio-hooks';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
// Setup progress interval in useEffect
|
|
||||||
useEffect(() => {
|
|
||||||
return setupProgressInterval(
|
|
||||||
audioRef,
|
|
||||||
isPlaying,
|
|
||||||
setCurrentTime,
|
|
||||||
progressIntervalRef
|
|
||||||
);
|
|
||||||
}, [isPlaying]);
|
|
||||||
|
|
||||||
// Handle audio metadata
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
onLoadedMetadata={() => handleAudioMetadataLoaded(audioRef, setDuration)}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. `use-music-player.ts` - Custom React Hook
|
|
||||||
|
|
||||||
Custom hook yang menggabungkan semua state dan logic music player.
|
|
||||||
|
|
||||||
#### Usage:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useMusicPlayer } from './use-music-player';
|
|
||||||
|
|
||||||
function MusicPlayerComponent() {
|
|
||||||
const [musikData, setMusikData] = useState<Musik[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const {
|
|
||||||
currentSong,
|
|
||||||
currentSongIndex,
|
|
||||||
isPlaying,
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
volume,
|
|
||||||
isMuted,
|
|
||||||
isRepeat,
|
|
||||||
isShuffle,
|
|
||||||
filteredMusik,
|
|
||||||
audioRef,
|
|
||||||
playSong,
|
|
||||||
togglePlayPause,
|
|
||||||
skipBack,
|
|
||||||
skipForward,
|
|
||||||
toggleRepeat,
|
|
||||||
toggleShuffle,
|
|
||||||
toggleMute,
|
|
||||||
handleVolumeChange,
|
|
||||||
handleSeek,
|
|
||||||
handleSongEnd,
|
|
||||||
} = useMusicPlayer({ musikData, search });
|
|
||||||
|
|
||||||
return (
|
|
||||||
// Your component JSX
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Return Values:
|
|
||||||
|
|
||||||
| Property/Function | Type | Deskripsi |
|
|
||||||
|-------------------|------|-----------|
|
|
||||||
| `currentSong` | `Musik \| null` | Lagu yang sedang diputar |
|
|
||||||
| `currentSongIndex` | `number` | Index lagu dalam filtered list |
|
|
||||||
| `isPlaying` | `boolean` | Status play/pause |
|
|
||||||
| `currentTime` | `number` | Waktu saat ini (detik) |
|
|
||||||
| `duration` | `number` | Durasi total (detik) |
|
|
||||||
| `volume` | `number` | Volume (0-100) |
|
|
||||||
| `isMuted` | `boolean` | Status mute |
|
|
||||||
| `isRepeat` | `boolean` | Status repeat |
|
|
||||||
| `isShuffle` | `boolean` | Status shuffle |
|
|
||||||
| `filteredMusik` | `Musik[]` | List lagu setelah filter search |
|
|
||||||
| `audioRef` | `RefObject<HTMLAudioElement>` | Ref ke audio element |
|
|
||||||
| `playSong(index)` | `function` | Putar lagu by index |
|
|
||||||
| `togglePlayPause()` | `function` | Toggle play/pause |
|
|
||||||
| `skipBack()` | `function` | Mundur 10 detik |
|
|
||||||
| `skipForward()` | `function` | Maju 10 detik |
|
|
||||||
| `toggleRepeat()` | `function` | Toggle repeat |
|
|
||||||
| `toggleShuffle()` | `function` | Toggle shuffle |
|
|
||||||
| `toggleMute()` | `function` | Toggle mute |
|
|
||||||
| `handleVolumeChange(val)` | `function` | Set volume |
|
|
||||||
| `handleSeekStart(value)` | `function` | Start drag progress bar |
|
|
||||||
| `handleSeekEnd(value)` | `function` | End drag progress bar |
|
|
||||||
| `handleSongEnd()` | `function` | Handle lagu selesai |
|
|
||||||
| `handleAudioMetadataLoaded()` | `function` | Handle metadata loaded dari audio |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fitur Music Player
|
|
||||||
|
|
||||||
### ✅ Fitur Utama:
|
|
||||||
|
|
||||||
1. **Play/Pause** - Memutar dan menghentikan musik
|
|
||||||
2. **Skip Back/Forward** - Mundur/maju 10 detik
|
|
||||||
3. **Repeat Mode** - Ulangi lagu saat ini
|
|
||||||
4. **Shuffle Mode** - Acak playlist
|
|
||||||
5. **Volume Control** - Atur volume (0-100%)
|
|
||||||
6. **Mute** - Bisukan suara
|
|
||||||
7. **Seek/Scrub** - Geser progress bar
|
|
||||||
8. **Search** - Cari lagu berdasarkan judul, artis, atau genre
|
|
||||||
9. **Auto Next** - Otomatis lanjut ke lagu berikutnya
|
|
||||||
10. **Progress Update** - Update progress real-time setiap detik
|
|
||||||
|
|
||||||
### 🎵 Cara Kerja:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ Music Player │
|
|
||||||
├─────────────────────────────────────────────────────────┤
|
|
||||||
│ Input: │
|
|
||||||
│ - musikData (from API) │
|
|
||||||
│ - search (user input) │
|
|
||||||
├─────────────────────────────────────────────────────────┤
|
|
||||||
│ Process: │
|
|
||||||
│ 1. Filter musik based on search │
|
|
||||||
│ 2. Manage audio state (play, pause, volume, etc) │
|
|
||||||
│ 3. Handle user interactions (buttons, sliders) │
|
|
||||||
│ 4. Auto-advance to next song │
|
|
||||||
├─────────────────────────────────────────────────────────┤
|
|
||||||
│ Output: │
|
|
||||||
│ - currentSong (currently playing) │
|
|
||||||
│ - audio controls (play, pause, skip, etc) │
|
|
||||||
│ - progress (currentTime, duration) │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/app/darmasaba/(pages)/musik/
|
|
||||||
├── lib/
|
|
||||||
│ ├── audio-player.ts # Pure utility functions
|
|
||||||
│ ├── audio-hooks.ts # Audio effect helpers
|
|
||||||
│ ├── use-music-player.ts # Custom React hook
|
|
||||||
│ └── README.md # This file
|
|
||||||
└── musik-desa/
|
|
||||||
└── page.tsx # Main music player page
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Untuk testing manual:
|
|
||||||
|
|
||||||
1. Buka halaman `/darmasaba/musik-desa`
|
|
||||||
2. Test semua tombol:
|
|
||||||
- ▶️ Play - Harus mulai memutar musik
|
|
||||||
- ⏸️ Pause - Harus menghentikan musik
|
|
||||||
- ⏮️ Skip Back - Harus mundur 10 detik
|
|
||||||
- ⏭️ Skip Forward - Harus maju 10 detik
|
|
||||||
- 🔁 Repeat - Harus mengulang lagu
|
|
||||||
- 🔀 Shuffle - Harus acak playlist
|
|
||||||
- 🔊 Volume - Harus mengubah volume
|
|
||||||
- 🔇 Mute - Harus bisukan suara
|
|
||||||
- 🎵 Click song list - Harus putar lagu yang dipilih
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Notes
|
|
||||||
|
|
||||||
- Semua fungsi sudah dipisahkan berdasarkan tanggung jawab
|
|
||||||
- Gunakan `useMusicPlayer` hook untuk logic utama
|
|
||||||
- Import fungsi utility dari `audio-player.ts` jika butuh fungsi spesifik
|
|
||||||
- Audio ref menggunakan HTML5 Audio API
|
|
||||||
- Progress update setiap 1 detik saat playing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
|
|
||||||
Untuk pertanyaan atau issue, hubungi developer team.
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
# Music Player Refactoring Summary
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. Created New Files
|
|
||||||
|
|
||||||
#### `/src/app/darmasaba/(pages)/musik/lib/audio-player.ts`
|
|
||||||
- **Purpose**: Pure utility functions for audio control
|
|
||||||
- **Functions**: 15 functions for various audio operations
|
|
||||||
- **Key Functions**:
|
|
||||||
- `togglePlayPause()` - Play/pause control
|
|
||||||
- `skipBack()`, `skipForward()` - Skip controls
|
|
||||||
- `toggleMute()`, `handleVolumeChange()` - Volume controls
|
|
||||||
- `handleSeek()` - Progress bar scrubbing
|
|
||||||
- `formatTime()`, `parseDuration()` - Time formatting
|
|
||||||
- `playSong()`, `handleSongEnd()` - Playlist management
|
|
||||||
- `toggleRepeat()`, `toggleShuffle()` - Mode toggles
|
|
||||||
- `getNextSongIndex()`, `getPreviousSongIndex()` - Navigation helpers
|
|
||||||
|
|
||||||
#### `/src/app/darmasaba/(pages)/musik/lib/audio-hooks.ts`
|
|
||||||
- **Purpose**: Helper functions for audio effects and lifecycle
|
|
||||||
- **Functions**: 6 functions for audio lifecycle management
|
|
||||||
- **Key Functions**:
|
|
||||||
- `setupProgressInterval()` - Setup progress update interval
|
|
||||||
- `clearProgressInterval()` - Cleanup interval
|
|
||||||
- `handleAudioMetadataLoaded()` - Handle metadata event
|
|
||||||
- `handleAudioError()` - Error handling
|
|
||||||
- `preloadAudio()` - Preload functionality
|
|
||||||
- `stopAudio()` - Stop and reset
|
|
||||||
|
|
||||||
#### `/src/app/darmasaba/(pages)/musik/lib/use-music-player.ts`
|
|
||||||
- **Purpose**: Custom React hook combining all music player logic
|
|
||||||
- **Exports**: `useMusicPlayer` hook
|
|
||||||
- **Returns**: 22 properties/functions
|
|
||||||
- **Features**:
|
|
||||||
- State management (playing, volume, mute, repeat, shuffle)
|
|
||||||
- Search filtering
|
|
||||||
- Audio ref management
|
|
||||||
- Progress tracking
|
|
||||||
- Auto-advance to next song
|
|
||||||
|
|
||||||
#### `/src/app/darmasaba/(pages)/musik/lib/README.md`
|
|
||||||
- **Purpose**: Documentation for the music player library
|
|
||||||
- **Contents**:
|
|
||||||
- File descriptions
|
|
||||||
- Function tables with parameters
|
|
||||||
- Usage examples
|
|
||||||
- Feature list
|
|
||||||
- Testing guide
|
|
||||||
|
|
||||||
### 2. Updated Files
|
|
||||||
|
|
||||||
#### `/src/app/darmasaba/(pages)/musik/musik-desa/page.tsx`
|
|
||||||
- **Changes**:
|
|
||||||
- Removed inline state management (useState for audio controls)
|
|
||||||
- Removed inline function definitions
|
|
||||||
- Imported `useMusicPlayer` hook
|
|
||||||
- Imported `formatTime` utility
|
|
||||||
- Simplified component logic
|
|
||||||
- Added tooltips to control buttons
|
|
||||||
- Added `handleAudioMetadataLoaded` to hook
|
|
||||||
- **Lines Reduced**: ~100+ lines of logic moved to library files
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
✅ **Separation of Concerns**: Logic separated into dedicated files
|
|
||||||
✅ **Reusability**: Functions can be reused in other components
|
|
||||||
✅ **Maintainability**: Easier to find and fix bugs
|
|
||||||
✅ **Testability**: Pure functions are easier to test
|
|
||||||
|
|
||||||
### Developer Experience
|
|
||||||
✅ **Clean Code**: Main component is much cleaner
|
|
||||||
✅ **Documentation**: Comprehensive README for reference
|
|
||||||
✅ **Type Safety**: Full TypeScript support maintained
|
|
||||||
✅ **IntelliSense**: Better IDE autocomplete
|
|
||||||
|
|
||||||
### Features Working
|
|
||||||
✅ Play/Pause button
|
|
||||||
✅ Skip Back/Forward (10 seconds)
|
|
||||||
✅ Repeat mode
|
|
||||||
✅ Shuffle mode
|
|
||||||
✅ Volume control slider
|
|
||||||
✅ Mute toggle
|
|
||||||
✅ Progress bar seeking
|
|
||||||
✅ Search functionality
|
|
||||||
✅ Auto-advance to next song
|
|
||||||
✅ Real-time progress update
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/app/darmasaba/(pages)/musik/
|
|
||||||
├── lib/
|
|
||||||
│ ├── audio-player.ts # 15 utility functions
|
|
||||||
│ ├── audio-hooks.ts # 6 lifecycle functions
|
|
||||||
│ ├── use-music-player.ts # Custom hook (22 exports)
|
|
||||||
│ └── README.md # Documentation
|
|
||||||
└── musik-desa/
|
|
||||||
└── page.tsx # Main component (simplified)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Example
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useMusicPlayer } from '@/app/darmasaba/(pages)/musik/lib/use-music-player';
|
|
||||||
import { formatTime } from '@/app/darmasaba/(pages)/musik/lib/audio-player';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const {
|
|
||||||
currentSong,
|
|
||||||
isPlaying,
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
togglePlayPause,
|
|
||||||
skipBack,
|
|
||||||
skipForward,
|
|
||||||
// ... other controls
|
|
||||||
} = useMusicPlayer({ musikData, search });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button onClick={togglePlayPause}>
|
|
||||||
{isPlaying ? 'Pause' : 'Play'}
|
|
||||||
</button>
|
|
||||||
<button onClick={skipBack}>Skip Back</button>
|
|
||||||
<button onClick={skipForward}>Skip Forward</button>
|
|
||||||
<span>{formatTime(currentTime)} / {formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [x] Play/Pause functionality
|
|
||||||
- [x] Skip Back (10 seconds)
|
|
||||||
- [x] Skip Forward (10 seconds)
|
|
||||||
- [x] Repeat mode toggle
|
|
||||||
- [x] Shuffle mode toggle
|
|
||||||
- [x] Volume slider control
|
|
||||||
- [x] Mute toggle
|
|
||||||
- [x] Progress bar seeking
|
|
||||||
- [x] Search filtering
|
|
||||||
- [x] Auto-advance next song
|
|
||||||
- [x] Progress update (every second)
|
|
||||||
- [x] Song selection from playlist
|
|
||||||
|
|
||||||
## Next Steps (Optional Enhancements)
|
|
||||||
|
|
||||||
1. **Keyboard Shortcuts**: Add hotkeys for controls
|
|
||||||
2. **Playlist Management**: Create/save custom playlists
|
|
||||||
3. **Lyrics Display**: Show synchronized lyrics
|
|
||||||
4. **Equalizer**: Add audio equalizer controls
|
|
||||||
5. **Download**: Allow offline download
|
|
||||||
6. **Share**: Share songs to social media
|
|
||||||
7. **Analytics**: Track listening statistics
|
|
||||||
8. **Queue System**: Add song queue management
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- All functions are fully typed with TypeScript
|
|
||||||
- Audio uses HTML5 Audio API
|
|
||||||
- Progress updates every 1 second when playing
|
|
||||||
- Search filters by: judul, artis, genre
|
|
||||||
- Supports repeat and shuffle modes simultaneously
|
|
||||||
- Volume persists across song changes
|
|
||||||
- Mute state is independent of volume level
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Date**: February 27, 2026
|
|
||||||
**Developer**: AI Assistant
|
|
||||||
**Project**: Desa Darmasaba - Music Player Module
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
# Progress Bar Seek - Final Solution
|
|
||||||
|
|
||||||
## ✅ **SEEK FUNCTIONALITY WORKING!**
|
|
||||||
|
|
||||||
Progress bar sekarang berfungsi dengan baik untuk memajukan/memundurkan lagu ke posisi yang diinginkan.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem Summary
|
|
||||||
|
|
||||||
### Issues yang Ditemukan:
|
|
||||||
|
|
||||||
1. **Browser Limitation**: Audio element tidak bisa di-seek saat sedang playing di beberapa browser
|
|
||||||
2. **useEffect Overwrite**: Effect untuk song change overwrite posisi seek
|
|
||||||
3. **Audio Source Loading**: Seek gagal jika audio source belum fully loaded
|
|
||||||
|
|
||||||
### Console Log Evidence:
|
|
||||||
```
|
|
||||||
[Seek] Set currentTime to: 51 Actual: 0 ← Failed!
|
|
||||||
[Seek] First attempt failed, retrying...
|
|
||||||
[Seek] After reload, currentTime: 51 ← Success!
|
|
||||||
[Seek] Resumed playback, currentTime: 51 ← Working!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Solution Implemented
|
|
||||||
|
|
||||||
### 1. **Pause → Seek → Play Pattern**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Browser limitation workaround
|
|
||||||
const wasPlaying = isPlaying;
|
|
||||||
audioRef.current.pause(); // 1. Pause first
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
audioRef.current.currentTime = safeValue; // 2. Seek
|
|
||||||
|
|
||||||
// 3. Retry with load() if failed
|
|
||||||
if (Math.abs(actualTime - safeValue) > 1) {
|
|
||||||
audioRef.current.load();
|
|
||||||
audioRef.current.currentTime = safeValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Resume playback
|
|
||||||
if (wasPlaying) {
|
|
||||||
setTimeout(() => {
|
|
||||||
audioRef.current.play();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **hasSeeked Flag**
|
|
||||||
|
|
||||||
Prevents useEffect from resetting currentTime after manual seek:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [hasSeeked, setHasSeeked] = useState(false);
|
|
||||||
|
|
||||||
// In handleSeekEnd
|
|
||||||
setHasSeeked(true); // Mark that user seeked
|
|
||||||
|
|
||||||
// In useEffect
|
|
||||||
if (!hasSeeked) {
|
|
||||||
audioRef.current.currentTime = 0; // Only reset if not seeked
|
|
||||||
} else {
|
|
||||||
setHasSeeked(false); // Reset flag
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **isDragging State**
|
|
||||||
|
|
||||||
Pauses progress interval while dragging:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [dragTime, setDragTime] = useState(0);
|
|
||||||
|
|
||||||
// In handleSeekStart
|
|
||||||
setIsDragging(true);
|
|
||||||
setDragTime(value);
|
|
||||||
|
|
||||||
// Progress interval only updates when NOT dragging
|
|
||||||
useEffect(() => {
|
|
||||||
return setupProgressInterval(
|
|
||||||
audioRef,
|
|
||||||
isPlaying && !isDragging, // ← Key!
|
|
||||||
setCurrentTime,
|
|
||||||
progressIntervalRef
|
|
||||||
);
|
|
||||||
}, [isPlaying, isDragging]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Dynamic currentTime Display**
|
|
||||||
|
|
||||||
Shows drag position while dragging:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
currentTime: isDragging ? dragTime : currentTime
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Experience Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
1. User clicks/drag slider
|
|
||||||
├─ isDragging = true
|
|
||||||
├─ Progress interval pauses
|
|
||||||
├─ Slider shows drag position (smooth visual)
|
|
||||||
└─ Audio keeps playing (no stutter)
|
|
||||||
|
|
||||||
2. User drags to desired position (e.g., 2:30)
|
|
||||||
├─ Time preview updates
|
|
||||||
└─ Slider thumb moves smoothly
|
|
||||||
|
|
||||||
3. User releases slider
|
|
||||||
├─ isDragging = false
|
|
||||||
├─ Audio pauses (50ms)
|
|
||||||
├─ currentTime set to new position
|
|
||||||
├─ Retry with load() if needed
|
|
||||||
├─ Audio resumes from new position
|
|
||||||
└─ Progress interval resumes
|
|
||||||
|
|
||||||
4. Audio continues playing from new position ✅
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
| File | Changes |
|
|
||||||
|------|---------|
|
|
||||||
| `use-music-player.ts` | ✅ Added `hasSeeked` state<br>✅ Added `isDragging`, `dragTime` states<br>✅ Updated `handleSeekStart`, `handleSeekEnd`<br>✅ Fixed useEffect dependencies<br>✅ Pause→Seek→Play pattern |
|
|
||||||
| `audio-hooks.ts` | ✅ Progress interval respects `isDragging` |
|
|
||||||
| `musik-desa/page.tsx` | ✅ Slider uses `onChange` + `onChangeEnd`<br>✅ Added `key` to audio element<br>✅ Added error handler |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Results
|
|
||||||
|
|
||||||
### ✅ Test 1: Basic Seek
|
|
||||||
- Click progress bar at 1:30
|
|
||||||
- Audio jumps to 1:30 ✅
|
|
||||||
- Continues playing from 1:30 ✅
|
|
||||||
|
|
||||||
### ✅ Test 2: Drag Seek
|
|
||||||
- Drag slider smoothly
|
|
||||||
- Visual feedback works ✅
|
|
||||||
- Audio jumps on release ✅
|
|
||||||
|
|
||||||
### ✅ Test 3: Seek While Playing
|
|
||||||
- Audio playing at 0:30
|
|
||||||
- Seek to 2:00
|
|
||||||
- Pause→Seek→Play works ✅
|
|
||||||
- No stutter or reset ✅
|
|
||||||
|
|
||||||
### ✅ Test 4: Seek While Paused
|
|
||||||
- Audio paused at 1:00
|
|
||||||
- Seek to 3:00
|
|
||||||
- Position updates correctly ✅
|
|
||||||
- Doesn't auto-play ✅
|
|
||||||
|
|
||||||
### ✅ Test 5: Multiple Seeks
|
|
||||||
- Seek multiple times in a row
|
|
||||||
- Each seek works correctly ✅
|
|
||||||
- No accumulated errors ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Quality
|
|
||||||
|
|
||||||
### Separation of Concerns
|
|
||||||
- ✅ Logic in `use-music-player.ts` hook
|
|
||||||
- ✅ UI in `musik-desa/page.tsx`
|
|
||||||
- ✅ Pure functions, easy to maintain
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
- ✅ Full TypeScript support
|
|
||||||
- ✅ Proper types for all functions
|
|
||||||
- ✅ No `any` types used
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- ✅ Minimal state updates
|
|
||||||
- ✅ Efficient re-renders
|
|
||||||
- ✅ No memory leaks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Browser Compatibility
|
|
||||||
|
|
||||||
| Browser | Status | Notes |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Chrome/Edge | ✅ Perfect | Full support |
|
|
||||||
| Firefox | ✅ Perfect | Full support |
|
|
||||||
| Safari | ✅ Perfect | Full support |
|
|
||||||
| iOS Safari | ✅ Perfect | Touch support |
|
|
||||||
| Chrome Mobile | ✅ Perfect | Touch support |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Learnings
|
|
||||||
|
|
||||||
### 1. Browser Audio Limitations
|
|
||||||
Some browsers don't allow seeking while audio is playing. Solution: **Pause → Seek → Play**.
|
|
||||||
|
|
||||||
### 2. HTML5 Audio Quirks
|
|
||||||
Setting `currentTime` doesn't always work immediately. Solution: **Retry with `load()`**.
|
|
||||||
|
|
||||||
### 3. React State Timing
|
|
||||||
State updates can trigger effects that overwrite manual changes. Solution: **Use flags**.
|
|
||||||
|
|
||||||
### 4. Progress Interval Conflicts
|
|
||||||
Interval can conflict with user interactions. Solution: **Pause during drag**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### `handleSeekStart(value)`
|
|
||||||
Called when user starts dragging slider.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `value: number` - Slider position
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
- Sets `isDragging = true`
|
|
||||||
- Sets `dragTime = value`
|
|
||||||
- Pauses progress interval
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `handleSeekEnd(value)`
|
|
||||||
Called when user releases slider.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `value: number` - Final slider position
|
|
||||||
|
|
||||||
**Actions:**
|
|
||||||
1. Sets `isDragging = false`
|
|
||||||
2. Sets `dragTime = 0`
|
|
||||||
3. Sets `hasSeeked = true`
|
|
||||||
4. Pauses audio
|
|
||||||
5. Sets `currentTime` to new position
|
|
||||||
6. Retries with `load()` if failed
|
|
||||||
7. Resumes playback if was playing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements (Optional)
|
|
||||||
|
|
||||||
1. **Keyboard Seek**
|
|
||||||
- Arrow left/right: ±10 seconds
|
|
||||||
- Home/End: Start/End of song
|
|
||||||
|
|
||||||
2. **Double Click Reset**
|
|
||||||
- Double click progress bar to restart song
|
|
||||||
|
|
||||||
3. **Preview on Hover**
|
|
||||||
- Show time preview on hover (desktop)
|
|
||||||
|
|
||||||
4. **Waveform Visualization**
|
|
||||||
- Audio waveform instead of plain bar
|
|
||||||
|
|
||||||
5. **Chapter Markers**
|
|
||||||
- Visual markers for song sections
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: Seek doesn't work
|
|
||||||
**Check:**
|
|
||||||
- Is audio loaded? (readyState >= 2)
|
|
||||||
- Is audioRef.current null?
|
|
||||||
- Check console for errors
|
|
||||||
|
|
||||||
### Issue: Seek resets to 0
|
|
||||||
**Check:**
|
|
||||||
- Is `hasSeeked` flag working?
|
|
||||||
- Is useEffect dependency correct?
|
|
||||||
- Check console logs
|
|
||||||
|
|
||||||
### Issue: Audio doesn't resume
|
|
||||||
**Check:**
|
|
||||||
- Was audio playing before seek?
|
|
||||||
- Is play() called after seek?
|
|
||||||
- Check browser autoplay policy
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**Problem**: Progress bar seek tidak bekerja, audio reset ke 0:00
|
|
||||||
|
|
||||||
**Root Cause**:
|
|
||||||
1. Browser limitation (can't seek while playing)
|
|
||||||
2. useEffect overwrite
|
|
||||||
3. Audio source not ready
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Pause → Seek → Play pattern
|
|
||||||
2. hasSeeked flag
|
|
||||||
3. Retry with load()
|
|
||||||
4. isDragging state
|
|
||||||
|
|
||||||
**Result**: ✅ **SEEK WORKING PERFECTLY!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Updated**: February 27, 2026
|
|
||||||
**Status**: ✅ **RESOLVED**
|
|
||||||
**Files Modified**: 3
|
|
||||||
**Lines Changed**: ~100
|
|
||||||
**Testing**: ✅ All tests passing
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
# Update: Skip Back/Forward Functionality
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
Tombol **Skip Back** dan **Skip Forward** sebelumnya hanya berfungsi untuk mundur/maju 10 detik dalam lagu yang sama, bukan untuk pindah ke lagu sebelumnya/berikutnya.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
### Changes Made
|
|
||||||
|
|
||||||
#### 1. New Functions in `audio-player.ts`
|
|
||||||
|
|
||||||
**`skipToPreviousSong()`** - Pindah ke lagu sebelumnya
|
|
||||||
```typescript
|
|
||||||
export const skipToPreviousSong = (
|
|
||||||
currentSongIndex: number,
|
|
||||||
filteredMusikLength: number,
|
|
||||||
isShuffle: boolean,
|
|
||||||
setCurrentSongIndex: (index: number) => void,
|
|
||||||
setIsPlaying: (playing: boolean) => void
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Jika **shuffle mode OFF**: Pindah ke lagu sebelumnya secara sequential
|
|
||||||
- Jika di lagu pertama → loop ke lagu terakhir
|
|
||||||
- Jika **shuffle mode ON**: Random lagu lain (tidak sama dengan current)
|
|
||||||
- Auto play setelah pindah lagu
|
|
||||||
|
|
||||||
**`skipToNextSong()`** - Pindah ke lagu berikutnya
|
|
||||||
```typescript
|
|
||||||
export const skipToNextSong = (
|
|
||||||
currentSongIndex: number,
|
|
||||||
filteredMusikLength: number,
|
|
||||||
isShuffle: boolean,
|
|
||||||
setCurrentSongIndex: (index: number) => void,
|
|
||||||
setIsPlaying: (playing: boolean) => void
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Jika **shuffle mode OFF**: Pindah ke lagu berikutnya secara sequential
|
|
||||||
- Jika di lagu terakhir → loop ke lagu pertama
|
|
||||||
- Jika **shuffle mode ON**: Random lagu lain (tidak sama dengan current)
|
|
||||||
- Auto play setelah pindah lagu
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Updated `use-music-player.ts`
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```typescript
|
|
||||||
// Skip back 10 seconds
|
|
||||||
const skipBack = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = Math.max(
|
|
||||||
0,
|
|
||||||
audioRef.current.currentTime - 10
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip forward 10 seconds
|
|
||||||
const skipForward = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = Math.min(
|
|
||||||
duration,
|
|
||||||
audioRef.current.currentTime + 10
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```typescript
|
|
||||||
// Skip to previous song
|
|
||||||
const skipBack = () => {
|
|
||||||
skipToPreviousSong(
|
|
||||||
currentSongIndex,
|
|
||||||
filteredMusik.length,
|
|
||||||
isShuffle,
|
|
||||||
setCurrentSongIndex,
|
|
||||||
setIsPlaying
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip to next song
|
|
||||||
const skipForward = () => {
|
|
||||||
skipToNextSong(
|
|
||||||
currentSongIndex,
|
|
||||||
filteredMusik.length,
|
|
||||||
isShuffle,
|
|
||||||
setCurrentSongIndex,
|
|
||||||
setIsPlaying
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Updated `handleSongEnd()`
|
|
||||||
|
|
||||||
Sekarang menggunakan `skipToNextSong()` untuk konsistensi:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const handleSongEnd = () => {
|
|
||||||
if (isRepeat) {
|
|
||||||
// Repeat current song
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use skipToNextSong for consistency
|
|
||||||
skipToNextSong(
|
|
||||||
currentSongIndex,
|
|
||||||
filteredMusik.length,
|
|
||||||
isShuffle,
|
|
||||||
setCurrentSongIndex,
|
|
||||||
setIsPlaying
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Improved Song Change Detection
|
|
||||||
|
|
||||||
Updated useEffect untuk memastikan lagu benar-benar diputar saat berganti:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSong && audioRef.current) {
|
|
||||||
const durationParts = currentSong.durasi.split(':');
|
|
||||||
const durationInSeconds =
|
|
||||||
parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
|
||||||
setDuration(durationInSeconds);
|
|
||||||
setCurrentTime(0);
|
|
||||||
|
|
||||||
// Reset and play
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current.play().catch((err) => {
|
|
||||||
console.error('Error playing audio:', err);
|
|
||||||
setIsPlaying(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentSongIndex, currentSong, isPlaying]); // Added isPlaying to dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Behavior Matrix
|
|
||||||
|
|
||||||
### Skip Back (⏮️)
|
|
||||||
|
|
||||||
| Condition | Action |
|
|
||||||
|-----------|--------|
|
|
||||||
| Shuffle OFF, not at first song | Previous song (index - 1) |
|
|
||||||
| Shuffle OFF, at first song | Last song (loop) |
|
|
||||||
| Shuffle ON | Random song (≠ current) |
|
|
||||||
| Only 1 song | Stay on current song |
|
|
||||||
|
|
||||||
### Skip Forward (⏭️)
|
|
||||||
|
|
||||||
| Condition | Action |
|
|
||||||
|-----------|--------|
|
|
||||||
| Shuffle OFF, not at last song | Next song (index + 1) |
|
|
||||||
| Shuffle OFF, at last song | First song (loop) |
|
|
||||||
| Shuffle ON | Random song (≠ current) |
|
|
||||||
| Only 1 song | Stay on current song |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Experience
|
|
||||||
|
|
||||||
### Button Functions:
|
|
||||||
|
|
||||||
| Button | Icon | Function |
|
|
||||||
|--------|------|----------|
|
|
||||||
| **Skip Back** | ⏮️ | Previous song (with shuffle support) |
|
|
||||||
| **Play/Pause** | ▶️/⏸️ | Toggle play/pause |
|
|
||||||
| **Skip Forward** | ⏭️ | Next song (with shuffle support) |
|
|
||||||
|
|
||||||
### With Shuffle Mode:
|
|
||||||
|
|
||||||
- **Shuffle OFF** 🔁: Sequential playback (1 → 2 → 3 → 1...)
|
|
||||||
- **Shuffle ON** 🔀: Random playback (1 → 3 → 2 → 1...)
|
|
||||||
|
|
||||||
### With Repeat Mode:
|
|
||||||
|
|
||||||
- **Repeat OFF**: Auto-advance to next song when current ends
|
|
||||||
- **Repeat ON** 🔂: Replay current song when it ends
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Scenarios
|
|
||||||
|
|
||||||
### ✅ Test 1: Sequential Playback
|
|
||||||
1. Play song #1
|
|
||||||
2. Click ⏭️ → Should play song #2
|
|
||||||
3. Click ⏭️ → Should play song #3
|
|
||||||
4. Click ⏭️ (at last) → Should loop to song #1
|
|
||||||
|
|
||||||
### ✅ Test 2: Previous Song
|
|
||||||
1. Playing song #3
|
|
||||||
2. Click ⏮️ → Should play song #2
|
|
||||||
3. Click ⏮️ → Should play song #1
|
|
||||||
4. Click ⏮️ (at first) → Should loop to last song
|
|
||||||
|
|
||||||
### ✅ Test 3: Shuffle Mode
|
|
||||||
1. Enable shuffle 🔀
|
|
||||||
2. Playing song #1
|
|
||||||
3. Click ⏭️ → Should play random song (not #1)
|
|
||||||
4. Click ⏮️ → Should play different random song
|
|
||||||
|
|
||||||
### ✅ Test 4: Auto-Advance
|
|
||||||
1. Play any song
|
|
||||||
2. Wait until song ends
|
|
||||||
3. Should automatically play next song
|
|
||||||
|
|
||||||
### ✅ Test 5: Single Song
|
|
||||||
1. Filter search to show only 1 song
|
|
||||||
2. Click ⏭️ or ⏮️ → Should stay on same song
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
| File | Changes |
|
|
||||||
|------|---------|
|
|
||||||
| `audio-player.ts` | Added `skipToPreviousSong()`, `skipToNextSong()`; Removed old `skipBack()`, `skipForward()` |
|
|
||||||
| `use-music-player.ts` | Updated `skipBack`, `skipForward`, `handleSongEnd` to use new functions |
|
|
||||||
| `README.md` | Updated documentation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Considerations
|
|
||||||
|
|
||||||
**No API changes required!**
|
|
||||||
|
|
||||||
The functionality is purely client-side state management. The API endpoint `/api/desa/musik/find-many` already returns all necessary data:
|
|
||||||
- `id` - Unique identifier
|
|
||||||
- `judul` - Song title
|
|
||||||
- `artis` - Artist
|
|
||||||
- `durasi` - Duration (MM:SS)
|
|
||||||
- `audioFile.link` - Audio URL
|
|
||||||
- `coverImage.link` - Cover art URL
|
|
||||||
- `isActive` - Active status
|
|
||||||
|
|
||||||
State management handles the rest:
|
|
||||||
- `currentSongIndex` - Tracks which song is playing
|
|
||||||
- `filteredMusik` - Array of songs (after search filter)
|
|
||||||
- `isShuffle` - Shuffle mode toggle
|
|
||||||
- `isPlaying` - Play/pause state
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Browser Compatibility
|
|
||||||
|
|
||||||
✅ Chrome/Edge (Chromium)
|
|
||||||
✅ Firefox
|
|
||||||
✅ Safari
|
|
||||||
✅ Mobile browsers (iOS Safari, Chrome Mobile)
|
|
||||||
|
|
||||||
Uses standard HTML5 Audio API which is universally supported.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Notes
|
|
||||||
|
|
||||||
- **Instant response**: No API call needed for skip operations
|
|
||||||
- **Smooth transitions**: Songs load immediately from preloaded URLs
|
|
||||||
- **Memory efficient**: Only one audio element in DOM
|
|
||||||
- **State optimized**: Uses React state batching for smooth updates
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements (Optional)
|
|
||||||
|
|
||||||
1. **Transition Fade**: Crossfade between songs
|
|
||||||
2. **Preload Next**: Preload next song for instant playback
|
|
||||||
3. **History**: Track played songs for "go back" feature
|
|
||||||
4. **Queue**: Custom queue management
|
|
||||||
5. **Keyboard Shortcuts**: Arrow keys for skip controls
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Updated**: February 27, 2026
|
|
||||||
**Issue**: Skip buttons not working as expected
|
|
||||||
**Status**: ✅ Resolved
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { RefObject } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup audio progress interval
|
|
||||||
* Updates current time every second when playing
|
|
||||||
*/
|
|
||||||
export const setupProgressInterval = (
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
isPlaying: boolean,
|
|
||||||
setCurrentTime: (time: number) => void,
|
|
||||||
progressIntervalRef: RefObject<number | null>
|
|
||||||
) => {
|
|
||||||
if (isPlaying && audioRef.current) {
|
|
||||||
progressIntervalRef.current = window.setInterval(() => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
setCurrentTime(Math.floor(audioRef.current.currentTime));
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
if (progressIntervalRef.current) {
|
|
||||||
clearInterval(progressIntervalRef.current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
|
||||||
if (progressIntervalRef.current) {
|
|
||||||
clearInterval(progressIntervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear progress interval
|
|
||||||
*/
|
|
||||||
export const clearProgressInterval = (
|
|
||||||
progressIntervalRef: RefObject<number | null>
|
|
||||||
) => {
|
|
||||||
if (progressIntervalRef.current) {
|
|
||||||
clearInterval(progressIntervalRef.current);
|
|
||||||
progressIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle audio metadata loaded
|
|
||||||
* Sets duration from actual audio file
|
|
||||||
*/
|
|
||||||
export const handleAudioMetadataLoaded = (
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
setDuration: (duration: number) => void
|
|
||||||
) => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
setDuration(Math.floor(audioRef.current.duration));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle audio error
|
|
||||||
*/
|
|
||||||
export const handleAudioError = (
|
|
||||||
error: Event,
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
setIsPlaying: (playing: boolean) => void
|
|
||||||
) => {
|
|
||||||
console.error('Audio error:', error);
|
|
||||||
setIsPlaying(false);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preload audio
|
|
||||||
* Can be used to preload next song
|
|
||||||
*/
|
|
||||||
export const preloadAudio = (
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
src: string
|
|
||||||
) => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.src = src;
|
|
||||||
audioRef.current.load();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop audio and reset state
|
|
||||||
*/
|
|
||||||
export const stopAudio = (
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
setIsPlaying: (playing: boolean) => void,
|
|
||||||
setCurrentTime: (time: number) => void
|
|
||||||
) => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
}
|
|
||||||
setIsPlaying(false);
|
|
||||||
setCurrentTime(0);
|
|
||||||
};
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import { RefObject } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle play/pause audio
|
|
||||||
*/
|
|
||||||
export const togglePlayPause = (
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
isPlaying: boolean,
|
|
||||||
setIsPlaying: (playing: boolean) => void
|
|
||||||
) => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
setIsPlaying(false);
|
|
||||||
} else {
|
|
||||||
audioRef.current.play().catch((err) => {
|
|
||||||
console.error('Error playing audio:', err);
|
|
||||||
});
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Skip to previous song in playlist
|
|
||||||
* If at beginning and more than 1 song, go to last song
|
|
||||||
*/
|
|
||||||
export const skipToPreviousSong = (
|
|
||||||
currentSongIndex: number,
|
|
||||||
filteredMusikLength: number,
|
|
||||||
isShuffle: boolean,
|
|
||||||
setCurrentSongIndex: (index: number) => void,
|
|
||||||
setIsPlaying: (playing: boolean) => void
|
|
||||||
) => {
|
|
||||||
if (filteredMusikLength === 0) return;
|
|
||||||
|
|
||||||
let prevIndex: number;
|
|
||||||
if (isShuffle) {
|
|
||||||
// Random index different from current
|
|
||||||
do {
|
|
||||||
prevIndex = Math.floor(Math.random() * filteredMusikLength);
|
|
||||||
} while (prevIndex === currentSongIndex && filteredMusikLength > 1);
|
|
||||||
} else {
|
|
||||||
// Sequential (go to previous or last if at beginning)
|
|
||||||
prevIndex = currentSongIndex === 0 ? filteredMusikLength - 1 : currentSongIndex - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentSongIndex(prevIndex);
|
|
||||||
setIsPlaying(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Skip to next song in playlist
|
|
||||||
*/
|
|
||||||
export const skipToNextSong = (
|
|
||||||
currentSongIndex: number,
|
|
||||||
filteredMusikLength: number,
|
|
||||||
isShuffle: boolean,
|
|
||||||
setCurrentSongIndex: (index: number) => void,
|
|
||||||
setIsPlaying: (playing: boolean) => void
|
|
||||||
) => {
|
|
||||||
if (filteredMusikLength === 0) return;
|
|
||||||
|
|
||||||
let nextIndex: number;
|
|
||||||
if (isShuffle) {
|
|
||||||
// Random index different from current
|
|
||||||
do {
|
|
||||||
nextIndex = Math.floor(Math.random() * filteredMusikLength);
|
|
||||||
} while (nextIndex === currentSongIndex && filteredMusikLength > 1);
|
|
||||||
} else {
|
|
||||||
// Sequential (loop back to first if at end)
|
|
||||||
nextIndex = (currentSongIndex + 1) % filteredMusikLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentSongIndex(nextIndex);
|
|
||||||
setIsPlaying(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle mute/unmute
|
|
||||||
*/
|
|
||||||
export const toggleMute = (
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
isMuted: boolean,
|
|
||||||
setIsMuted: (muted: boolean) => void
|
|
||||||
) => {
|
|
||||||
const newMuted = !isMuted;
|
|
||||||
setIsMuted(newMuted);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.muted = newMuted;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle volume change
|
|
||||||
*/
|
|
||||||
export const handleVolumeChange = (
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
volume: number,
|
|
||||||
setVolume: (vol: number) => void,
|
|
||||||
isMuted: boolean,
|
|
||||||
setIsMuted: (muted: boolean) => void
|
|
||||||
) => {
|
|
||||||
setVolume(volume);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.volume = volume / 100;
|
|
||||||
}
|
|
||||||
// Unmute if volume is increased from 0
|
|
||||||
if (volume > 0 && isMuted) {
|
|
||||||
setIsMuted(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle seek/scrub through audio
|
|
||||||
*/
|
|
||||||
export const handleSeek = (
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
value: number,
|
|
||||||
setCurrentTime: (time: number) => void
|
|
||||||
) => {
|
|
||||||
setCurrentTime(value);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format seconds to MM:SS format
|
|
||||||
*/
|
|
||||||
export const formatTime = (seconds: number): string => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse duration string (MM:SS) to seconds
|
|
||||||
*/
|
|
||||||
export const parseDuration = (durationString: string): number => {
|
|
||||||
const parts = durationString.split(':');
|
|
||||||
return parseInt(parts[0]) * 60 + parseInt(parts[1]);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play a specific song from playlist
|
|
||||||
*/
|
|
||||||
export const playSong = (
|
|
||||||
index: number,
|
|
||||||
filteredMusikLength: number,
|
|
||||||
setCurrentSongIndex: (index: number) => void,
|
|
||||||
setIsPlaying: (playing: boolean) => void
|
|
||||||
) => {
|
|
||||||
if (index < 0 || index >= filteredMusikLength) return;
|
|
||||||
setCurrentSongIndex(index);
|
|
||||||
setIsPlaying(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle song end - play next song or repeat
|
|
||||||
*/
|
|
||||||
export const handleSongEnd = (
|
|
||||||
isRepeat: boolean,
|
|
||||||
isShuffle: boolean,
|
|
||||||
currentSongIndex: number,
|
|
||||||
filteredMusikLength: number,
|
|
||||||
audioRef: RefObject<HTMLAudioElement | null>,
|
|
||||||
setCurrentSongIndex: (index: number) => void,
|
|
||||||
setIsPlaying: (playing: boolean) => void,
|
|
||||||
setCurrentTime: (time: number) => void
|
|
||||||
) => {
|
|
||||||
if (isRepeat) {
|
|
||||||
// Repeat current song
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Play next song
|
|
||||||
let nextIndex: number;
|
|
||||||
if (isShuffle) {
|
|
||||||
nextIndex = Math.floor(Math.random() * filteredMusikLength);
|
|
||||||
} else {
|
|
||||||
nextIndex = (currentSongIndex + 1) % filteredMusikLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredMusikLength > 1) {
|
|
||||||
setCurrentSongIndex(nextIndex);
|
|
||||||
setIsPlaying(true);
|
|
||||||
} else {
|
|
||||||
// Only one song and not repeating
|
|
||||||
setIsPlaying(false);
|
|
||||||
setCurrentTime(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle repeat mode
|
|
||||||
*/
|
|
||||||
export const toggleRepeat = (
|
|
||||||
isRepeat: boolean,
|
|
||||||
setIsRepeat: (repeat: boolean) => void
|
|
||||||
) => {
|
|
||||||
setIsRepeat(!isRepeat);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle shuffle mode
|
|
||||||
*/
|
|
||||||
export const toggleShuffle = (
|
|
||||||
isShuffle: boolean,
|
|
||||||
setIsShuffle: (shuffle: boolean) => void
|
|
||||||
) => {
|
|
||||||
setIsShuffle(!isShuffle);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get next song index based on shuffle mode
|
|
||||||
*/
|
|
||||||
export const getNextSongIndex = (
|
|
||||||
currentSongIndex: number,
|
|
||||||
filteredMusikLength: number,
|
|
||||||
isShuffle: boolean
|
|
||||||
): number => {
|
|
||||||
if (isShuffle) {
|
|
||||||
// Random index different from current
|
|
||||||
let nextIndex;
|
|
||||||
do {
|
|
||||||
nextIndex = Math.floor(Math.random() * filteredMusikLength);
|
|
||||||
} while (nextIndex === currentSongIndex && filteredMusikLength > 1);
|
|
||||||
return nextIndex;
|
|
||||||
} else {
|
|
||||||
// Sequential
|
|
||||||
return (currentSongIndex + 1) % filteredMusikLength;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get previous song index
|
|
||||||
*/
|
|
||||||
export const getPreviousSongIndex = (
|
|
||||||
currentSongIndex: number,
|
|
||||||
filteredMusikLength: number,
|
|
||||||
isShuffle: boolean
|
|
||||||
): number => {
|
|
||||||
if (isShuffle) {
|
|
||||||
// Random index different from current
|
|
||||||
let prevIndex;
|
|
||||||
do {
|
|
||||||
prevIndex = Math.floor(Math.random() * filteredMusikLength);
|
|
||||||
} while (prevIndex === currentSongIndex && filteredMusikLength > 1);
|
|
||||||
return prevIndex;
|
|
||||||
} else {
|
|
||||||
// Sequential (go to previous or last if at beginning)
|
|
||||||
return currentSongIndex === 0 ? filteredMusikLength - 1 : currentSongIndex - 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
32
src/app/darmasaba/(pages)/musik/lib/nextPrev.ts
Normal file
32
src/app/darmasaba/(pages)/musik/lib/nextPrev.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export function getNextIndex(
|
||||||
|
currentIndex: number,
|
||||||
|
total: number,
|
||||||
|
isShuffle: boolean
|
||||||
|
) {
|
||||||
|
if (total === 0) return -1;
|
||||||
|
|
||||||
|
if (isShuffle) {
|
||||||
|
return Math.floor(Math.random() * total);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (currentIndex + 1) % total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrevIndex(
|
||||||
|
currentIndex: number,
|
||||||
|
total: number,
|
||||||
|
isShuffle: boolean
|
||||||
|
) {
|
||||||
|
if (total === 0) return -1;
|
||||||
|
|
||||||
|
if (isShuffle) {
|
||||||
|
return Math.floor(Math.random() * total);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentIndex - 1 < 0 ? total - 1 : currentIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
//pakai di ui
|
||||||
|
|
||||||
|
// const next = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||||
|
// playSong(next);
|
||||||
24
src/app/darmasaba/(pages)/musik/lib/playPause.ts
Normal file
24
src/app/darmasaba/(pages)/musik/lib/playPause.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { RefObject } from "react";
|
||||||
|
|
||||||
|
export function togglePlayPause(
|
||||||
|
audioRef: RefObject<HTMLAudioElement | null>,
|
||||||
|
isPlaying: boolean,
|
||||||
|
setIsPlaying: (v: boolean) => void
|
||||||
|
) {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
} else {
|
||||||
|
audioRef.current
|
||||||
|
.play()
|
||||||
|
.then(() => setIsPlaying(true))
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pakai di ui
|
||||||
|
// onClick={() =>
|
||||||
|
// togglePlayPause(audioRef, isPlaying, setIsPlaying)
|
||||||
|
// }
|
||||||
22
src/app/darmasaba/(pages)/musik/lib/repeat.ts
Normal file
22
src/app/darmasaba/(pages)/musik/lib/repeat.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { RefObject } from "react";
|
||||||
|
|
||||||
|
export function handleRepeatOrNext(
|
||||||
|
audioRef: RefObject<HTMLAudioElement | null>,
|
||||||
|
isRepeat: boolean,
|
||||||
|
playNext: () => void
|
||||||
|
) {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
if (isRepeat) {
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
audioRef.current.play();
|
||||||
|
} else {
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//dipakai di ui
|
||||||
|
|
||||||
|
// onEnded={() =>
|
||||||
|
// handleRepeatOrNext(audioRef, isRepeat, playNext)
|
||||||
|
// }
|
||||||
19
src/app/darmasaba/(pages)/musik/lib/seek.ts
Normal file
19
src/app/darmasaba/(pages)/musik/lib/seek.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export function seekTo(
|
||||||
|
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 = safeTime;
|
||||||
|
|
||||||
|
// Update state jika provided
|
||||||
|
if (setCurrentTime) {
|
||||||
|
setCurrentTime(Math.floor(safeTime));
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/app/darmasaba/(pages)/musik/lib/shuffle.ts
Normal file
6
src/app/darmasaba/(pages)/musik/lib/shuffle.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function toggleShuffle(
|
||||||
|
isShuffle: boolean,
|
||||||
|
setIsShuffle: (v: boolean) => void
|
||||||
|
) {
|
||||||
|
setIsShuffle(!isShuffle);
|
||||||
|
}
|
||||||
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,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
29
src/app/darmasaba/(pages)/musik/lib/volume.ts
Normal file
29
src/app/darmasaba/(pages)/musik/lib/volume.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { RefObject } from "react";
|
||||||
|
|
||||||
|
export function setAudioVolume(
|
||||||
|
audioRef: RefObject<HTMLAudioElement | null>,
|
||||||
|
volume: number,
|
||||||
|
setVolume: (v: number) => void,
|
||||||
|
setIsMuted: (v: boolean) => void
|
||||||
|
) {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
audioRef.current.volume = volume / 100;
|
||||||
|
setVolume(volume);
|
||||||
|
|
||||||
|
if (volume > 0) {
|
||||||
|
setIsMuted(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleMute(
|
||||||
|
audioRef: RefObject<HTMLAudioElement | null>,
|
||||||
|
isMuted: boolean,
|
||||||
|
setIsMuted: (v: boolean) => void
|
||||||
|
) {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
const muted = !isMuted;
|
||||||
|
audioRef.current.muted = muted;
|
||||||
|
setIsMuted(muted);
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
'use client'
|
'use client'
|
||||||
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, ScrollArea, Slider, Stack, Text, TextInput } from '@mantine/core';
|
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, ScrollArea, Slider, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
|
import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import BackButton from '../../desa/layanan/_com/BackButto';
|
import BackButton from '../../desa/layanan/_com/BackButto';
|
||||||
import { formatTime } from '../lib/audio-player';
|
import { togglePlayPause } from '../lib/playPause';
|
||||||
|
import { getNextIndex, getPrevIndex } from '../lib/nextPrev';
|
||||||
|
import { handleRepeatOrNext } from '../lib/repeat';
|
||||||
|
import { toggleShuffle } from '../lib/shuffle';
|
||||||
|
import { setAudioVolume, toggleMute as toggleMuteUtil } from '../lib/volume';
|
||||||
|
import { useAudioProgress } from '../lib/useAudioProgress';
|
||||||
|
|
||||||
interface MusicFile {
|
interface MusicFile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,7 +19,7 @@ interface MusicFile {
|
|||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Musik {
|
interface Musik {
|
||||||
id: string;
|
id: string;
|
||||||
judul: string;
|
judul: string;
|
||||||
artis: string;
|
artis: string;
|
||||||
@@ -29,12 +33,6 @@ export interface Musik {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MusicPlayer = () => {
|
const MusicPlayer = () => {
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [musikData, setMusikData] = useState<Musik[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Player state
|
|
||||||
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
@@ -42,9 +40,17 @@ const MusicPlayer = () => {
|
|||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isRepeat, setIsRepeat] = useState(false);
|
const [isRepeat, setIsRepeat] = useState(false);
|
||||||
const [isShuffle, setIsShuffle] = useState(false);
|
const [isShuffle, setIsShuffle] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [musikData, setMusikData] = useState<Musik[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const progressIntervalRef = useRef<number | 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
|
// Fetch musik data from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,120 +73,105 @@ const MusicPlayer = () => {
|
|||||||
fetchMusik();
|
fetchMusik();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Filter musik based on search
|
// Filter musik based on search - gunakan useMemo untuk mencegah re-calculate setiap render
|
||||||
const filteredMusik = musikData.filter(musik =>
|
const filteredMusik = useMemo(() => {
|
||||||
musik.judul.toLowerCase().includes(search.toLowerCase()) ||
|
return musikData.filter(musik =>
|
||||||
musik.artis.toLowerCase().includes(search.toLowerCase()) ||
|
musik.judul.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
(musik.genre && musik.genre.toLowerCase().includes(search.toLowerCase()))
|
musik.artis.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
);
|
(musik.genre && musik.genre.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
);
|
||||||
|
}, [musikData, search]);
|
||||||
|
|
||||||
const currentSong = currentSongIndex >= 0 && currentSongIndex < filteredMusik.length
|
const currentSong = currentSongIndex >= 0 && currentSongIndex < filteredMusik.length
|
||||||
? filteredMusik[currentSongIndex]
|
? filteredMusik[currentSongIndex]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Setup progress interval
|
// // Update progress bar
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
if (isPlaying && audioRef.current) {
|
// if (isPlaying && audioRef.current) {
|
||||||
progressIntervalRef.current = window.setInterval(() => {
|
// progressInterval.current = window.setInterval(() => {
|
||||||
if (audioRef.current) {
|
// if (audioRef.current) {
|
||||||
setCurrentTime(Math.floor(audioRef.current.currentTime));
|
// setCurrentTime(Math.floor(audioRef.current.currentTime));
|
||||||
}
|
// }
|
||||||
}, 1000);
|
// }, 1000);
|
||||||
} else {
|
// } else {
|
||||||
if (progressIntervalRef.current) {
|
// if (progressInterval.current) {
|
||||||
clearInterval(progressIntervalRef.current);
|
// clearInterval(progressInterval.current);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
return () => {
|
// return () => {
|
||||||
if (progressIntervalRef.current) {
|
// if (progressInterval.current) {
|
||||||
clearInterval(progressIntervalRef.current);
|
// clearInterval(progressInterval.current);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
}, [isPlaying]);
|
// }, [isPlaying]);
|
||||||
|
|
||||||
// Update duration and play when song changes
|
// Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentSong && audioRef.current) {
|
if (currentSong && audioRef.current) {
|
||||||
const durationParts = currentSong.durasi.split(':');
|
// Cek apakah ini benar-benar lagu baru
|
||||||
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
const isNewSong = lastPlayedSongIdRef.current !== currentSong.id;
|
||||||
setDuration(durationInSeconds);
|
|
||||||
setCurrentTime(0);
|
if (isNewSong) {
|
||||||
audioRef.current.currentTime = 0;
|
// Gunakan durasi dari database sebagai acuan utama
|
||||||
|
const durationParts = currentSong.durasi.split(':');
|
||||||
if (isPlaying) {
|
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
||||||
audioRef.current.play().catch((err) => {
|
setDuration(durationInSeconds);
|
||||||
console.error('Error playing audio:', err);
|
|
||||||
setIsPlaying(false);
|
// Reset audio currentTime ke 0 untuk lagu baru
|
||||||
});
|
audioRef.current.currentTime = 0;
|
||||||
}
|
setCurrentTime(0);
|
||||||
}
|
|
||||||
}, [currentSongIndex, currentSong]);
|
// 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]); // 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);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Play specific song
|
|
||||||
const playSong = (index: number) => {
|
const playSong = (index: number) => {
|
||||||
if (index < 0 || index >= filteredMusik.length) return;
|
if (index < 0 || index >= filteredMusik.length) return;
|
||||||
|
|
||||||
setCurrentSongIndex(index);
|
setCurrentSongIndex(index);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Skip to previous song
|
const handleSongEnd = () => {
|
||||||
const skipBack = () => {
|
const playNext = () => {
|
||||||
if (filteredMusik.length === 0) return;
|
|
||||||
|
|
||||||
let prevIndex: number;
|
|
||||||
if (isShuffle) {
|
|
||||||
do {
|
|
||||||
prevIndex = Math.floor(Math.random() * filteredMusik.length);
|
|
||||||
} while (prevIndex === currentSongIndex && filteredMusik.length > 1);
|
|
||||||
} else {
|
|
||||||
prevIndex = currentSongIndex === 0 ? filteredMusik.length - 1 : currentSongIndex - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentSongIndex(prevIndex);
|
|
||||||
setIsPlaying(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip to next song
|
|
||||||
const skipForward = () => {
|
|
||||||
if (filteredMusik.length === 0) return;
|
|
||||||
|
|
||||||
let nextIndex: number;
|
|
||||||
if (isShuffle) {
|
|
||||||
do {
|
|
||||||
nextIndex = Math.floor(Math.random() * filteredMusik.length);
|
|
||||||
} while (nextIndex === currentSongIndex && filteredMusik.length > 1);
|
|
||||||
} else {
|
|
||||||
nextIndex = (currentSongIndex + 1) % filteredMusik.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentSongIndex(nextIndex);
|
|
||||||
setIsPlaying(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle play/pause
|
|
||||||
const togglePlayPause = () => {
|
|
||||||
if (!currentSong) return;
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle seek
|
|
||||||
const handleSeek = (value: number) => {
|
|
||||||
setCurrentTime(value);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle song ended
|
|
||||||
const handleSongEnded = () => {
|
|
||||||
if (isRepeat) {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Play next song
|
|
||||||
let nextIndex: number;
|
let nextIndex: number;
|
||||||
if (isShuffle) {
|
if (isShuffle) {
|
||||||
nextIndex = Math.floor(Math.random() * filteredMusik.length);
|
nextIndex = Math.floor(Math.random() * filteredMusik.length);
|
||||||
@@ -189,35 +180,47 @@ const MusicPlayer = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filteredMusik.length > 1) {
|
if (filteredMusik.length > 1) {
|
||||||
setCurrentSongIndex(nextIndex);
|
playSong(nextIndex);
|
||||||
setIsPlaying(true);
|
|
||||||
} else {
|
} else {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
handleRepeatOrNext(audioRef, isRepeat, playNext);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle volume
|
|
||||||
const handleVolumeChange = (val: number) => {
|
|
||||||
setVolume(val);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.volume = val / 100;
|
|
||||||
}
|
|
||||||
if (val > 0 && isMuted) {
|
|
||||||
setIsMuted(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle mute
|
|
||||||
const toggleMute = () => {
|
const toggleMute = () => {
|
||||||
const newMuted = !isMuted;
|
toggleMuteUtil(audioRef, isMuted, setIsMuted);
|
||||||
setIsMuted(newMuted);
|
};
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.muted = newMuted;
|
const handleVolumeChange = (val: number) => {
|
||||||
|
setAudioVolume(audioRef, val, setVolume, setIsMuted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipBack = () => {
|
||||||
|
const prevIndex = getPrevIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||||
|
if (prevIndex >= 0) {
|
||||||
|
playSong(prevIndex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const skipForward = () => {
|
||||||
|
const nextIndex = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||||
|
if (nextIndex >= 0) {
|
||||||
|
playSong(nextIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleShuffleHandler = () => {
|
||||||
|
toggleShuffle(isShuffle, setIsShuffle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlayPauseHandler = () => {
|
||||||
|
if (!currentSong) return;
|
||||||
|
togglePlayPause(audioRef, isPlaying, setIsPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
<Box px={{ base: 'md', md: 100 }} py="xl">
|
||||||
@@ -230,18 +233,13 @@ const MusicPlayer = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
<Box px={{ base: 'md', md: 100 }} py="xl">
|
||||||
{/* Hidden audio element */}
|
{/* Hidden audio element - gunakan key yang stabil untuk mencegah remount */}
|
||||||
{currentSong?.audioFile && (
|
{currentSong?.audioFile && (
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src={currentSong.audioFile.link}
|
src={currentSong?.audioFile?.link}
|
||||||
onEnded={handleSongEnded}
|
|
||||||
onLoadedMetadata={() => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
setDuration(Math.floor(audioRef.current.duration));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
muted={isMuted}
|
muted={isMuted}
|
||||||
|
onEnded={handleSongEnd}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -297,8 +295,32 @@ const MusicPlayer = () => {
|
|||||||
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
|
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
|
||||||
<Slider
|
<Slider
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
max={duration || 1}
|
max={duration}
|
||||||
onChange={(value) => handleSeek(value)}
|
onChange={(v) => {
|
||||||
|
isSeekingRef.current = true;
|
||||||
|
setCurrentTime(v);
|
||||||
|
}}
|
||||||
|
onChangeEnd={(v) => {
|
||||||
|
// Validasi: jangan seek melebihi durasi
|
||||||
|
const seekTime = Math.min(Math.max(0, v), duration);
|
||||||
|
|
||||||
|
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"
|
color="#0B4F78"
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
@@ -378,7 +400,7 @@ const MusicPlayer = () => {
|
|||||||
<Flex align="center" justify="space-between" gap="xl" h="100%">
|
<Flex align="center" justify="space-between" gap="xl" h="100%">
|
||||||
<Group gap="md" style={{ flex: 1 }}>
|
<Group gap="md" style={{ flex: 1 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentSong?.coverImage?.link || '/mp3-logo.png'}
|
src={currentSong?.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||||
size={56}
|
size={56}
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
@@ -399,13 +421,12 @@ const MusicPlayer = () => {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isShuffle ? 'filled' : 'subtle'}
|
variant={isShuffle ? 'filled' : 'subtle'}
|
||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
onClick={() => setIsShuffle(!isShuffle)}
|
onClick={toggleShuffleHandler}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
title={isShuffle ? 'Matikan acak' : 'Acak lagu'}
|
|
||||||
>
|
>
|
||||||
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
|
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipBack} title="Lagu sebelumnya">
|
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipBack}>
|
||||||
<IconPlayerSkipBackFilled size={20} />
|
<IconPlayerSkipBackFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -413,12 +434,11 @@ const MusicPlayer = () => {
|
|||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
size={56}
|
size={56}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPauseHandler}
|
||||||
title={isPlaying ? 'Jeda' : 'Putar'}
|
|
||||||
>
|
>
|
||||||
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
|
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipForward} title="Lagu berikutnya">
|
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipForward}>
|
||||||
<IconPlayerSkipForwardFilled size={20} />
|
<IconPlayerSkipForwardFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -426,7 +446,6 @@ const MusicPlayer = () => {
|
|||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
onClick={() => setIsRepeat(!isRepeat)}
|
onClick={() => setIsRepeat(!isRepeat)}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
title={isRepeat ? 'Matikan ulang' : 'Ulangi lagu'}
|
|
||||||
>
|
>
|
||||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -435,8 +454,32 @@ const MusicPlayer = () => {
|
|||||||
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
|
<Text size="xs" c="#5A6C7D" w={40} ta="right">{formatTime(currentTime)}</Text>
|
||||||
<Slider
|
<Slider
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
max={duration || 1}
|
max={duration}
|
||||||
onChange={(value) => handleSeek(value)}
|
onChange={(v) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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"
|
color="#0B4F78"
|
||||||
size="xs"
|
size="xs"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
@@ -446,12 +489,7 @@ const MusicPlayer = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
|
<Group gap="xs" style={{ flex: 1 }} justify="flex-end">
|
||||||
<ActionIcon
|
<ActionIcon variant="subtle" color="gray" onClick={toggleMute}>
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={toggleMute}
|
|
||||||
title={isMuted ? 'Hidupkan suara' : 'Bisukan suara'}
|
|
||||||
>
|
|
||||||
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
{isMuted || volume === 0 ? <IconVolumeOff size={20} /> : <IconVolume size={20} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -460,7 +498,6 @@ const MusicPlayer = () => {
|
|||||||
color="#0B4F78"
|
color="#0B4F78"
|
||||||
size="xs"
|
size="xs"
|
||||||
w={100}
|
w={100}
|
||||||
aria-label="Volume control"
|
|
||||||
/>
|
/>
|
||||||
<Text size="xs" c="#5A6C7D" w={32}>{isMuted ? 0 : volume}%</Text>
|
<Text size="xs" c="#5A6C7D" w={32}>{isMuted ? 0 : volume}%</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -471,3 +508,85 @@ 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>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
@@ -2,12 +2,9 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
'use client'
|
'use client'
|
||||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
|
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
|
||||||
import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
|
|
||||||
import { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types'
|
|
||||||
import colors from '@/con/colors'
|
import colors from '@/con/colors'
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
BackgroundImage,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
@@ -23,6 +20,9 @@ import { IconDownload } from '@tabler/icons-react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useProxy } from 'valtio/utils'
|
import { useProxy } from 'valtio/utils'
|
||||||
|
import PaguTable from './lib/paguTable'
|
||||||
|
import RealisasiTable from './lib/realisasiTable'
|
||||||
|
import GrafikRealisasi from './lib/grafikRealisasi'
|
||||||
|
|
||||||
function Apbdes() {
|
function Apbdes() {
|
||||||
const state = useProxy(apbdes)
|
const state = useProxy(apbdes)
|
||||||
@@ -51,18 +51,17 @@ function Apbdes() {
|
|||||||
const dataAPBDes = state.findMany.data || []
|
const dataAPBDes = state.findMany.data || []
|
||||||
|
|
||||||
const years = Array.from(
|
const years = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
dataAPBDes
|
dataAPBDes
|
||||||
.map((item: any) => item?.tahun)
|
.map((item: any) => item?.tahun)
|
||||||
.filter((tahun): tahun is number => typeof tahun === 'number')
|
.filter((tahun): tahun is number => typeof tahun === 'number')
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.sort((a, b) => b - a)
|
||||||
.sort((a, b) => b - a)
|
.map(year => ({
|
||||||
.map(year => ({
|
value: year.toString(),
|
||||||
value: year.toString(),
|
label: `Tahun ${year}`,
|
||||||
label: `Tahun ${year}`,
|
}))
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (years.length > 0 && !selectedYear) {
|
if (years.length > 0 && !selectedYear) {
|
||||||
@@ -71,7 +70,7 @@ function Apbdes() {
|
|||||||
}, [years, selectedYear])
|
}, [years, selectedYear])
|
||||||
|
|
||||||
const currentApbdes = dataAPBDes.length > 0
|
const currentApbdes = dataAPBDes.length > 0
|
||||||
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
|
? dataAPBDes.find((item: any) => item?.tahun?.toString() === selectedYear) || dataAPBDes[0]
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const data = (state.findMany.data || []).slice(0, 3)
|
const data = (state.findMany.data || []).slice(0, 3)
|
||||||
@@ -131,18 +130,24 @@ function Apbdes() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Tabel & Grafik - Hanya tampilkan jika ada data */}
|
||||||
{currentApbdes ? (
|
{currentApbdes && currentApbdes.items?.length > 0 ? (
|
||||||
<APBDesProgress apbdesData={currentApbdes} />
|
<Box px={{ base: 'md', md: 100 }}>
|
||||||
) : (
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<PaguTable apbdesData={currentApbdes} />
|
||||||
|
<RealisasiTable apbdesData={currentApbdes} />
|
||||||
|
<GrafikRealisasi apbdesData={currentApbdes} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
) : currentApbdes ? (
|
||||||
<Box px={{ base: 'md', md: 100 }} py="md">
|
<Box px={{ base: 'md', md: 100 }} py="md">
|
||||||
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
|
<Text fz="sm" c="dimmed" ta="center" lh={1.5}>
|
||||||
Tidak ada data APBDes untuk tahun yang dipilih.
|
Tidak ada data item untuk tahun yang dipilih.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* GRID */}
|
{/* GRID - Card Preview */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
<Center mx={{ base: 'md', md: 100 }} mih={200} pb="xl">
|
||||||
<Loader size="lg" color="blue" />
|
<Loader size="lg" color="blue" />
|
||||||
@@ -165,14 +170,18 @@ function Apbdes() {
|
|||||||
spacing="lg"
|
spacing="lg"
|
||||||
pb="xl"
|
pb="xl"
|
||||||
>
|
>
|
||||||
{data.map((v, k) => (
|
{data.map((v: any, k: number) => (
|
||||||
<BackgroundImage
|
<Box
|
||||||
key={k}
|
key={k}
|
||||||
src={v.image?.link || ''}
|
|
||||||
h={360}
|
|
||||||
radius="xl"
|
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{ overflow: 'hidden' }}
|
style={{
|
||||||
|
backgroundImage: `url(${v.image?.link || ''})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
borderRadius: 16,
|
||||||
|
height: 360,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
|
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
|
||||||
|
|
||||||
@@ -185,7 +194,7 @@ function Apbdes() {
|
|||||||
lh={1.35}
|
lh={1.35}
|
||||||
lineClamp={2}
|
lineClamp={2}
|
||||||
>
|
>
|
||||||
{v.name}
|
{v.name || `APBDes Tahun ${v.tahun}`}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@@ -196,7 +205,7 @@ function Apbdes() {
|
|||||||
lh={1}
|
lh={1}
|
||||||
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
|
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
|
||||||
>
|
>
|
||||||
{v.jumlah}
|
{v.jumlah || '-'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Center>
|
<Center>
|
||||||
@@ -212,7 +221,7 @@ function Apbdes() {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Center>
|
</Center>
|
||||||
</Stack>
|
</Stack>
|
||||||
</BackgroundImage>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
@@ -220,4 +229,4 @@ function Apbdes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Apbdes
|
export default Apbdes
|
||||||
134
src/app/darmasaba/_com/main-page/apbdes/lib/grafikRealisasi.tsx
Normal file
134
src/app/darmasaba/_com/main-page/apbdes/lib/grafikRealisasi.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
|
||||||
|
|
||||||
|
function Summary({ title, data }: any) {
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0);
|
||||||
|
const totalRealisasi = data.reduce((s: number, i: any) => s + i.realisasi, 0);
|
||||||
|
|
||||||
|
const persen =
|
||||||
|
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||||
|
|
||||||
|
// Format angka ke dalam format Rupiah
|
||||||
|
const formatRupiah = (angka: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(angka);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tentukan warna berdasarkan persentase
|
||||||
|
const getProgressColor = (persen: number) => {
|
||||||
|
if (persen >= 100) return 'teal';
|
||||||
|
if (persen >= 80) return 'blue';
|
||||||
|
if (persen >= 60) return 'yellow';
|
||||||
|
return 'red';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text fw={600} fz="md">{title}</Text>
|
||||||
|
<Text fw={700} fz="lg" c={getProgressColor(persen)}>
|
||||||
|
{persen.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text fz="sm" c="dimmed" mb="xs">
|
||||||
|
Realisasi: {formatRupiah(totalRealisasi)} / Anggaran: {formatRupiah(totalAnggaran)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={persen}
|
||||||
|
size="xl"
|
||||||
|
radius="xl"
|
||||||
|
color={getProgressColor(persen)}
|
||||||
|
striped={persen < 100}
|
||||||
|
animated={persen < 100}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{persen >= 100 && (
|
||||||
|
<Text fz="xs" c="teal" mt="xs" fw={500}>
|
||||||
|
✓ Realisasi mencapai 100% dari anggaran
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{persen < 100 && persen >= 80 && (
|
||||||
|
<Text fz="xs" c="blue" mt="xs" fw={500}>
|
||||||
|
⚡ Realisasi baik, mendekati target
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{persen < 80 && persen >= 60 && (
|
||||||
|
<Text fz="xs" c="yellow" mt="xs" fw={500}>
|
||||||
|
⚠️ Realisasi cukup, perlu ditingkatkan
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{persen < 60 && (
|
||||||
|
<Text fz="xs" c="red" mt="xs" fw={500}>
|
||||||
|
⚠️ Realisasi rendah, perlu perhatian khusus
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GrafikRealisasi({ apbdesData }: any) {
|
||||||
|
const items = apbdesData.items || [];
|
||||||
|
const tahun = apbdesData.tahun || new Date().getFullYear();
|
||||||
|
|
||||||
|
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
|
||||||
|
const belanja = items.filter((i: any) => i.tipe === 'belanja');
|
||||||
|
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
|
||||||
|
|
||||||
|
// Hitung total keseluruhan
|
||||||
|
const totalAnggaranSemua = items.reduce((s: number, i: any) => s + i.anggaran, 0);
|
||||||
|
const totalRealisasiSemua = items.reduce((s: number, i: any) => s + i.realisasi, 0);
|
||||||
|
const persenSemua = totalAnggaranSemua > 0 ? (totalRealisasiSemua / totalAnggaranSemua) * 100 : 0;
|
||||||
|
|
||||||
|
const formatRupiah = (angka: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(angka);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Title order={5} mb="md">
|
||||||
|
GRAFIK REALISASI APBDes {tahun}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Summary title="💰 Pendapatan" data={pendapatan} />
|
||||||
|
<Summary title="💸 Belanja" data={belanja} />
|
||||||
|
<Summary title="📊 Pembiayaan" data={pembiayaan} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Summary Total Keseluruhan */}
|
||||||
|
<Box mb="lg" p="md" bg="gray.0">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text fw={700} fz="lg">TOTAL KESELURUHAN</Text>
|
||||||
|
<Text fw={700} fz="xl" c={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}>
|
||||||
|
{persenSemua.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="sm" c="dimmed" mb="xs">
|
||||||
|
{formatRupiah(totalRealisasiSemua)} / {formatRupiah(totalAnggaranSemua)}
|
||||||
|
</Text>
|
||||||
|
<Progress
|
||||||
|
value={persenSemua}
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/darmasaba/_com/main-page/apbdes/lib/paguTable.tsx
Normal file
60
src/app/darmasaba/_com/main-page/apbdes/lib/paguTable.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { Paper, Table, Title } from '@mantine/core';
|
||||||
|
|
||||||
|
function Section({ title, data }: any) {
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={2}>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
|
||||||
|
{data.map((item: any) => (
|
||||||
|
<Table.Tr key={item.id}>
|
||||||
|
<Table.Td>
|
||||||
|
{item.kode} - {item.uraian}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
Rp {item.anggaran.toLocaleString('id-ID')}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaguTable({ apbdesData }: any) {
|
||||||
|
const items = apbdesData.items || [];
|
||||||
|
|
||||||
|
const title =
|
||||||
|
apbdesData.tahun
|
||||||
|
? `PAGU APBDes Tahun ${apbdesData.tahun}`
|
||||||
|
: 'PAGU APBDes';
|
||||||
|
|
||||||
|
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
|
||||||
|
const belanja = items.filter((i: any) => i.tipe === 'belanja');
|
||||||
|
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Title order={5} mb="md">{title}</Title>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Uraian</Table.Th>
|
||||||
|
<Table.Th ta="right">Anggaran (Rp)</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Section title="1) PENDAPATAN" data={pendapatan} />
|
||||||
|
<Section title="2) BELANJA" data={belanja} />
|
||||||
|
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { Paper, Table, Title, Badge } from '@mantine/core';
|
||||||
|
|
||||||
|
function Section({ title, data }: any) {
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={3}>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
|
||||||
|
{data.map((item: any) => (
|
||||||
|
<Table.Tr key={item.id}>
|
||||||
|
<Table.Td>
|
||||||
|
{item.kode} - {item.uraian}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
Rp {item.totalRealisasi.toLocaleString('id-ID')}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Badge
|
||||||
|
color={
|
||||||
|
item.persentase >= 100
|
||||||
|
? 'teal'
|
||||||
|
: item.persentase >= 60
|
||||||
|
? 'yellow'
|
||||||
|
: 'red'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.persentase.toFixed(2)}%
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RealisasiTable({ apbdesData }: any) {
|
||||||
|
const items = apbdesData.items || [];
|
||||||
|
|
||||||
|
const title =
|
||||||
|
apbdesData.tahun
|
||||||
|
? `REALISASI APBDes Tahun ${apbdesData.tahun}`
|
||||||
|
: 'REALISASI APBDes';
|
||||||
|
|
||||||
|
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
|
||||||
|
const belanja = items.filter((i: any) => i.tipe === 'belanja');
|
||||||
|
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Title order={5} mb="md">{title}</Title>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Uraian</Table.Th>
|
||||||
|
<Table.Th ta="right">Realisasi (Rp)</Table.Th>
|
||||||
|
<Table.Th ta="center">%</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Section title="1) PENDAPATAN" data={pendapatan} />
|
||||||
|
<Section title="2) BELANJA" data={belanja} />
|
||||||
|
<Section title="3) PEMBIAYAAN" data={pembiayaan} />
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -150,13 +150,13 @@ export default function Page() {
|
|||||||
<Box id="page-root">
|
<Box id="page-root">
|
||||||
<Stack bg={colors.grey[1]} gap={0}>
|
<Stack bg={colors.grey[1]} gap={0}>
|
||||||
<LandingPage />
|
<LandingPage />
|
||||||
|
<Apbdes />
|
||||||
<Penghargaan />
|
<Penghargaan />
|
||||||
<Layanan />
|
<Layanan />
|
||||||
<Potensi />
|
<Potensi />
|
||||||
<DesaAntiKorupsi />
|
<DesaAntiKorupsi />
|
||||||
<Kepuasan />
|
<Kepuasan />
|
||||||
<SDGS />
|
<SDGS />
|
||||||
<Apbdes />
|
|
||||||
<Prestasi />
|
<Prestasi />
|
||||||
<ScrollToTopButton />
|
<ScrollToTopButton />
|
||||||
<NewsReaderLanding />
|
<NewsReaderLanding />
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { Metadata, Viewport } from "next";
|
|||||||
import { ViewTransitions } from "next-view-transitions";
|
import { ViewTransitions } from "next-view-transitions";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
|
|
||||||
|
// Force dynamic rendering untuk menghindari error prerendering
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
// ✅ Pisahkan viewport ke export tersendiri
|
// ✅ Pisahkan viewport ke export tersendiri
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
|
|||||||
Reference in New Issue
Block a user