Compare commits
27 Commits
nico/fix-p
...
fix/tampil
| Author | SHA1 | Date | |
|---|---|---|---|
| 239771a714 | |||
| 03451195c8 | |||
| 597af7e716 | |||
| 0a8a026b94 | |||
| a5bd91b580 | |||
| ae3187804e | |||
| 91e32f3f1c | |||
| 4d03908f23 | |||
| 0563f9664f | |||
| 961cc32057 | |||
| fe7672e09f | |||
| 341ff5779f | |||
| 69f7b4c162 | |||
| 409ad4f1a2 | |||
| 55ea3c473a | |||
| a152eaf984 | |||
| 223b85a714 | |||
| f1729151b3 | |||
| 8e8c133eea | |||
| 1e7acac193 | |||
| 42dcbcfb22 | |||
| 22de1aa1f3 | |||
| b1d28a8322 | |||
| b86a3a85c3 | |||
| fd63bb0fd4 | |||
| f0558aa0d0 | |||
| 8132609ccb |
169
darkMode.md
Normal file
169
darkMode.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# 🌙 Dark Mode Design Specification
|
||||||
|
## Admin Darmasaba – Dashboard & CMS
|
||||||
|
|
||||||
|
Dokumen ini mendefinisikan standar **Dark Mode UI** agar:
|
||||||
|
- nyaman di mata
|
||||||
|
- konsisten
|
||||||
|
- tidak flat
|
||||||
|
- tetap profesional untuk aplikasi pemerintahan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Color Palette (Dark Mode)
|
||||||
|
|
||||||
|
### Background Layers
|
||||||
|
| Layer | Token | Warna | Fungsi |
|
||||||
|
|------|------|------|------|
|
||||||
|
| Base | `--bg-base` | `#0B1220` | Background utama aplikasi |
|
||||||
|
| App | `--bg-app` | `#0F172A` | Area kerja utama |
|
||||||
|
| Card | `--bg-card` | `#162235` | Card / container |
|
||||||
|
| Surface | `--bg-surface` | `#1E2A3D` | Table header, tab, input |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Border & Divider
|
||||||
|
| Token | Warna | Catatan |
|
||||||
|
|-----|------|--------|
|
||||||
|
| `--border-default` | `#2A3A52` | Border utama |
|
||||||
|
| `--border-soft` | `#22314A` | Divider halus |
|
||||||
|
|
||||||
|
> ❗ Hindari border terlalu tipis (`opacity < 20%`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Text Colors
|
||||||
|
| Jenis | Token | Warna |
|
||||||
|
|-----|------|------|
|
||||||
|
| Primary | `--text-primary` | `#E5E7EB` |
|
||||||
|
| Secondary | `--text-secondary` | `#9CA3AF` |
|
||||||
|
| Muted | `--text-muted` | `#6B7280` |
|
||||||
|
| Inverse | `--text-inverse` | `#020617` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Accent & Action
|
||||||
|
| Fungsi | Warna |
|
||||||
|
|------|------|
|
||||||
|
| Primary Action | `#3B82F6` |
|
||||||
|
| Hover | `#2563EB` |
|
||||||
|
| Active | `#1D4ED8` |
|
||||||
|
| Link | `#60A5FA` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Status Colors
|
||||||
|
| Status | Warna |
|
||||||
|
|------|------|
|
||||||
|
| Success | `#22C55E` |
|
||||||
|
| Warning | `#FACC15` |
|
||||||
|
| Error | `#EF4444` |
|
||||||
|
| Info | `#38BDF8` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Layout Rules
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
- Background: `--bg-app`
|
||||||
|
- Active menu:
|
||||||
|
- Background: `rgba(59,130,246,0.15)`
|
||||||
|
- Text: Primary
|
||||||
|
- Indicator: kiri (2–3px accent bar)
|
||||||
|
- Hover:
|
||||||
|
- Background: `rgba(255,255,255,0.04)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Header / Topbar
|
||||||
|
- Background: `linear-gradient(#0F172A → #0B1220)`
|
||||||
|
- Border bawah wajib (`--border-soft`)
|
||||||
|
- Icon:
|
||||||
|
- Default: muted
|
||||||
|
- Hover: primary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Card & Section
|
||||||
|
|
||||||
|
### Card
|
||||||
|
- Background: `--bg-card`
|
||||||
|
- Border: `--border-default`
|
||||||
|
- Radius: 12–16px
|
||||||
|
- Jangan pakai shadow hitam
|
||||||
|
|
||||||
|
### Section Header
|
||||||
|
- Font weight lebih besar
|
||||||
|
- Text: primary
|
||||||
|
- Spacing jelas dari konten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Table (Dark Mode Friendly)
|
||||||
|
|
||||||
|
### Table Header
|
||||||
|
- Background: `--bg-surface`
|
||||||
|
- Text: secondary
|
||||||
|
- Font weight: medium
|
||||||
|
|
||||||
|
### Table Row
|
||||||
|
- Default: transparent
|
||||||
|
- Hover:
|
||||||
|
- Background: `rgba(255,255,255,0.03)`
|
||||||
|
- Divider antar row wajib terlihat
|
||||||
|
|
||||||
|
### Link di Table
|
||||||
|
- Warna link **lebih terang dari text**
|
||||||
|
- Hover underline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔘 Button Rules
|
||||||
|
|
||||||
|
### Primary Button
|
||||||
|
- Background: Primary Action
|
||||||
|
- Text: Inverse
|
||||||
|
- Hover: darker shade
|
||||||
|
|
||||||
|
### Secondary Button
|
||||||
|
- Background: transparent
|
||||||
|
- Border: `--border-default`
|
||||||
|
- Text: primary
|
||||||
|
|
||||||
|
### Icon Button
|
||||||
|
- Default: muted
|
||||||
|
- Hover: primary + bg soft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Tab Navigation
|
||||||
|
|
||||||
|
- Inactive:
|
||||||
|
- Text: muted
|
||||||
|
- Active:
|
||||||
|
- Background: `rgba(59,130,246,0.15)`
|
||||||
|
- Text: primary
|
||||||
|
- Icon ikut berubah
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌗 Dark vs Light Mode Rule
|
||||||
|
- Layout, spacing, typography **HARUS SAMA**
|
||||||
|
- Yang boleh beda:
|
||||||
|
- warna
|
||||||
|
- border intensity
|
||||||
|
- background layer
|
||||||
|
|
||||||
|
> ❌ Jangan ganti struktur UI antara dark & light
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Dark Mode Checklist
|
||||||
|
- [ ] Kontras teks terbaca
|
||||||
|
- [ ] Active state jelas
|
||||||
|
- [ ] Hover terasa hidup
|
||||||
|
- [ ] Tidak flat
|
||||||
|
- [ ] Tidak silau
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Dokumen ini adalah **single source of truth** untuk Dark Mode.
|
||||||
@@ -19,7 +19,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -26,7 +26,24 @@ export async function seedBerita() {
|
|||||||
|
|
||||||
console.log("🔄 Seeding Berita...");
|
console.log("🔄 Seeding Berita...");
|
||||||
|
|
||||||
|
// Build a map of valid kategori IDs
|
||||||
|
const validKategoriIds = new Set<string>();
|
||||||
|
const kategoriList = await prisma.kategoriBerita.findMany({
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
kategoriList.forEach((k) => validKategoriIds.add(k.id));
|
||||||
|
|
||||||
|
console.log(`📋 Found ${validKategoriIds.size} valid kategori IDs in database`);
|
||||||
|
|
||||||
for (const b of beritaJson) {
|
for (const b of beritaJson) {
|
||||||
|
// Validate kategoriBeritaId exists
|
||||||
|
if (!b.kategoriBeritaId || !validKategoriIds.has(b.kategoriBeritaId)) {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ Skipping berita "${b.judul}": Invalid kategoriBeritaId "${b.kategoriBeritaId}"`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let imageId: string | null = null;
|
let imageId: string | null = null;
|
||||||
|
|
||||||
if (b.imageName) {
|
if (b.imageName) {
|
||||||
@@ -44,26 +61,32 @@ export async function seedBerita() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.berita.upsert({
|
try {
|
||||||
where: { id: b.id },
|
await prisma.berita.upsert({
|
||||||
update: {
|
where: { id: b.id },
|
||||||
judul: b.judul,
|
update: {
|
||||||
deskripsi: b.deskripsi,
|
judul: b.judul,
|
||||||
content: b.content,
|
deskripsi: b.deskripsi,
|
||||||
kategoriBeritaId: b.kategoriBeritaId,
|
content: b.content,
|
||||||
imageId,
|
kategoriBeritaId: b.kategoriBeritaId,
|
||||||
},
|
imageId,
|
||||||
create: {
|
},
|
||||||
id: b.id,
|
create: {
|
||||||
judul: b.judul,
|
id: b.id,
|
||||||
deskripsi: b.deskripsi,
|
judul: b.judul,
|
||||||
content: b.content,
|
deskripsi: b.deskripsi,
|
||||||
kategoriBeritaId: b.kategoriBeritaId,
|
content: b.content,
|
||||||
imageId,
|
kategoriBeritaId: b.kategoriBeritaId,
|
||||||
},
|
imageId,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`✅ Berita seeded: ${b.judul}`);
|
console.log(`✅ Berita seeded: ${b.judul}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`❌ Failed to seed berita "${b.judul}": ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🎉 Berita seed selesai");
|
console.log("🎉 Berita seed selesai");
|
||||||
|
|||||||
170
prisma/migrations/20260225082505_deploy/migration.sql
Normal file
170
prisma/migrations/20260225082505_deploy/migration.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to alter the column `nama` on the `KategoriPotensi` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(100)`.
|
||||||
|
- You are about to alter the column `name` on the `PotensiDesa` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`.
|
||||||
|
- You are about to alter the column `kategoriId` on the `PotensiDesa` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(36)`.
|
||||||
|
- A unique constraint covering the columns `[nama]` on the table `KategoriPotensi` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[name]` on the table `PotensiDesa` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Made the column `kategoriId` on table `PotensiDesa` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "DataPerpustakaan" DROP CONSTRAINT "DataPerpustakaan_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "DesaDigital" DROP CONSTRAINT "DesaDigital_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "InfoTekno" DROP CONSTRAINT "InfoTekno_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "KegiatanDesa" DROP CONSTRAINT "KegiatanDesa_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "PengaduanMasyarakat" DROP CONSTRAINT "PengaduanMasyarakat_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "PotensiDesa" DROP CONSTRAINT "PotensiDesa_kategoriId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ProfileDesaImage" DROP CONSTRAINT "ProfileDesaImage_imageId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CaraMemperolehInformasi" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CaraMemperolehSalinanInformasi" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DaftarInformasiPublik" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DasarHukumPPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DataPerpustakaan" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DesaDigital" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FormulirPermohonanKeberatan" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "InfoTekno" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "JenisInformasiDiminta" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "JenisKelaminResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "KategoriPotensi" ALTER COLUMN "nama" SET DATA TYPE VARCHAR(100),
|
||||||
|
ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "KategoriPrestasiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "KegiatanDesa" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LambangDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MaskotDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PegawaiPPID" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PengaduanMasyarakat" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PermohonanInformasiPublik" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PilihanRatingResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PosisiOrganisasiPPID" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PotensiDesa" ALTER COLUMN "name" SET DATA TYPE VARCHAR(255),
|
||||||
|
ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "kategoriId" SET NOT NULL,
|
||||||
|
ALTER COLUMN "kategoriId" SET DATA TYPE VARCHAR(36);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PrestasiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ProfileDesaImage" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ProfilePPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Responden" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SejarahDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "UmurResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VisiMisiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VisiMisiPPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "KategoriPotensi_nama_key" ON "KategoriPotensi"("nama");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PotensiDesa_name_key" ON "PotensiDesa"("name");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProfileDesaImage" ADD CONSTRAINT "ProfileDesaImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PotensiDesa" ADD CONSTRAINT "PotensiDesa_kategoriId_fkey" FOREIGN KEY ("kategoriId") REFERENCES "KategoriPotensi"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DesaDigital" ADD CONSTRAINT "DesaDigital_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InfoTekno" ADD CONSTRAINT "InfoTekno_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PengaduanMasyarakat" ADD CONSTRAINT "PengaduanMasyarakat_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "KegiatanDesa" ADD CONSTRAINT "KegiatanDesa_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DataPerpustakaan" ADD CONSTRAINT "DataPerpustakaan_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ model FileStorage {
|
|||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
link String
|
link String
|
||||||
category String // "image" / "document" / "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[]
|
||||||
@@ -102,6 +103,9 @@ model FileStorage {
|
|||||||
|
|
||||||
ArtikelKesehatan ArtikelKesehatan[]
|
ArtikelKesehatan ArtikelKesehatan[]
|
||||||
StrukturBumDes StrukturBumDes[]
|
StrukturBumDes StrukturBumDes[]
|
||||||
|
|
||||||
|
MusikDesaAudio MusikDesa[] @relation("MusikAudioFile")
|
||||||
|
MusikDesaCover MusikDesa[] @relation("MusikCoverImage")
|
||||||
}
|
}
|
||||||
|
|
||||||
//========================================= MENU LANDING PAGE ========================================= //
|
//========================================= MENU LANDING PAGE ========================================= //
|
||||||
@@ -236,7 +240,7 @@ model PrestasiDesa {
|
|||||||
imageId String?
|
imageId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +249,7 @@ model KategoriPrestasiDesa {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PrestasiDesa PrestasiDesa[]
|
PrestasiDesa PrestasiDesa[]
|
||||||
}
|
}
|
||||||
@@ -263,7 +267,7 @@ model Responden {
|
|||||||
kelompokUmurId String
|
kelompokUmurId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +276,7 @@ model JenisKelaminResponden {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
Responden Responden[]
|
Responden Responden[]
|
||||||
}
|
}
|
||||||
@@ -282,7 +286,7 @@ model PilihanRatingResponden {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
Responden Responden[]
|
Responden Responden[]
|
||||||
}
|
}
|
||||||
@@ -292,7 +296,7 @@ model UmurResponden {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
Responden Responden[]
|
Responden Responden[]
|
||||||
}
|
}
|
||||||
@@ -326,6 +330,7 @@ model PosisiOrganisasiPPID {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id])
|
parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id])
|
||||||
children PosisiOrganisasiPPID[] @relation("Parent")
|
children PosisiOrganisasiPPID[] @relation("Parent")
|
||||||
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
|
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
|
||||||
@@ -345,6 +350,7 @@ model PegawaiPPID {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id])
|
posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id])
|
||||||
strukturOrganisasi StrukturPPID[] // Relasi balik
|
strukturOrganisasi StrukturPPID[] // Relasi balik
|
||||||
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
|
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
|
||||||
@@ -370,7 +376,7 @@ model VisiMisiPPID {
|
|||||||
misi String @db.Text
|
misi String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +387,7 @@ model DasarHukumPPID {
|
|||||||
content String @db.Text
|
content String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +404,7 @@ model ProfilePPID {
|
|||||||
imageId String?
|
imageId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +416,7 @@ model DaftarInformasiPublik {
|
|||||||
tanggal DateTime @db.Date
|
tanggal DateTime @db.Date
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +437,7 @@ model PermohonanInformasiPublik {
|
|||||||
caraMemperolehSalinanInformasiId String?
|
caraMemperolehSalinanInformasiId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +446,7 @@ model JenisInformasiDiminta {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
||||||
}
|
}
|
||||||
@@ -450,7 +456,7 @@ model CaraMemperolehInformasi {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
||||||
}
|
}
|
||||||
@@ -460,7 +466,7 @@ model CaraMemperolehSalinanInformasi {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
||||||
}
|
}
|
||||||
@@ -474,7 +480,7 @@ model FormulirPermohonanKeberatan {
|
|||||||
alasan String
|
alasan String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +537,7 @@ model SejarahDesa {
|
|||||||
deskripsi String @db.Text
|
deskripsi String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +547,7 @@ model VisiMisiDesa {
|
|||||||
misi String @db.Text
|
misi String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +557,7 @@ model LambangDesa {
|
|||||||
deskripsi String @db.Text
|
deskripsi String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +568,7 @@ model MaskotDesa {
|
|||||||
images ProfileDesaImage[]
|
images ProfileDesaImage[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,15 +613,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 {
|
||||||
@@ -631,25 +641,25 @@ model KategoriBerita {
|
|||||||
// ========================================= POTENSI DESA ========================================= //
|
// ========================================= POTENSI DESA ========================================= //
|
||||||
model PotensiDesa {
|
model PotensiDesa {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String @unique @db.VarChar(255)
|
||||||
deskripsi String
|
deskripsi String @db.Text
|
||||||
kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id])
|
kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id])
|
||||||
kategoriId String?
|
kategoriId String @db.VarChar(36)
|
||||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
image FileStorage? @relation(fields: [imageId], references: [id])
|
||||||
imageId String?
|
imageId String?
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
model KategoriPotensi {
|
model KategoriPotensi {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
nama String
|
nama String @unique @db.VarChar(100)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PotensiDesa PotensiDesa[]
|
PotensiDesa PotensiDesa[]
|
||||||
}
|
}
|
||||||
@@ -2261,3 +2271,25 @@ model UserMenuAccess {
|
|||||||
|
|
||||||
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
|
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================= MUSIK DESA ========================================= //
|
||||||
|
model MusikDesa {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
judul String @db.VarChar(255)
|
||||||
|
artis String @db.VarChar(255)
|
||||||
|
deskripsi String? @db.Text
|
||||||
|
durasi String @db.VarChar(20) // format: "MM:SS"
|
||||||
|
audioFile FileStorage? @relation("MusikAudioFile", fields: [audioFileId], references: [id])
|
||||||
|
audioFileId String?
|
||||||
|
coverImage FileStorage? @relation("MusikCoverImage", fields: [coverImageId], references: [id])
|
||||||
|
coverImageId String?
|
||||||
|
genre String? @db.VarChar(100)
|
||||||
|
tahunRilis Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
@@index([judul])
|
||||||
|
@@index([artis])
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ import { seedProfilPpd } from "./_seeder_list/ppid/profil-ppid/seed_profil_ppd";
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
// Always run seedAssets to handle new images without duplication
|
// Always run seedAssets to handle new images without duplication
|
||||||
// console.log("📂 Checking for new assets to seed...");
|
console.log("📂 Checking for new assets to seed...");
|
||||||
// await seedAssets();
|
await seedAssets();
|
||||||
|
|
||||||
// // =========== FILE STORAGE ===========
|
// // =========== FILE STORAGE ===========
|
||||||
|
|
||||||
|
|||||||
BIN
public/mp3-logo.png
Normal file
BIN
public/mp3-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -1,7 +1,11 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Grid, GridCol, Paper, TextInput, Title } from '@mantine/core';
|
import { Grid, GridCol, Paper, TextInput } from '@mantine/core';
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import colors from '@/con/colors';
|
import { useDarkMode } from '@/state/darkModeStore';
|
||||||
|
import { themeTokens } from '@/utils/themeTokens';
|
||||||
|
import { UnifiedTitle } from '@/components/admin/UnifiedTypography';
|
||||||
|
|
||||||
type HeaderSearchProps = {
|
type HeaderSearchProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -18,13 +22,16 @@ const HeaderSearch = ({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: HeaderSearchProps) => {
|
}: HeaderSearchProps) => {
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const tokens = themeTokens(isDark);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid mb={10}>
|
<Grid mb={10}>
|
||||||
<GridCol span={{ base: 12, md: 9 }}>
|
<GridCol span={{ base: 12, md: 9 }}>
|
||||||
<Title order={3}>{title}</Title>
|
<UnifiedTitle order={3}>{title}</UnifiedTitle>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
<GridCol span={{ base: 12, md: 3 }}>
|
<GridCol span={{ base: 12, md: 3 }}>
|
||||||
<Paper radius="lg" bg={colors['white-1']}>
|
<Paper radius="lg" bg={tokens.colors.bg.surface}>
|
||||||
<TextInput
|
<TextInput
|
||||||
radius="lg"
|
radius="lg"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -32,6 +39,16 @@ const HeaderSearch = ({
|
|||||||
w="100%"
|
w="100%"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
style={{
|
||||||
|
input: {
|
||||||
|
backgroundColor: tokens.colors.bg.surface,
|
||||||
|
color: tokens.colors.text.primary,
|
||||||
|
borderColor: tokens.colors.border.default,
|
||||||
|
'::placeholder': {
|
||||||
|
color: tokens.colors.text.muted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
import colors from '@/con/colors';
|
import { Grid, GridCol, Button } from '@mantine/core';
|
||||||
import { Grid, GridCol, Button, Text } from '@mantine/core';
|
|
||||||
import { IconCircleDashedPlus } from '@tabler/icons-react';
|
import { IconCircleDashedPlus } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useDarkMode } from '@/state/darkModeStore';
|
||||||
|
import { themeTokens } from '@/utils/themeTokens';
|
||||||
|
import { UnifiedText } from '@/components/admin/UnifiedTypography';
|
||||||
|
|
||||||
const JudulList = ({ title = "", href = "#" }) => {
|
const JudulList = ({ title = "", href = "#" }) => {
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const tokens = themeTokens(isDark);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleNavigate = () => {
|
const handleNavigate = () => {
|
||||||
@@ -16,10 +20,18 @@ const JudulList = ({ title = "", href = "#" }) => {
|
|||||||
return (
|
return (
|
||||||
<Grid align="center" mb={10}>
|
<Grid align="center" mb={10}>
|
||||||
<GridCol span={{ base: 12, md: 11 }}>
|
<GridCol span={{ base: 12, md: 11 }}>
|
||||||
<Text fz={"xl"} fw={"bold"}>{title}</Text>
|
<UnifiedText size="body" weight="bold" color="primary">{title}</UnifiedText>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
<GridCol span={{ base: 12, md: 1 }} ta="right">
|
<GridCol span={{ base: 12, md: 1 }} ta="right">
|
||||||
<Button onClick={handleNavigate} bg={colors['blue-button']}>
|
<Button
|
||||||
|
onClick={handleNavigate}
|
||||||
|
bg={tokens.colors.primary}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${tokens.colors.primary}, ${isDark ? '#60A5FA' : '#4facfe'})`,
|
||||||
|
color: tokens.colors.text.inverse,
|
||||||
|
boxShadow: isDark ? 'none' : `0 4px 15px rgba(79, 172, 254, 0.4)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<IconCircleDashedPlus size={25} />
|
<IconCircleDashedPlus size={25} />
|
||||||
</Button>
|
</Button>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import colors from '@/con/colors';
|
import { Grid, GridCol, Button, Paper, TextInput } from '@mantine/core';
|
||||||
import { Grid, GridCol, Button, Text, Paper, TextInput } from '@mantine/core';
|
|
||||||
import { IconCircleDashedPlus, IconSearch } from '@tabler/icons-react';
|
import { IconCircleDashedPlus, IconSearch } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useDarkMode } from '@/state/darkModeStore';
|
||||||
|
import { themeTokens } from '@/utils/themeTokens';
|
||||||
|
import { UnifiedText } from '@/components/admin/UnifiedTypography';
|
||||||
|
|
||||||
type JudulListTabProps = {
|
type JudulListTabProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,17 +16,16 @@ type JudulListTabProps = {
|
|||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const JudulListTab = ({
|
const JudulListTab = ({
|
||||||
title = "",
|
title = "",
|
||||||
href = "#",
|
href = "#",
|
||||||
placeholder = "pencarian",
|
placeholder = "pencarian",
|
||||||
searchIcon = <IconSearch size={20} />,
|
searchIcon = <IconSearch size={20} />,
|
||||||
value,
|
value,
|
||||||
onChange
|
onChange
|
||||||
}: JudulListTabProps) => {
|
}: JudulListTabProps) => {
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const tokens = themeTokens(isDark);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleNavigate = () => {
|
const handleNavigate = () => {
|
||||||
@@ -34,10 +35,17 @@ const JudulListTab = ({
|
|||||||
return (
|
return (
|
||||||
<Grid mb={10}>
|
<Grid mb={10}>
|
||||||
<GridCol span={{ base: 12, md: 8 }}>
|
<GridCol span={{ base: 12, md: 8 }}>
|
||||||
<Text fz={{ base: "md", md: "xl" }} fw={"bold"}>{title}</Text>
|
<UnifiedText
|
||||||
|
size="body"
|
||||||
|
weight="bold"
|
||||||
|
color="primary"
|
||||||
|
style={{ fontSize: 'clamp(1rem, 2vw, 1.25rem)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</UnifiedText>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
<GridCol span={{ base: 9, md: 3 }} ta="right">
|
<GridCol span={{ base: 9, md: 3 }} ta="right">
|
||||||
<Paper radius={"lg"} bg={colors['white-1']}>
|
<Paper radius={"lg"} bg={tokens.colors.bg.surface}>
|
||||||
<TextInput
|
<TextInput
|
||||||
radius="lg"
|
radius="lg"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -45,11 +53,29 @@ const JudulListTab = ({
|
|||||||
w="100%"
|
w="100%"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
style={{
|
||||||
|
input: {
|
||||||
|
backgroundColor: tokens.colors.bg.surface,
|
||||||
|
color: tokens.colors.text.primary,
|
||||||
|
borderColor: tokens.colors.border.default,
|
||||||
|
'::placeholder': {
|
||||||
|
color: tokens.colors.text.muted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
<GridCol span={{ base: 3, md: 1 }} ta="right">
|
<GridCol span={{ base: 3, md: 1 }} ta="right">
|
||||||
<Button onClick={handleNavigate} bg={colors['blue-button']}>
|
<Button
|
||||||
|
onClick={handleNavigate}
|
||||||
|
bg={tokens.colors.primary}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${tokens.colors.primary}, ${isDark ? '#60A5FA' : '#4facfe'})`,
|
||||||
|
color: tokens.colors.text.inverse,
|
||||||
|
boxShadow: isDark ? 'none' : `0 4px 15px rgba(79, 172, 254, 0.4)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<IconCircleDashedPlus size={25} />
|
<IconCircleDashedPlus size={25} />
|
||||||
</Button>
|
</Button>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { proxy } from "valtio";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// 1. Schema validasi dengan Zod
|
||||||
|
const templateForm = z.object({
|
||||||
|
judul: z.string().min(3, "Judul minimal 3 karakter"),
|
||||||
|
artis: z.string().min(3, "Artis minimal 3 karakter"),
|
||||||
|
deskripsi: z.string().optional(),
|
||||||
|
durasi: z.string().min(3, "Durasi minimal 3 karakter"),
|
||||||
|
audioFileId: z.string().nonempty(),
|
||||||
|
coverImageId: z.string().nonempty(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
tahunRilis: z.number().optional().or(z.literal(undefined)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Default value form musik
|
||||||
|
const defaultForm = {
|
||||||
|
judul: "",
|
||||||
|
artis: "",
|
||||||
|
deskripsi: "",
|
||||||
|
durasi: "",
|
||||||
|
audioFileId: "",
|
||||||
|
coverImageId: "",
|
||||||
|
genre: "",
|
||||||
|
tahunRilis: undefined as number | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Musik proxy
|
||||||
|
const musik = proxy({
|
||||||
|
create: {
|
||||||
|
form: { ...defaultForm },
|
||||||
|
loading: false,
|
||||||
|
async create() {
|
||||||
|
const cek = templateForm.safeParse(musik.create.form);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues
|
||||||
|
.map((v) => `${v.path.join(".")}`)
|
||||||
|
.join("\n")}] required`;
|
||||||
|
return toast.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
musik.create.loading = true;
|
||||||
|
const res = await ApiFetch.api.desa.musik["create"].post(
|
||||||
|
musik.create.form
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
musik.findMany.load();
|
||||||
|
return toast.success("Musik berhasil disimpan!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return toast.error("Gagal menyimpan musik");
|
||||||
|
} catch (error) {
|
||||||
|
console.log((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
musik.create.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetForm() {
|
||||||
|
musik.create.form = { ...defaultForm };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: {
|
||||||
|
data: null as
|
||||||
|
| Prisma.MusikDesaGetPayload<{
|
||||||
|
include: {
|
||||||
|
audioFile: true;
|
||||||
|
coverImage: true;
|
||||||
|
};
|
||||||
|
}>[]
|
||||||
|
| null,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
loading: false,
|
||||||
|
search: "",
|
||||||
|
load: async (page = 1, limit = 10, search = "", genre = "") => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
musik.findMany.loading = true;
|
||||||
|
musik.findMany.page = page;
|
||||||
|
musik.findMany.search = search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query: any = { page, limit };
|
||||||
|
if (search) query.search = search;
|
||||||
|
if (genre) query.genre = genre;
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.desa.musik["find-many"].get({ query });
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
musik.findMany.data = res.data.data ?? [];
|
||||||
|
musik.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
|
} else {
|
||||||
|
musik.findMany.data = [];
|
||||||
|
musik.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch musik paginated:", err);
|
||||||
|
musik.findMany.data = [];
|
||||||
|
musik.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const minDelay = 300;
|
||||||
|
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
musik.findMany.loading = false;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: {
|
||||||
|
data: null as Prisma.MusikDesaGetPayload<{
|
||||||
|
include: {
|
||||||
|
audioFile: true;
|
||||||
|
coverImage: true;
|
||||||
|
};
|
||||||
|
}> | null,
|
||||||
|
loading: false,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
musik.findUnique.loading = true;
|
||||||
|
const res = await fetch(`/api/desa/musik/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
musik.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch musik:", res.statusText);
|
||||||
|
musik.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching musik:", error);
|
||||||
|
musik.findUnique.data = null;
|
||||||
|
} finally {
|
||||||
|
musik.findUnique.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
loading: false,
|
||||||
|
async byId(id: string) {
|
||||||
|
if (!id) return toast.warn("ID tidak valid");
|
||||||
|
|
||||||
|
try {
|
||||||
|
musik.delete.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/desa/musik/delete/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
toast.success(result.message || "Musik berhasil dihapus");
|
||||||
|
await musik.findMany.load();
|
||||||
|
} else {
|
||||||
|
toast.error(result?.message || "Gagal menghapus musik");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal delete:", error);
|
||||||
|
toast.error("Terjadi kesalahan saat menghapus musik");
|
||||||
|
} finally {
|
||||||
|
musik.delete.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
id: "",
|
||||||
|
form: { ...defaultForm },
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
async load(id: string) {
|
||||||
|
if (!id) {
|
||||||
|
toast.warn("ID tidak valid");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/desa/musik/${id}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
const data = result.data;
|
||||||
|
this.id = data.id;
|
||||||
|
this.form = {
|
||||||
|
judul: data.judul,
|
||||||
|
artis: data.artis,
|
||||||
|
deskripsi: data.deskripsi || "",
|
||||||
|
durasi: data.durasi,
|
||||||
|
audioFileId: data.audioFileId || "",
|
||||||
|
coverImageId: data.coverImageId || "",
|
||||||
|
genre: data.genre || "",
|
||||||
|
tahunRilis: data.tahunRilis || undefined,
|
||||||
|
};
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
throw new Error(result?.message || "Gagal memuat data");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading musik:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Gagal memuat data"
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async update() {
|
||||||
|
const cek = templateForm.safeParse(musik.edit.form);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues
|
||||||
|
.map((v) => `${v.path.join(".")}`)
|
||||||
|
.join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
musik.edit.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/desa/musik/${this.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
judul: this.form.judul,
|
||||||
|
artis: this.form.artis,
|
||||||
|
deskripsi: this.form.deskripsi,
|
||||||
|
durasi: this.form.durasi,
|
||||||
|
audioFileId: this.form.audioFileId,
|
||||||
|
coverImageId: this.form.coverImageId,
|
||||||
|
genre: this.form.genre,
|
||||||
|
tahunRilis: this.form.tahunRilis,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
errorData.message || `HTTP error! status: ${response.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Musik berhasil diupdate");
|
||||||
|
await musik.findMany.load();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "Gagal update musik");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating musik:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Terjadi kesalahan saat update musik"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
musik.edit.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
musik.edit.id = "";
|
||||||
|
musik.edit.form = { ...defaultForm };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. State global
|
||||||
|
const stateDashboardMusik = proxy({
|
||||||
|
musik,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default stateDashboardMusik;
|
||||||
@@ -19,6 +19,9 @@ const ApbdesItemSchema = z.object({
|
|||||||
|
|
||||||
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,6 +30,9 @@ 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>[],
|
||||||
@@ -244,6 +250,9 @@ 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) => ({
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function ListKategoriBerita({ search }: { search: string }) {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd colSpan={4}>
|
<TableTd colSpan={3}> {/* ✅ Match column count (3 columns) */}
|
||||||
<Center py={24}>
|
<Center py={24}>
|
||||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
Tidak ada data kategori berita yang cocok
|
Tidak ada data kategori berita yang cocok
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ function ListBerita({ search }: { search: string }) {
|
|||||||
<Pagination
|
<Pagination
|
||||||
value={page}
|
value={page}
|
||||||
onChange={(newPage) => {
|
onChange={(newPage) => {
|
||||||
load(newPage, 10);
|
load(newPage, 10, debouncedSearch); // ✅ Include search parameter
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}}
|
}}
|
||||||
total={totalPages}
|
total={totalPages}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { IconArrowBack, IconEdit, IconTrash } 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';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
export default function DetailPotensi() {
|
export default function DetailPotensi() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -77,7 +78,17 @@ export default function DetailPotensi() {
|
|||||||
|
|
||||||
<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>
|
<Text
|
||||||
|
fz="md"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(data.deskripsi || '-', {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
></Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
@@ -102,7 +113,12 @@ export default function DetailPotensi() {
|
|||||||
<Text
|
<Text
|
||||||
fz="md"
|
fz="md"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(data.content || '-', {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
})
|
||||||
|
}}
|
||||||
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useProxy } from 'valtio/utils';
|
|||||||
import HeaderSearch from '../../../_com/header';
|
import HeaderSearch from '../../../_com/header';
|
||||||
import potensiDesaState from '../../../_state/desa/potensi';
|
import potensiDesaState from '../../../_state/desa/potensi';
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
function Potensi() {
|
function Potensi() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -137,7 +138,12 @@ function ListPotensi({ search }: { search: string }) {
|
|||||||
fz="sm"
|
fz="sm"
|
||||||
lh={1.5}
|
lh={1.5}
|
||||||
lineClamp={2}
|
lineClamp={2}
|
||||||
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(item.deskripsi, {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
})
|
||||||
|
}}
|
||||||
style={{ wordBreak: 'break-word' }}
|
style={{ wordBreak: 'break-word' }}
|
||||||
/>
|
/>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
@@ -199,7 +205,12 @@ function ListPotensi({ search }: { search: string }) {
|
|||||||
<Text
|
<Text
|
||||||
fz="sm"
|
fz="sm"
|
||||||
lh={1.5}
|
lh={1.5}
|
||||||
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(item.deskripsi, {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
})
|
||||||
|
}}
|
||||||
style={{ wordBreak: 'break-word' }}
|
style={{ wordBreak: 'break-word' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ function EditDigitalSmartVillage() {
|
|||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
@@ -248,8 +263,11 @@ function EditDigitalSmartVillage() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ export default function CreateDesaDigital() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateDesaDigital.create.form.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateDesaDigital.create.form.deskripsi) &&
|
||||||
|
file !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateDesaDigital.create.form = {
|
stateDesaDigital.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -227,8 +243,11 @@ export default function CreateDesaDigital() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ function EditInfoTeknologiTepatGuna() {
|
|||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data pertama kali
|
// Load data pertama kali
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
@@ -260,8 +275,11 @@ function EditInfoTeknologiTepatGuna() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ function CreateInfoTeknologiTepatGuna() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateInfoTekno.create.form.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateInfoTekno.create.form.deskripsi) &&
|
||||||
|
file !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateInfoTekno.create.form = {
|
stateInfoTekno.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -202,8 +218,11 @@ function CreateInfoTeknologiTepatGuna() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ function EditKolaborasiInovasi() {
|
|||||||
kolaborator: "",
|
kolaborator: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.slug?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
formData.kolaborator?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data awal dari server
|
// Load data awal dari server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadKolaborasi = async () => {
|
const loadKolaborasi = async () => {
|
||||||
@@ -199,8 +216,11 @@ function EditKolaborasiInovasi() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ function CreateProgramKreatifDesa() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateCreate.create.form.name?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.slug?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateCreate.create.form.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateCreate.create.form = {
|
stateCreate.create.form = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -135,8 +151,11 @@ function CreateProgramKreatifDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ function EditMitraKolaborasi() {
|
|||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return formData.name?.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
// Load data ke state lokal sekali saja
|
// Load data ke state lokal sekali saja
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -263,8 +268,11 @@ function EditMitraKolaborasi() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ function CreateMitraKolaborasi() {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
state.create.form.name?.trim() !== '' &&
|
||||||
|
file !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
state.create.form = {
|
state.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -181,8 +189,11 @@ function CreateMitraKolaborasi() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ function EditProgramKreatifDesa() {
|
|||||||
|
|
||||||
const [isDataChanged, setIsDataChanged] = useState(false);
|
const [isDataChanged, setIsDataChanged] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.slug?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
formData.icon?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data hanya sekali berdasarkan params.id
|
// Load data hanya sekali berdasarkan params.id
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProgramKreatif = async () => {
|
const loadProgramKreatif = async () => {
|
||||||
@@ -236,8 +253,11 @@ function EditProgramKreatifDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ function CreateProgramKreatifDesa() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateCreate.create.form.name?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.icon?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.slug?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateCreate.create.form.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateCreate.create.form = {
|
stateCreate.create.form = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -127,8 +144,11 @@ function CreateProgramKreatifDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -79,6 +79,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 +106,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 +118,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) => ({
|
||||||
@@ -238,9 +247,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,11 +261,11 @@ 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: '',
|
||||||
@@ -263,7 +275,7 @@ function EditAPBDes() {
|
|||||||
level: 1,
|
level: 1,
|
||||||
tipe: 'pendapatan',
|
tipe: 'pendapatan',
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.info('Form dikembalikan ke data awal');
|
toast.info('Form dikembalikan ke data awal');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,6 +300,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()}
|
||||||
|
|||||||
@@ -94,7 +94,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 +105,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 ? (
|
||||||
|
|||||||
@@ -117,9 +117,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);
|
||||||
}
|
}
|
||||||
@@ -334,6 +334,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()}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ export default function EditDataLingkunganDesa() {
|
|||||||
icon: '',
|
icon: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.jumlah?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
formData.icon?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data saat komponen mount
|
// Load data saat komponen mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -211,8 +228,11 @@ export default function EditDataLingkunganDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ function CreateDataLingkunganDesa() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateCreate.create.form.name?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.icon?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.jumlah?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateCreate.create.form.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateCreate.create.form = {
|
stateCreate.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -129,8 +146,11 @@ function CreateDataLingkunganDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ export default function EditContohKegiatanDesaDarmasaba() {
|
|||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
!isHtmlEmpty(formData.judul) &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// load data awal
|
// load data awal
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!contohEdukasiState.findById.data) {
|
if (!contohEdukasiState.findById.data) {
|
||||||
@@ -156,8 +171,11 @@ export default function EditContohKegiatanDesaDarmasaba() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ export default function EditMateriEdukasiYangDiberikan() {
|
|||||||
content: '',
|
content: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
!isHtmlEmpty(formData.judul) &&
|
||||||
|
!isHtmlEmpty(formData.content)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize data kalau belum ada
|
// Initialize data kalau belum ada
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!materiEdukasiState.findById.data) {
|
if (!materiEdukasiState.findById.data) {
|
||||||
@@ -139,8 +154,11 @@ export default function EditMateriEdukasiYangDiberikan() {
|
|||||||
onClick={submit}
|
onClick={submit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ export default function EditTujuanEdukasiLingkungan() {
|
|||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
!isHtmlEmpty(formData.judul) &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize global state
|
// Initialize global state
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!tujuanEdukasiState.findById.data) {
|
if (!tujuanEdukasiState.findById.data) {
|
||||||
@@ -147,8 +162,11 @@ export default function EditTujuanEdukasiLingkungan() {
|
|||||||
onClick={submit}
|
onClick={submit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ function EditKategoriKegiatan() {
|
|||||||
const [originalData, setOriginalData] = useState({ nama: '' });
|
const [originalData, setOriginalData] = useState({ nama: '' });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return formData.nama?.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
// Load data once
|
// Load data once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -126,8 +131,11 @@ function EditKategoriKegiatan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ function CreateKategoriKegiatan() {
|
|||||||
const stateKategori = useProxy(gotongRoyongState.kategoriKegiatan)
|
const stateKategori = useProxy(gotongRoyongState.kategoriKegiatan)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return stateKategori.create.form.nama?.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateKategori.findMany.load();
|
stateKategori.findMany.load();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -84,8 +89,11 @@ function CreateKategoriKegiatan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -67,6 +67,27 @@ export default function EditKegiatanDesa() {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsiSingkat) &&
|
||||||
|
!isHtmlEmpty(formData.deskripsiLengkap) &&
|
||||||
|
formData.tanggal?.trim() !== '' &&
|
||||||
|
formData.lokasi?.trim() !== '' &&
|
||||||
|
formData.partisipan !== null &&
|
||||||
|
formData.partisipan >= 0 &&
|
||||||
|
formData.kategoriKegiatanId?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const formatDateForInput = (dateString: string) => {
|
const formatDateForInput = (dateString: string) => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
return new Date(dateString).toISOString().split('T')[0];
|
return new Date(dateString).toISOString().split('T')[0];
|
||||||
@@ -312,8 +333,11 @@ export default function EditKegiatanDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -38,6 +38,28 @@ function CreateKegiatanDesa() {
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateKegiatanDesa.create.form.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateKegiatanDesa.create.form.deskripsiSingkat) &&
|
||||||
|
stateKegiatanDesa.create.form.partisipan !== null &&
|
||||||
|
stateKegiatanDesa.create.form.partisipan >= 0 &&
|
||||||
|
stateKegiatanDesa.create.form.tanggal !== null &&
|
||||||
|
stateKegiatanDesa.create.form.lokasi?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateKegiatanDesa.create.form.deskripsiLengkap) &&
|
||||||
|
stateKegiatanDesa.create.form.kategoriKegiatanId?.trim() !== '' &&
|
||||||
|
file !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateKegiatanDesa.create.form = {
|
stateKegiatanDesa.create.form = {
|
||||||
judul: '',
|
judul: '',
|
||||||
@@ -273,8 +295,11 @@ function CreateKegiatanDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ function EditBentukKonservasiBerdasarkanAdat() {
|
|||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
!isHtmlEmpty(formData.judul) &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize data dari global state
|
// Initialize data dari global state
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!bentukKonservasiState.findById.data) {
|
if (!bentukKonservasiState.findById.data) {
|
||||||
@@ -137,8 +152,11 @@ function EditBentukKonservasiBerdasarkanAdat() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -31,6 +31,21 @@ function EditFilosofiTriHitaKarana() {
|
|||||||
content: '',
|
content: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
!isHtmlEmpty(formData.judul) &&
|
||||||
|
!isHtmlEmpty(formData.content)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data dari global state kalau belum ada
|
// Load data dari global state kalau belum ada
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!filosofiTriHitaState.findById.data) {
|
if (!filosofiTriHitaState.findById.data) {
|
||||||
@@ -142,8 +157,11 @@ function EditFilosofiTriHitaKarana() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ function EditNilaiKonservasiAdat() {
|
|||||||
const [formData, setFormData] = useState({ judul: '', deskripsi: '' });
|
const [formData, setFormData] = useState({ judul: '', deskripsi: '' });
|
||||||
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
|
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
!isHtmlEmpty(formData.judul) &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// load data awal
|
// load data awal
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!nilaiKonservasiState.findById.data) {
|
if (!nilaiKonservasiState.findById.data) {
|
||||||
@@ -136,8 +151,11 @@ function EditNilaiKonservasiAdat() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ function EditKeteranganBankSampahTerdekat() {
|
|||||||
lng: 0,
|
lng: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.alamat?.trim() !== '' &&
|
||||||
|
formData.namaTempatMaps?.trim() !== '' &&
|
||||||
|
markerPosition !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data ketika component mount
|
// Load data ketika component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadKeterangan = async () => {
|
const loadKeterangan = async () => {
|
||||||
@@ -197,8 +207,11 @@ function EditKeteranganBankSampahTerdekat() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ function CreateKeteranganBankSampahTerdekat() {
|
|||||||
const [markerPosition, setMarkerPosition] = useState<{ lat: number; lng: number } | null>(null);
|
const [markerPosition, setMarkerPosition] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
keteranganState.create.form.name?.trim() !== '' &&
|
||||||
|
keteranganState.create.form.alamat?.trim() !== '' &&
|
||||||
|
keteranganState.create.form.namaTempatMaps?.trim() !== '' &&
|
||||||
|
markerPosition !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
keteranganState.create.form = {
|
keteranganState.create.form = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -135,8 +145,11 @@ function CreateKeteranganBankSampahTerdekat() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ function EditProgramKreatifDesa() {
|
|||||||
icon: '',
|
icon: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.icon?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProgramKreatif = async () => {
|
const loadProgramKreatif = async () => {
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
@@ -143,8 +151,11 @@ function EditProgramKreatifDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ function CreatePengelolaanSampahBankSampah() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateCreate.create.form.name?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.icon?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateCreate.create.form = {
|
stateCreate.create.form = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -91,8 +99,11 @@ function CreatePengelolaanSampahBankSampah() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -64,6 +64,23 @@ function EditProgramPenghijauan() {
|
|||||||
icon: '',
|
icon: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
formData.icon?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data program penghijauan
|
// Load data program penghijauan
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProgram = async () => {
|
const loadProgram = async () => {
|
||||||
@@ -216,8 +233,11 @@ function EditProgramPenghijauan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ function CreateProgramPenghijauan() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateCreate.create.form.name?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.icon?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateCreate.create.form.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateCreate.create.form = {
|
stateCreate.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -128,8 +145,11 @@ function CreateProgramPenghijauan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
428
src/app/admin/(dashboard)/musik/[id]/edit/page.tsx
Normal file
428
src/app/admin/(dashboard)/musik/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
'use client'
|
||||||
|
import CreateEditor from '../../../_com/createEditor';
|
||||||
|
import stateDashboardMusik from '../../../_state/desa/musik';
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import ApiFetch from '@/lib/api-fetch';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
Loader,
|
||||||
|
ActionIcon,
|
||||||
|
NumberInput
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconMusic } from '@tabler/icons-react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
|
||||||
|
export default function EditMusik() {
|
||||||
|
const musikState = useProxy(stateDashboardMusik);
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
const [previewCover, setPreviewCover] = useState<string | null>(null);
|
||||||
|
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||||
|
const [previewAudio, setPreviewAudio] = useState<string | null>(null);
|
||||||
|
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||||
|
const [isExtractingDuration, setIsExtractingDuration] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Fungsi untuk mendapatkan durasi dari file audio
|
||||||
|
const getAudioDuration = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
const duration = audio.duration;
|
||||||
|
const minutes = Math.floor(duration / 60);
|
||||||
|
const seconds = Math.floor(duration % 60);
|
||||||
|
const formatted = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(formatted);
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve('0:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
musikState.musik.edit.load(id).then(() => setIsLoading(false));
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
musikState.musik.edit.form.judul?.trim() !== '' &&
|
||||||
|
musikState.musik.edit.form.artis?.trim() !== '' &&
|
||||||
|
musikState.musik.edit.form.durasi?.trim() !== '' &&
|
||||||
|
(coverFile !== null || musikState.musik.edit.form.coverImageId !== '') &&
|
||||||
|
(audioFile !== null || musikState.musik.edit.form.audioFileId !== '')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
musikState.musik.edit.reset();
|
||||||
|
setPreviewCover(null);
|
||||||
|
setCoverFile(null);
|
||||||
|
setPreviewAudio(null);
|
||||||
|
setAudioFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!musikState.musik.edit.form.judul?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!musikState.musik.edit.form.artis?.trim()) {
|
||||||
|
toast.error('Artis wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!musikState.musik.edit.form.durasi?.trim()) {
|
||||||
|
toast.error('Durasi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Upload cover image if new file selected
|
||||||
|
if (coverFile) {
|
||||||
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: coverFile,
|
||||||
|
name: coverFile.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploaded = res.data?.data;
|
||||||
|
if (!uploaded?.id) {
|
||||||
|
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||||
|
}
|
||||||
|
|
||||||
|
musikState.musik.edit.form.coverImageId = uploaded.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload audio file if new file selected
|
||||||
|
if (audioFile) {
|
||||||
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: audioFile,
|
||||||
|
name: audioFile.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploaded = res.data?.data;
|
||||||
|
if (!uploaded?.id) {
|
||||||
|
return toast.error('Gagal mengunggah audio, silakan coba lagi');
|
||||||
|
}
|
||||||
|
|
||||||
|
musikState.musik.edit.form.audioFileId = uploaded.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await musikState.musik.edit.update();
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
router.push('/admin/musik');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating musik:', error);
|
||||||
|
toast.error('Terjadi kesalahan saat mengupdate musik');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xl">
|
||||||
|
<Center>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
|
{/* Header dengan tombol kembali */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Edit Musik
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Judul"
|
||||||
|
placeholder="Masukkan judul lagu"
|
||||||
|
value={musikState.musik.edit.form.judul}
|
||||||
|
onChange={(e) => (musikState.musik.edit.form.judul = e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Artis"
|
||||||
|
placeholder="Masukkan nama artis"
|
||||||
|
value={musikState.musik.edit.form.artis}
|
||||||
|
onChange={(e) => (musikState.musik.edit.form.artis = e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
|
Deskripsi
|
||||||
|
</Text>
|
||||||
|
<CreateEditor
|
||||||
|
value={musikState.musik.edit.form.deskripsi}
|
||||||
|
onChange={(htmlContent) => {
|
||||||
|
musikState.musik.edit.form.deskripsi = htmlContent;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Durasi"
|
||||||
|
placeholder="Contoh: 3:45"
|
||||||
|
value={musikState.musik.edit.form.durasi}
|
||||||
|
onChange={(e) => (musikState.musik.edit.form.durasi = e.target.value)}
|
||||||
|
required
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Genre"
|
||||||
|
placeholder="Contoh: Pop, Rock, Jazz"
|
||||||
|
value={musikState.musik.edit.form.genre}
|
||||||
|
onChange={(e) => (musikState.musik.edit.form.genre = e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Tahun Rilis"
|
||||||
|
placeholder="Contoh: 2024"
|
||||||
|
value={musikState.musik.edit.form.tahunRilis}
|
||||||
|
onChange={(val) => (musikState.musik.edit.form.tahunRilis = val as number | undefined)}
|
||||||
|
min={1900}
|
||||||
|
max={new Date().getFullYear() + 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Cover Image
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={(files) => {
|
||||||
|
const selectedFile = files[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
setCoverFile(selectedFile);
|
||||||
|
setPreviewCover(URL.createObjectURL(selectedFile));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||||
|
maxSize={5 * 1024 ** 2}
|
||||||
|
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
|
radius="md"
|
||||||
|
p="xl"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||||
|
Seret gambar atau klik untuk memilih file (maks 5MB)
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{(previewCover || musikState.musik.edit.form.coverImageId) && (
|
||||||
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
|
<Image
|
||||||
|
src={previewCover || '/api/placeholder/200/200'}
|
||||||
|
alt="Preview Cover"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: 200,
|
||||||
|
objectFit: 'contain',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewCover(null);
|
||||||
|
setCoverFile(null);
|
||||||
|
musikState.musik.edit.form.coverImageId = '';
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Audio File */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
File Audio
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={async (files) => {
|
||||||
|
const selectedFile = files[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
setAudioFile(selectedFile);
|
||||||
|
setPreviewAudio(selectedFile.name);
|
||||||
|
|
||||||
|
// Extract durasi otomatis dari audio
|
||||||
|
setIsExtractingDuration(true);
|
||||||
|
try {
|
||||||
|
const duration = await getAudioDuration(selectedFile);
|
||||||
|
musikState.musik.edit.form.durasi = duration;
|
||||||
|
toast.success(`Durasi audio terdeteksi: ${duration}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting audio duration:', error);
|
||||||
|
toast.error('Gagal mendeteksi durasi audio, silakan isi manual');
|
||||||
|
} finally {
|
||||||
|
setIsExtractingDuration(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')}
|
||||||
|
maxSize={50 * 1024 ** 2}
|
||||||
|
accept={{ 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a'] }}
|
||||||
|
radius="md"
|
||||||
|
p="xl"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconMusic size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||||
|
Seret file audio atau klik untuk memilih file (maks 50MB)
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{(previewAudio || musikState.musik.edit.form.audioFileId) && (
|
||||||
|
<Box mt="sm">
|
||||||
|
<Card p="sm" withBorder>
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconMusic size={20} color={colors['blue-button']} />
|
||||||
|
<Text fz="sm" truncate style={{ flex: 1 }}>
|
||||||
|
{previewAudio || 'File audio tersimpan'}
|
||||||
|
</Text>
|
||||||
|
{isExtractingDuration && (
|
||||||
|
<Text fz="xs" c="blue">
|
||||||
|
Mendeteksi durasi...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewAudio(null);
|
||||||
|
setAudioFile(null);
|
||||||
|
musikState.musik.edit.form.audioFileId = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader size="sm" color="white" /> : 'Update'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
src/app/admin/(dashboard)/musik/[id]/page.tsx
Normal file
271
src/app/admin/(dashboard)/musik/[id]/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
'use client'
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Modal,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import stateDashboardMusik from '../../_state/desa/musik';
|
||||||
|
|
||||||
|
export default function DetailMusik() {
|
||||||
|
const musikState = useProxy(stateDashboardMusik);
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const { data, loading, load } = musikState.musik.findUnique;
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
load(id);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
|
<Stack>
|
||||||
|
<Skeleton height={50} radius="md" />
|
||||||
|
<Skeleton height={400} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xl">
|
||||||
|
<Center>
|
||||||
|
<Text c="dimmed">Musik tidak ditemukan</Text>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await musikState.musik.delete.byId(id);
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
router.push('/admin/musik');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting musik:', error);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
|
{/* Header dengan tombol kembali */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.push('/admin/musik')}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Detail Musik
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Cover Image */}
|
||||||
|
{data.coverImage && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={data.coverImage.link}
|
||||||
|
alt={data.judul}
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '1/1',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Judul
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={600}>
|
||||||
|
{data.judul}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Artis
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={500}>
|
||||||
|
{data.artis}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{data.deskripsi && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Deskripsi
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} dangerouslySetInnerHTML={{ __html: data.deskripsi }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group gap="xl">
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Durasi
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={500}>
|
||||||
|
{data.durasi}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{data.genre && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Genre
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={500}>
|
||||||
|
{data.genre}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.tahunRilis && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
Tahun Rilis
|
||||||
|
</Text>
|
||||||
|
<Text fz="md" fw={500}>
|
||||||
|
{data.tahunRilis}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Audio File */}
|
||||||
|
{data.audioFile && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed">
|
||||||
|
File Audio
|
||||||
|
</Text>
|
||||||
|
<Card mt="xs" p="sm" withBorder>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text fz="sm" truncate style={{ flex: 1 }}>
|
||||||
|
{data.audioFile.realName}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={data.audioFile.link}
|
||||||
|
target="_blank"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Putar
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<Group justify="right" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<IconTrash size={18} />}
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<IconEdit size={18} />}
|
||||||
|
onClick={() => router.push(`/admin/musik/${id}/edit`)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={showDeleteModal}
|
||||||
|
onClose={() => setShowDeleteModal(false)}
|
||||||
|
title="Konfirmasi Hapus"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text>
|
||||||
|
Apakah Anda yakin ingin menghapus musik "{data.judul}"?
|
||||||
|
</Text>
|
||||||
|
<Text c="red" fz="sm">
|
||||||
|
Tindakan ini tidak dapat dibatalkan.
|
||||||
|
</Text>
|
||||||
|
<Group justify="right" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={handleDelete}
|
||||||
|
loading={isDeleting}
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
426
src/app/admin/(dashboard)/musik/create/page.tsx
Normal file
426
src/app/admin/(dashboard)/musik/create/page.tsx
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
'use client'
|
||||||
|
import CreateEditor from '../../_com/createEditor';
|
||||||
|
import stateDashboardMusik from '../../_state/desa/musik';
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import ApiFetch from '@/lib/api-fetch';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
Loader,
|
||||||
|
ActionIcon,
|
||||||
|
NumberInput
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconMusic } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
|
||||||
|
export default function CreateMusik() {
|
||||||
|
const musikState = useProxy(stateDashboardMusik);
|
||||||
|
const [previewCover, setPreviewCover] = useState<string | null>(null);
|
||||||
|
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||||
|
const [previewAudio, setPreviewAudio] = useState<string | null>(null);
|
||||||
|
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||||
|
const [isExtractingDuration, setIsExtractingDuration] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Fungsi untuk mendapatkan durasi dari file audio
|
||||||
|
const getAudioDuration = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
const duration = audio.duration;
|
||||||
|
const minutes = Math.floor(duration / 60);
|
||||||
|
const seconds = Math.floor(duration % 60);
|
||||||
|
const formatted = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(formatted);
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve('0:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
musikState.musik.create.form.judul?.trim() !== '' &&
|
||||||
|
musikState.musik.create.form.artis?.trim() !== '' &&
|
||||||
|
musikState.musik.create.form.durasi?.trim() !== '' &&
|
||||||
|
audioFile !== null &&
|
||||||
|
coverFile !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
return () => {
|
||||||
|
musikState.musik.create.resetForm();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
musikState.musik.create.form = {
|
||||||
|
judul: '',
|
||||||
|
artis: '',
|
||||||
|
deskripsi: '',
|
||||||
|
durasi: '',
|
||||||
|
audioFileId: '',
|
||||||
|
coverImageId: '',
|
||||||
|
genre: '',
|
||||||
|
tahunRilis: undefined,
|
||||||
|
};
|
||||||
|
setPreviewCover(null);
|
||||||
|
setCoverFile(null);
|
||||||
|
setPreviewAudio(null);
|
||||||
|
setAudioFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!musikState.musik.create.form.judul?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!musikState.musik.create.form.artis?.trim()) {
|
||||||
|
toast.error('Artis wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!musikState.musik.create.form.durasi?.trim()) {
|
||||||
|
toast.error('Durasi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coverFile) {
|
||||||
|
toast.error('Cover image wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioFile) {
|
||||||
|
toast.error('File audio wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Upload cover image
|
||||||
|
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: coverFile,
|
||||||
|
name: coverFile.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const coverUploaded = coverRes.data?.data;
|
||||||
|
if (!coverUploaded?.id) {
|
||||||
|
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||||
|
}
|
||||||
|
|
||||||
|
musikState.musik.create.form.coverImageId = coverUploaded.id;
|
||||||
|
|
||||||
|
// Upload audio file
|
||||||
|
const audioRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: audioFile,
|
||||||
|
name: audioFile.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioUploaded = audioRes.data?.data;
|
||||||
|
if (!audioUploaded?.id) {
|
||||||
|
return toast.error('Gagal mengunggah audio, silakan coba lagi');
|
||||||
|
}
|
||||||
|
|
||||||
|
musikState.musik.create.form.audioFileId = audioUploaded.id;
|
||||||
|
|
||||||
|
await musikState.musik.create.create();
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
router.push('/admin/musik');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating musik:', error);
|
||||||
|
toast.error('Terjadi kesalahan saat membuat musik');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
|
{/* Header dengan tombol kembali */}
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
p="xs"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
|
</Button>
|
||||||
|
<Title order={4} ml="sm" c="dark">
|
||||||
|
Tambah Musik
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Judul"
|
||||||
|
placeholder="Masukkan judul lagu"
|
||||||
|
value={musikState.musik.create.form.judul}
|
||||||
|
onChange={(e) => (musikState.musik.create.form.judul = e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Artis"
|
||||||
|
placeholder="Masukkan nama artis"
|
||||||
|
value={musikState.musik.create.form.artis}
|
||||||
|
onChange={(e) => (musikState.musik.create.form.artis = e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
|
Deskripsi
|
||||||
|
</Text>
|
||||||
|
<CreateEditor
|
||||||
|
value={musikState.musik.create.form.deskripsi}
|
||||||
|
onChange={(htmlContent) => {
|
||||||
|
musikState.musik.create.form.deskripsi = htmlContent;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Durasi"
|
||||||
|
placeholder="Contoh: 3:45"
|
||||||
|
value={musikState.musik.create.form.durasi}
|
||||||
|
onChange={(e) => (musikState.musik.create.form.durasi = e.target.value)}
|
||||||
|
required
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Genre"
|
||||||
|
placeholder="Contoh: Pop, Rock, Jazz"
|
||||||
|
value={musikState.musik.create.form.genre}
|
||||||
|
onChange={(e) => (musikState.musik.create.form.genre = e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Tahun Rilis"
|
||||||
|
placeholder="Contoh: 2024"
|
||||||
|
value={musikState.musik.create.form.tahunRilis}
|
||||||
|
onChange={(val) => (musikState.musik.create.form.tahunRilis = val as number | undefined)}
|
||||||
|
min={1900}
|
||||||
|
max={new Date().getFullYear() + 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Cover Image
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={(files) => {
|
||||||
|
const selectedFile = files[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
setCoverFile(selectedFile);
|
||||||
|
setPreviewCover(URL.createObjectURL(selectedFile));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||||
|
maxSize={5 * 1024 ** 2}
|
||||||
|
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
|
radius="md"
|
||||||
|
p="xl"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||||
|
Seret gambar atau klik untuk memilih file (maks 5MB)
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{previewCover && (
|
||||||
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
|
<Image
|
||||||
|
src={previewCover}
|
||||||
|
alt="Preview Cover"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: 200,
|
||||||
|
objectFit: 'contain',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewCover(null);
|
||||||
|
setCoverFile(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Audio File */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
File Audio
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={async (files) => {
|
||||||
|
const selectedFile = files[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
setAudioFile(selectedFile);
|
||||||
|
setPreviewAudio(selectedFile.name);
|
||||||
|
|
||||||
|
// Extract durasi otomatis dari audio
|
||||||
|
setIsExtractingDuration(true);
|
||||||
|
try {
|
||||||
|
const duration = await getAudioDuration(selectedFile);
|
||||||
|
musikState.musik.create.form.durasi = duration;
|
||||||
|
toast.success(`Durasi audio terdeteksi: ${duration}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting audio duration:', error);
|
||||||
|
toast.error('Gagal mendeteksi durasi audio, silakan isi manual');
|
||||||
|
} finally {
|
||||||
|
setIsExtractingDuration(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')}
|
||||||
|
maxSize={50 * 1024 ** 2}
|
||||||
|
accept={{ 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a'] }}
|
||||||
|
radius="md"
|
||||||
|
p="xl"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconMusic size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||||
|
Seret file audio atau klik untuk memilih file (maks 50MB)
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{previewAudio && (
|
||||||
|
<Box mt="sm">
|
||||||
|
<Card p="sm" withBorder>
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconMusic size={20} color={colors['blue-button']} />
|
||||||
|
<Text fz="sm" truncate style={{ flex: 1 }}>
|
||||||
|
{previewAudio}
|
||||||
|
</Text>
|
||||||
|
{isExtractingDuration && (
|
||||||
|
<Text fz="xs" c="blue">
|
||||||
|
Mendeteksi durasi...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewAudio(null);
|
||||||
|
setAudioFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
|
style={{
|
||||||
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
src/app/admin/(dashboard)/musik/page.tsx
Normal file
231
src/app/admin/(dashboard)/musik/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use client'
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
|
Text,
|
||||||
|
Title
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import HeaderSearch from '../_com/header';
|
||||||
|
import stateDashboardMusik from '../_state/desa/musik';
|
||||||
|
|
||||||
|
|
||||||
|
function Musik() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<HeaderSearch
|
||||||
|
title="Musik Desa"
|
||||||
|
placeholder="Cari judul, artis, atau genre..."
|
||||||
|
searchIcon={<IconSearch size={20} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<ListMusik search={search} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListMusik({ search }: { search: string }) {
|
||||||
|
const musikState = useProxy(stateDashboardMusik);
|
||||||
|
const router = useRouter();
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||||
|
|
||||||
|
const { data, page, totalPages, loading, load } = musikState.musik.findMany;
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 10, debouncedSearch);
|
||||||
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<Stack py="md">
|
||||||
|
<Skeleton height={600} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredData = data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py="md">
|
||||||
|
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Title order={4}>Daftar Musik</Title>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconCircleDashedPlus size={18} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => router.push('/admin/musik/create')}
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
|
||||||
|
<Table highlightOnHover
|
||||||
|
layout="fixed"
|
||||||
|
withColumnBorders={false} miw={0}>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh w="30%">Judul</TableTh>
|
||||||
|
<TableTh w="20%">Artis</TableTh>
|
||||||
|
<TableTh w="15%">Durasi</TableTh>
|
||||||
|
<TableTh w="15%">Genre</TableTh>
|
||||||
|
<TableTh w="20%">Aksi</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item) => (
|
||||||
|
<TableTr key={item.id}>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="md" fw={600} lh={1.45} truncate="end">
|
||||||
|
{item.judul}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||||
|
{item.artis}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||||
|
{item.durasi}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||||
|
{item.genre || '-'}
|
||||||
|
</Text>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/musik/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="sm"
|
||||||
|
px="sm"
|
||||||
|
h={36}
|
||||||
|
>
|
||||||
|
<IconDeviceImacCog size={18} />
|
||||||
|
<Text ml="xs">Detail</Text>
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableTr>
|
||||||
|
<TableTd colSpan={5}>
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data musik yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
)}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile Cards */}
|
||||||
|
<Stack hiddenFrom="md" gap="sm" mt="sm">
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item) => (
|
||||||
|
<Paper key={item.id} withBorder p="md" radius="md">
|
||||||
|
<Stack gap={"xs"}>
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed">
|
||||||
|
Judul
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500} lh={1.45}>
|
||||||
|
{item.judul}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||||
|
Artis
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" lh={1.45} fw={500}>
|
||||||
|
{item.artis}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||||
|
Durasi
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" lh={1.45} fw={500}>
|
||||||
|
{item.durasi}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||||
|
Genre
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" lh={1.45} fw={500}>
|
||||||
|
{item.genre || '-'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/admin/musik/${item.id}`)
|
||||||
|
}
|
||||||
|
fz="sm"
|
||||||
|
h={36}
|
||||||
|
>
|
||||||
|
<IconDeviceImacCog size={18} />
|
||||||
|
<Text ml="xs">Detail</Text>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
|
Tidak ada data musik yang cocok
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Center>
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => {
|
||||||
|
load(newPage, 10, debouncedSearch);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
total={totalPages}
|
||||||
|
mt="md"
|
||||||
|
mb="md"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Musik;
|
||||||
@@ -24,6 +24,21 @@ function EditProgramKreatifDesa() {
|
|||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProgramKreatif = async () => {
|
const loadProgramKreatif = async () => {
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
@@ -160,8 +175,11 @@ function EditProgramKreatifDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ function CreateKeunggulanProgram() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateCreate.create.form.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateCreate.create.form.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateCreate.create.form = {
|
stateCreate.create.form = {
|
||||||
judul: "",
|
judul: "",
|
||||||
@@ -97,8 +112,11 @@ function CreateKeunggulanProgram() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ function EditFasilitasYangDisediakan() {
|
|||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data pertama kali
|
// Load data pertama kali
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!editState.findById.data) {
|
if (!editState.findById.data) {
|
||||||
@@ -76,11 +91,6 @@ function EditFasilitasYangDisediakan() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul.trim()) {
|
|
||||||
toast.error('Judul wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (editState.findById.data) {
|
if (editState.findById.data) {
|
||||||
@@ -180,8 +190,11 @@ function EditFasilitasYangDisediakan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ function EditLokasiDanJadwal() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
|
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data sekali
|
// Load data sekali
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!editState.findById.data) {
|
if (!editState.findById.data) {
|
||||||
@@ -73,11 +88,6 @@ function EditLokasiDanJadwal() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul.trim()) {
|
|
||||||
toast.error('Judul wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (editState.findById.data) {
|
if (editState.findById.data) {
|
||||||
@@ -178,8 +188,11 @@ function EditLokasiDanJadwal() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ function EditTujuanProgram() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
|
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// load data sekali
|
// load data sekali
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!editState.findById.data) editState.findById.initialize();
|
if (!editState.findById.data) editState.findById.initialize();
|
||||||
@@ -71,11 +86,6 @@ function EditTujuanProgram() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul.trim()) {
|
|
||||||
toast.error('Judul wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (editState.findById.data) {
|
if (editState.findById.data) {
|
||||||
@@ -170,8 +180,11 @@ function EditTujuanProgram() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export default function EditDataPendidikan() {
|
|||||||
jumlah: '',
|
jumlah: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.jumlah?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data saat mount
|
// Load data saat mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -127,8 +135,11 @@ export default function EditDataPendidikan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export default function CreateDataPendidikan() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateDPM.create.form.name?.trim() !== '' &&
|
||||||
|
stateDPM.create.form.jumlah?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateDPM.create.form = { name: '', jumlah: '' };
|
stateDPM.create.form = { name: '', jumlah: '' };
|
||||||
};
|
};
|
||||||
@@ -90,8 +98,11 @@ export default function CreateDataPendidikan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ function EditJenjangPendidikan() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return formData.nama?.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
// Load data sekali saat component mount
|
// Load data sekali saat component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -136,8 +141,11 @@ function EditJenjangPendidikan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ function CreateJenjangPendidikan() {
|
|||||||
const stateJenjang = useProxy(infoSekolahPaud.jenjangPendidikan);
|
const stateJenjang = useProxy(infoSekolahPaud.jenjangPendidikan);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return stateJenjang.create.form.nama?.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateJenjang.findMany.load();
|
stateJenjang.findMany.load();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -101,8 +106,11 @@ function CreateJenjangPendidikan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ export default function EditLembaga() {
|
|||||||
jenjangId: '',
|
jenjangId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
form.nama?.trim() !== '' &&
|
||||||
|
form.jenjangId?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load jenjang pendidikan dan data lembaga
|
// Load jenjang pendidikan dan data lembaga
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
infoSekolahPaud.jenjangPendidikan.findMany.load();
|
infoSekolahPaud.jenjangPendidikan.findMany.load();
|
||||||
@@ -161,8 +169,11 @@ export default function EditLembaga() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ function CreateLembaga() {
|
|||||||
const stateLembaga = useProxy(infoSekolahPaud.lembagaPendidikan);
|
const stateLembaga = useProxy(infoSekolahPaud.lembagaPendidikan);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateLembaga.create.form.nama?.trim() !== '' &&
|
||||||
|
stateLembaga.create.form.jenjangId?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateLembaga.findMany.load();
|
stateLembaga.findMany.load();
|
||||||
infoSekolahPaud.jenjangPendidikan.findMany.load();
|
infoSekolahPaud.jenjangPendidikan.findMany.load();
|
||||||
@@ -116,8 +124,11 @@ function CreateLembaga() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ function EditPengajar() {
|
|||||||
lembagaId: ''
|
lembagaId: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.nama?.trim() !== '' &&
|
||||||
|
formData.lembagaId?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
@@ -157,8 +165,11 @@ function EditPengajar() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ function CreatePengajar() {
|
|||||||
const stateCreate = useProxy(infoSekolahPaud.pengajar);
|
const stateCreate = useProxy(infoSekolahPaud.pengajar);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateCreate.create.form.nama?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.lembagaId?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateCreate.findMany.load();
|
stateCreate.findMany.load();
|
||||||
infoSekolahPaud.lembagaPendidikan.findMany.load();
|
infoSekolahPaud.lembagaPendidikan.findMany.load();
|
||||||
@@ -116,8 +124,11 @@ function CreatePengajar() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ function EditSiswa() {
|
|||||||
lembagaId: '',
|
lembagaId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.nama?.trim() !== '' &&
|
||||||
|
formData.lembagaId?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data siswa
|
// Load data siswa
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSiswa = async () => {
|
const loadSiswa = async () => {
|
||||||
@@ -166,8 +174,11 @@ function EditSiswa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ function CreateSiswa() {
|
|||||||
const stateCreate = useProxy(infoSekolahPaud.siswa);
|
const stateCreate = useProxy(infoSekolahPaud.siswa);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateCreate.create.form.nama?.trim() !== '' &&
|
||||||
|
stateCreate.create.form.lembagaId?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateCreate.findMany.load();
|
stateCreate.findMany.load();
|
||||||
infoSekolahPaud.lembagaPendidikan.findMany.load();
|
infoSekolahPaud.lembagaPendidikan.findMany.load();
|
||||||
@@ -115,8 +123,11 @@ function CreateSiswa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ function EditJenisProgramYangDiselenggarakan() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [originalData, setOriginalData] = useState({ judul: '', content: '' });
|
const [originalData, setOriginalData] = useState({ judul: '', content: '' });
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.content)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data pertama kali
|
// Load data pertama kali
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!editState.findById.data) {
|
if (!editState.findById.data) {
|
||||||
@@ -71,11 +86,6 @@ function EditJenisProgramYangDiselenggarakan() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul.trim()) {
|
|
||||||
toast.error('Judul wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (editState.findById.data) {
|
if (editState.findById.data) {
|
||||||
@@ -168,8 +178,11 @@ function EditJenisProgramYangDiselenggarakan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ function EditTempatKegiatan() {
|
|||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// load data pertama kali
|
// load data pertama kali
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!editState.findById.data) {
|
if (!editState.findById.data) {
|
||||||
@@ -79,11 +94,6 @@ function EditTempatKegiatan() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul.trim()) {
|
|
||||||
toast.error('Judul wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (editState.findById.data) {
|
if (editState.findById.data) {
|
||||||
@@ -177,8 +187,11 @@ function EditTempatKegiatan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ function EditTujuanProgram() {
|
|||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data pertama kali
|
// Load data pertama kali
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!editState.findById.data) {
|
if (!editState.findById.data) {
|
||||||
@@ -72,11 +87,6 @@ function EditTujuanProgram() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul.trim()) {
|
|
||||||
toast.error('Judul wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editState.findById.data) return;
|
if (!editState.findById.data) return;
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
@@ -163,8 +173,11 @@ function EditTujuanProgram() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ function EditPerpustakaanDigital() {
|
|||||||
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);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
formData.kategoriId?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load kategori & data awal
|
// Load kategori & data awal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
perpustakaanDigitalState.kategoriBuku.findManyAll.load();
|
perpustakaanDigitalState.kategoriBuku.findManyAll.load();
|
||||||
@@ -254,8 +270,11 @@ function EditPerpustakaanDigital() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ function CreateDataPerpustakaan() {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
createState.create.form.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(createState.create.form.deskripsi) &&
|
||||||
|
createState.create.form.kategoriId?.trim() !== '' &&
|
||||||
|
file !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
perpustakaanDigitalState.kategoriBuku.findManyAll.load();
|
perpustakaanDigitalState.kategoriBuku.findManyAll.load();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -196,8 +213,11 @@ function CreateDataPerpustakaan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ function EditKategoriBuku() {
|
|||||||
const [formData, setFormData] = useState({ name: '' });
|
const [formData, setFormData] = useState({ name: '' });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return formData.name?.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadKategori = async () => {
|
const loadKategori = async () => {
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
@@ -120,8 +125,11 @@ function EditKategoriBuku() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ function CreateKategoriBuku() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return createState.create.form.name?.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
createState.create.form = {
|
createState.create.form = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -81,8 +86,11 @@ function CreateKategoriBuku() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -70,6 +70,26 @@ function EditPeminjam() {
|
|||||||
catatan: "",
|
catatan: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.nama?.trim() !== '' &&
|
||||||
|
formData.noTelp?.trim() !== '' &&
|
||||||
|
formData.alamat?.trim() !== '' &&
|
||||||
|
formData.bukuId?.trim() !== '' &&
|
||||||
|
formData.tanggalPinjam?.trim() !== '' &&
|
||||||
|
formData.status?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.catatan)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
perpustakaanDigitalState.dataPerpustakaan.findManyAll.load()
|
perpustakaanDigitalState.dataPerpustakaan.findManyAll.load()
|
||||||
})
|
})
|
||||||
@@ -296,8 +316,11 @@ function EditPeminjam() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -50,6 +50,21 @@ function EditTujuanProgram() {
|
|||||||
});
|
});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// load data once
|
// load data once
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!editState.findById.data) editState.findById.initialize();
|
if (!editState.findById.data) editState.findById.initialize();
|
||||||
@@ -85,11 +100,6 @@ function EditTujuanProgram() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul.trim()) {
|
|
||||||
toast.error('Judul wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (editState.findById.data) {
|
if (editState.findById.data) {
|
||||||
@@ -186,8 +196,11 @@ function EditTujuanProgram() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ function EditTujuanProgram() {
|
|||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data pertama kali
|
// Load data pertama kali
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
if (!editState.findById.data) {
|
if (!editState.findById.data) {
|
||||||
@@ -72,11 +87,6 @@ function EditTujuanProgram() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.judul.trim()) {
|
|
||||||
toast.error('Judul wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (editState.findById.data) {
|
if (editState.findById.data) {
|
||||||
@@ -166,8 +176,11 @@ function EditTujuanProgram() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export const devBar = [
|
|||||||
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "Pendidikan",
|
id: "Pendidikan",
|
||||||
name: "Pendidikan",
|
name: "Pendidikan",
|
||||||
@@ -373,6 +373,11 @@ export const devBar = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "Musik",
|
||||||
|
name: "Musik",
|
||||||
|
path: "/admin/musik"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "User & Role",
|
id: "User & Role",
|
||||||
name: "User & Role",
|
name: "User & Role",
|
||||||
@@ -729,7 +734,7 @@ export const navBar = [
|
|||||||
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "Pendidikan",
|
id: "Pendidikan",
|
||||||
name: "Pendidikan",
|
name: "Pendidikan",
|
||||||
@@ -772,6 +777,11 @@ export const navBar = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "Musik",
|
||||||
|
name: "Musik",
|
||||||
|
path: "/admin/musik"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "User & Role",
|
id: "User & Role",
|
||||||
name: "User & Role",
|
name: "User & Role",
|
||||||
@@ -1051,7 +1061,7 @@ export const role1 = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "Lingkungan",
|
id: "Lingkungan",
|
||||||
name: "Lingkungan",
|
name: "Lingkungan",
|
||||||
@@ -1088,6 +1098,11 @@ export const role1 = [
|
|||||||
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Musik",
|
||||||
|
name: "Musik",
|
||||||
|
path: "/admin/musik"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1133,6 +1148,11 @@ export const role2 = [
|
|||||||
path: "/admin/kesehatan/info-wabah-penyakit"
|
path: "/admin/kesehatan/info-wabah-penyakit"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Musik",
|
||||||
|
name: "Musik",
|
||||||
|
path: "/admin/musik"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1178,5 +1198,10 @@ export const role3 = [
|
|||||||
path: "/admin/pendidikan/data-pendidikan"
|
path: "/admin/pendidikan/data-pendidikan"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Musik",
|
||||||
|
name: "Musik",
|
||||||
|
path: "/admin/musik"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import colors from "@/con/colors";
|
import { DarkModeToggle } from "@/components/admin/DarkModeToggle";
|
||||||
|
import { useDarkMode } from "@/state/darkModeStore";
|
||||||
import { authStore } from "@/store/authStore";
|
import { authStore } from "@/store/authStore";
|
||||||
|
import { themeTokens } from "@/utils/themeTokens";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
AppShell,
|
AppShell,
|
||||||
@@ -33,13 +35,21 @@ import { useEffect, useState } from "react";
|
|||||||
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
|
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [opened, { toggle, close }] = useDisclosure(); // ✅ Tambahkan 'close'
|
const { isDark } = useDarkMode();
|
||||||
|
const tokens = themeTokens(isDark);
|
||||||
|
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [opened, { toggle, close }] = useDisclosure();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
|
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
|
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
|
||||||
|
|
||||||
|
// Ensure component is mounted on client side
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
@@ -74,7 +84,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
if (currentPath === '/admin') {
|
if (currentPath === '/admin') {
|
||||||
const expectedPath = getRedirectPath(Number(data.user.roleId));
|
const expectedPath = getRedirectPath(Number(data.user.roleId));
|
||||||
console.log('🔄 Redirecting from /admin to:', expectedPath);
|
console.log('🔄 Redirecting from /admin to:', expectedPath);
|
||||||
@@ -112,11 +122,11 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading || !mounted) {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<AppShellMain>
|
<AppShellMain>
|
||||||
<Center h="100vh">
|
<Center h="100vh" bg="#f6f9fc">
|
||||||
<Loader />
|
<Loader />
|
||||||
</Center>
|
</Center>
|
||||||
</AppShellMain>
|
</AppShellMain>
|
||||||
@@ -132,7 +142,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
|
|
||||||
const response = await fetch('/api/auth/logout', {
|
const response = await fetch('/api/auth/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
@@ -158,10 +168,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ Handler untuk menutup mobile menu saat navigasi
|
|
||||||
const handleNavClick = (path: string) => {
|
const handleNavClick = (path: string) => {
|
||||||
router.push(path);
|
router.push(path);
|
||||||
close(); // Tutup mobile menu
|
close();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -178,11 +187,16 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
padding="md"
|
padding="md"
|
||||||
>
|
>
|
||||||
|
{/*
|
||||||
|
HEADER / TOPBAR
|
||||||
|
Spec: Background gradient, border bawah wajib
|
||||||
|
*/}
|
||||||
<AppShellHeader
|
<AppShellHeader
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(90deg, #ffffff, #f9fbff)",
|
background: mounted ? tokens.colors.bg.header : 'linear-gradient(90deg, #ffffff, #f9fbff)',
|
||||||
borderBottom: `1px solid ${colors["blue-button"]}20`,
|
borderBottom: `1px solid ${mounted ? tokens.colors.border.soft : '#e9ecef'}`,
|
||||||
padding: '0 16px',
|
padding: '0 16px',
|
||||||
|
transition: 'background 0.3s ease, border-color 0.3s ease',
|
||||||
}}
|
}}
|
||||||
px={{ base: 'sm', sm: 'md' }}
|
px={{ base: 'sm', sm: 'md' }}
|
||||||
py={{ base: 'xs', sm: 'sm' }}
|
py={{ base: 'xs', sm: 'sm' }}
|
||||||
@@ -198,30 +212,49 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
style={{ minWidth: '32px', height: 'auto' }}
|
style={{ minWidth: '32px', height: 'auto' }}
|
||||||
/>
|
/>
|
||||||
<Text fw={700} c={colors["blue-button"]} fz={{ base: 'md', sm: 'xl' }}>
|
<Text fw={700} c={mounted ? tokens.colors.text.brand : '#0A4E78'} fz={{ base: 'md', sm: 'xl' }}>
|
||||||
Admin Darmasaba
|
Admin Darmasaba
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
{/* Dark Mode Toggle */}
|
||||||
|
<DarkModeToggle variant="light" size="lg" showTooltip tooltipPosition="bottom" />
|
||||||
|
|
||||||
{!desktopOpened && (
|
{!desktopOpened && (
|
||||||
<Tooltip label="Buka Navigasi" position="bottom" withArrow>
|
<Tooltip label="Buka Navigasi" position="bottom" withArrow>
|
||||||
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
|
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={mounted ? tokens.colors.primary : '#3B82F6'}>
|
||||||
<IconChevronRight />
|
<IconChevronRight />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={colors["blue-button"]} mr="xs" />
|
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={mounted ? tokens.colors.text.brand : '#0A4E78'} mr="xs" />
|
||||||
|
|
||||||
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
|
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
|
||||||
<ActionIcon onClick={() => router.push("/darmasaba")} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }}>
|
<ActionIcon
|
||||||
|
onClick={() => router.push("/darmasaba")}
|
||||||
|
color={mounted ? tokens.colors.primary : '#3B82F6'}
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={mounted ? tokens.colors.gradient : { from: '#3B82F6', to: '#60A5FA' }}
|
||||||
|
>
|
||||||
<Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} />
|
<Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label="Keluar" position="bottom" withArrow>
|
<Tooltip label="Keluar" position="bottom" withArrow>
|
||||||
<ActionIcon onClick={handleLogout} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }} loading={isLoggingOut} disabled={isLoggingOut}>
|
<ActionIcon
|
||||||
|
onClick={handleLogout}
|
||||||
|
color={mounted ? tokens.colors.primary : '#3B82F6'}
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={mounted ? tokens.colors.gradient : { from: '#3B82F6', to: '#60A5FA' }}
|
||||||
|
loading={isLoggingOut}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
>
|
||||||
<IconLogout2 size={22} />
|
<IconLogout2 size={22} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -229,47 +262,110 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</Group>
|
</Group>
|
||||||
</AppShellHeader>
|
</AppShellHeader>
|
||||||
|
|
||||||
<AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}>
|
{/*
|
||||||
|
SIDEBAR / NAVBAR
|
||||||
|
Spec: Background --bg-app, active state dengan accent bar
|
||||||
|
*/}
|
||||||
|
<AppShellNavbar
|
||||||
|
component={ScrollArea}
|
||||||
|
style={{
|
||||||
|
background: mounted ? tokens.colors.bg.app : '#ffffff',
|
||||||
|
borderRight: `1px solid ${mounted ? tokens.colors.border.soft : '#e9ecef'}`,
|
||||||
|
transition: 'background 0.3s ease, border-color 0.3s ease',
|
||||||
|
}}
|
||||||
|
p={{ base: 'xs', sm: 'sm' }}
|
||||||
|
>
|
||||||
<AppShell.Section p="sm">
|
<AppShell.Section p="sm">
|
||||||
{currentNav.map((v, k) => {
|
{currentNav.map((v, k) => {
|
||||||
const isParentActive = segments.includes(_.lowerCase(v.name));
|
const isParentActive = segments.includes(_.lowerCase(v.name));
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={k}
|
key={k}
|
||||||
defaultOpened={isParentActive}
|
defaultOpened={isParentActive}
|
||||||
c={isParentActive ? colors["blue-button"] : "gray"}
|
c={mounted && isParentActive ? tokens.colors.primary : mounted && isDark ? '#E5E7EB' : tokens.colors.text.secondary}
|
||||||
label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>}
|
label={
|
||||||
style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }}
|
<Text
|
||||||
styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }}
|
fw={isParentActive ? 600 : 400}
|
||||||
variant="light"
|
fz="sm"
|
||||||
|
style={{
|
||||||
|
color: mounted && isDark ? '#E5E7EB' : 'inherit',
|
||||||
|
transition: 'color 150ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderRadius: rem(10),
|
||||||
|
marginBottom: rem(4),
|
||||||
|
transition: "background 150ms ease",
|
||||||
|
...(mounted && isParentActive && !isDark && {
|
||||||
|
borderLeft: `3px solid ${tokens.colors.primary}`,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: mounted && isDark ? '#1E293B' : tokens.colors.bg.hover,
|
||||||
|
},
|
||||||
|
...(mounted && isParentActive && isDark && {
|
||||||
|
backgroundColor: 'rgba(59,130,246,0.25)',
|
||||||
|
borderLeft: `3px solid ${tokens.colors.primary}`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="light"
|
||||||
active={isParentActive}
|
active={isParentActive}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (v.path) handleNavClick(v.path);
|
||||||
|
}}
|
||||||
|
href={v.path || undefined}
|
||||||
>
|
>
|
||||||
{v.children.map((child, key) => {
|
{v.children?.map((child, key) => {
|
||||||
const isChildActive = segments.includes(_.lowerCase(child.name));
|
const isChildActive = segments.includes(_.lowerCase(child.name));
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={key}
|
key={key}
|
||||||
// ✅ PERBAIKAN: Gunakan onClick untuk handle navigasi dan close menu
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleNavClick(child.path);
|
handleNavClick(child.path);
|
||||||
}}
|
}}
|
||||||
href={child.path}
|
href={child.path}
|
||||||
c={isChildActive ? colors["blue-button"] : "gray"}
|
c={mounted && isChildActive ? tokens.colors.primary : mounted && isDark ? '#E5E7EB' : tokens.colors.text.secondary}
|
||||||
label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>}
|
label={
|
||||||
styles={{
|
<Text
|
||||||
root: {
|
fw={isChildActive ? 600 : 400}
|
||||||
borderRadius: rem(8),
|
fz="sm"
|
||||||
marginBottom: rem(2),
|
style={{
|
||||||
transition: 'background 150ms ease',
|
color: mounted && isDark ? '#E5E7EB' : 'inherit',
|
||||||
padding: '6px 12px',
|
transition: 'color 150ms ease',
|
||||||
'&:hover': {
|
}}
|
||||||
backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)'
|
>
|
||||||
},
|
{child.name}
|
||||||
...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' })
|
</Text>
|
||||||
}
|
}
|
||||||
}}
|
styles={{
|
||||||
active={isChildActive}
|
root: {
|
||||||
|
borderRadius: rem(8),
|
||||||
|
marginBottom: rem(2),
|
||||||
|
transition: 'background 150ms ease',
|
||||||
|
padding: '6px 12px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: mounted && isDark ? 'rgba(255, 255, 255, 0.05)' : tokens.colors.bg.hover,
|
||||||
|
},
|
||||||
|
...(mounted && isChildActive && isDark && {
|
||||||
|
backgroundColor: 'rgba(59,130,246,0.15)',
|
||||||
|
borderLeft: `2px solid ${tokens.colors.primary}`,
|
||||||
|
}),
|
||||||
|
...(mounted && isChildActive && !isDark && {
|
||||||
|
backgroundColor: 'rgba(25, 113, 194, 0.1)',
|
||||||
|
borderLeft: `2px solid ${tokens.colors.primary}`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
active={isChildActive}
|
||||||
|
variant="subtle"
|
||||||
component={Link}
|
component={Link}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -282,7 +378,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<AppShell.Section py="md">
|
<AppShell.Section py="md">
|
||||||
<Group justify="end" pr="sm">
|
<Group justify="end" pr="sm">
|
||||||
<Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow>
|
<Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow>
|
||||||
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
|
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={mounted ? tokens.colors.primary : '#3B82F6'}>
|
||||||
<IconChevronLeft />
|
<IconChevronLeft />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -290,7 +386,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
</AppShellNavbar>
|
</AppShellNavbar>
|
||||||
|
|
||||||
<AppShellMain style={{ background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)", minHeight: "100vh" }}>
|
{/*
|
||||||
|
MAIN CONTENT
|
||||||
|
Spec: Background --bg-base
|
||||||
|
*/}
|
||||||
|
<AppShellMain
|
||||||
|
style={{
|
||||||
|
background: mounted ? tokens.colors.bg.base : '#f6f9fc',
|
||||||
|
minHeight: "100vh",
|
||||||
|
transition: 'background 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AppShellMain>
|
</AppShellMain>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -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()),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,49 @@ import prisma from "@/lib/prisma";
|
|||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
export default async function kategoriBeritaDelete(context: Context) {
|
export default async function kategoriBeritaDelete(context: Context) {
|
||||||
const id = context.params.id as string;
|
try {
|
||||||
|
const id = context.params?.id as string;
|
||||||
|
|
||||||
await prisma.kategoriBerita.delete({
|
if (!id) {
|
||||||
where: { id },
|
return Response.json({
|
||||||
});
|
success: false,
|
||||||
|
message: "ID tidak boleh kosong",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// ✅ Cek apakah kategori masih digunakan oleh berita
|
||||||
status: 200,
|
const beritaCount = await prisma.berita.count({
|
||||||
success: true,
|
where: {
|
||||||
message: "Sukses Menghapus kategori berita",
|
kategoriBeritaId: id,
|
||||||
};
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (beritaCount > 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: `Kategori tidak dapat dihapus karena masih digunakan oleh ${beritaCount} berita`,
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Soft delete (bukan hard delete)
|
||||||
|
await prisma.kategoriBerita.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
deletedAt: new Date(),
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Kategori berita berhasil dihapus",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete kategori error:", error);
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Elysia from "elysia";
|
|||||||
import Berita from "./berita";
|
import Berita from "./berita";
|
||||||
import Pengumuman from "./pengumuman";
|
import Pengumuman from "./pengumuman";
|
||||||
import ProfileDesa from "./profile/profile_desa";
|
import ProfileDesa from "./profile/profile_desa";
|
||||||
import PotensiDesa from "./potensi";
|
import PotensiDesa from "./potensi";
|
||||||
import GalleryFoto from "./gallery/foto";
|
import GalleryFoto from "./gallery/foto";
|
||||||
import GalleryVideo from "./gallery/video";
|
import GalleryVideo from "./gallery/video";
|
||||||
import LayananDesa from "./layanan";
|
import LayananDesa from "./layanan";
|
||||||
@@ -12,6 +12,7 @@ import KategoriBerita from "./berita/kategori-berita";
|
|||||||
import KategoriPengumuman from "./pengumuman/kategori-pengumuman";
|
import KategoriPengumuman from "./pengumuman/kategori-pengumuman";
|
||||||
import MantanPerbekel from "./profile/profile-mantan-perbekel";
|
import MantanPerbekel from "./profile/profile-mantan-perbekel";
|
||||||
import AjukanPermohonan from "./layanan/ajukan_permohonan";
|
import AjukanPermohonan from "./layanan/ajukan_permohonan";
|
||||||
|
import Musik from "./musik";
|
||||||
|
|
||||||
|
|
||||||
const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
|
const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
|
||||||
@@ -28,6 +29,7 @@ const Desa = new Elysia({ prefix: "/api/desa", tags: ["Desa"] })
|
|||||||
.use(KategoriBerita)
|
.use(KategoriBerita)
|
||||||
.use(KategoriPengumuman)
|
.use(KategoriPengumuman)
|
||||||
.use(AjukanPermohonan)
|
.use(AjukanPermohonan)
|
||||||
|
.use(Musik)
|
||||||
|
|
||||||
|
|
||||||
export default Desa;
|
export default Desa;
|
||||||
|
|||||||
37
src/app/api/[[...slugs]]/_lib/desa/musik/create.ts
Normal file
37
src/app/api/[[...slugs]]/_lib/desa/musik/create.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Context } from "elysia";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
type FormCreate = {
|
||||||
|
judul: string;
|
||||||
|
artis: string;
|
||||||
|
deskripsi?: string;
|
||||||
|
durasi: string;
|
||||||
|
audioFileId: string;
|
||||||
|
coverImageId: string;
|
||||||
|
genre?: string;
|
||||||
|
tahunRilis?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function musikCreate(context: Context) {
|
||||||
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
|
await prisma.musikDesa.create({
|
||||||
|
data: {
|
||||||
|
judul: body.judul,
|
||||||
|
artis: body.artis,
|
||||||
|
deskripsi: body.deskripsi,
|
||||||
|
durasi: body.durasi,
|
||||||
|
audioFileId: body.audioFileId,
|
||||||
|
coverImageId: body.coverImageId,
|
||||||
|
genre: body.genre,
|
||||||
|
tahunRilis: body.tahunRilis,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Sukses menambahkan musik",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default musikCreate;
|
||||||
54
src/app/api/[[...slugs]]/_lib/desa/musik/del.ts
Normal file
54
src/app/api/[[...slugs]]/_lib/desa/musik/del.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Context } from "elysia";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const musikDelete = async (context: Context) => {
|
||||||
|
const { id } = context.params as { id: string };
|
||||||
|
|
||||||
|
const musik = await prisma.musikDesa.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { audioFile: true, coverImage: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!musik) return { status: 404, body: "Musik tidak ditemukan" };
|
||||||
|
|
||||||
|
// 1. HAPUS MUSIK DULU
|
||||||
|
await prisma.musikDesa.delete({ where: { id } });
|
||||||
|
|
||||||
|
// 2. HAPUS FILE AUDIO (jika ada)
|
||||||
|
if (musik.audioFile) {
|
||||||
|
try {
|
||||||
|
const fs = await import("fs/promises");
|
||||||
|
const filePath = path.join(musik.audioFile.path, musik.audioFile.name);
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
|
||||||
|
await prisma.fileStorage.delete({
|
||||||
|
where: { id: musik.audioFile.id },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting audio file:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. HAPUS FILE COVER (jika ada)
|
||||||
|
if (musik.coverImage) {
|
||||||
|
try {
|
||||||
|
const fs = await import("fs/promises");
|
||||||
|
const filePath = path.join(musik.coverImage.path, musik.coverImage.name);
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
|
||||||
|
await prisma.fileStorage.delete({
|
||||||
|
where: { id: musik.coverImage.id },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting cover image:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Musik dan file terkait berhasil dihapus",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default musikDelete;
|
||||||
66
src/app/api/[[...slugs]]/_lib/desa/musik/find-by-id.ts
Normal file
66
src/app/api/[[...slugs]]/_lib/desa/musik/find-by-id.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function findMusikById(request: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const id = url.pathname.split("/").pop();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "ID tidak valid",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await prisma.musikDesa.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
audioFile: true,
|
||||||
|
coverImage: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Musik tidak ditemukan",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: "Success fetch musik by ID",
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error fetching musik by ID:", e);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Gagal mengambil musik: " + (e instanceof Error ? e.message : 'Unknown error'),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/app/api/[[...slugs]]/_lib/desa/musik/find-many.ts
Normal file
69
src/app/api/[[...slugs]]/_lib/desa/musik/find-many.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// /api/desa/musik/find-many.ts
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Context } from "elysia";
|
||||||
|
|
||||||
|
async function musikFindMany(context: Context) {
|
||||||
|
// Ambil parameter dari query
|
||||||
|
const page = Number(context.query.page) || 1;
|
||||||
|
const limit = Number(context.query.limit) || 10;
|
||||||
|
const search = (context.query.search as string) || '';
|
||||||
|
const genre = (context.query.genre as string) || '';
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Buat where clause
|
||||||
|
const where: any = { isActive: true };
|
||||||
|
|
||||||
|
// Filter berdasarkan genre (jika ada)
|
||||||
|
if (genre) {
|
||||||
|
where.genre = {
|
||||||
|
equals: genre,
|
||||||
|
mode: 'insensitive'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tambahkan pencarian (jika ada)
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ judul: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ artis: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ deskripsi: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ genre: { contains: search, mode: 'insensitive' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ambil data dan total count secara paralel
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.musikDesa.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
audioFile: true,
|
||||||
|
coverImage: true,
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
prisma.musikDesa.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Berhasil ambil data musik dengan pagination",
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error di findMany paginated:", e);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Gagal mengambil data musik",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default musikFindMany;
|
||||||
47
src/app/api/[[...slugs]]/_lib/desa/musik/index.ts
Normal file
47
src/app/api/[[...slugs]]/_lib/desa/musik/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import musikFindMany from "./find-many";
|
||||||
|
import musikCreate from "./create";
|
||||||
|
import musikDelete from "./del";
|
||||||
|
import musikUpdate from "./updt";
|
||||||
|
import findMusikById from "./find-by-id";
|
||||||
|
|
||||||
|
const Musik = new Elysia({ prefix: "/musik", tags: ["Desa/Musik"] })
|
||||||
|
.get("/find-many", musikFindMany)
|
||||||
|
.get("/:id", async (context) => {
|
||||||
|
const response = await findMusikById(new Request(context.request));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.post("/create", musikCreate, {
|
||||||
|
body: t.Object({
|
||||||
|
judul: t.String(),
|
||||||
|
artis: t.String(),
|
||||||
|
deskripsi: t.Optional(t.String()),
|
||||||
|
durasi: t.String(),
|
||||||
|
audioFileId: t.String(),
|
||||||
|
coverImageId: t.String(),
|
||||||
|
genre: t.Optional(t.String()),
|
||||||
|
tahunRilis: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.delete("/delete/:id", musikDelete)
|
||||||
|
.put(
|
||||||
|
"/:id",
|
||||||
|
async (context) => {
|
||||||
|
const response = await musikUpdate(context);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
judul: t.String(),
|
||||||
|
artis: t.String(),
|
||||||
|
deskripsi: t.Optional(t.String()),
|
||||||
|
durasi: t.String(),
|
||||||
|
audioFileId: t.String(),
|
||||||
|
coverImageId: t.String(),
|
||||||
|
genre: t.Optional(t.String()),
|
||||||
|
tahunRilis: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Musik;
|
||||||
65
src/app/api/[[...slugs]]/_lib/desa/musik/updt.ts
Normal file
65
src/app/api/[[...slugs]]/_lib/desa/musik/updt.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Context } from "elysia";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
type FormUpdate = {
|
||||||
|
judul: string;
|
||||||
|
artis: string;
|
||||||
|
deskripsi?: string;
|
||||||
|
durasi: string;
|
||||||
|
audioFileId: string;
|
||||||
|
coverImageId: string;
|
||||||
|
genre?: string;
|
||||||
|
tahunRilis?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function musikUpdate(context: Context) {
|
||||||
|
const { id } = context.params as { id: string };
|
||||||
|
const body = context.body as FormUpdate;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await prisma.musikDesa.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
success: false,
|
||||||
|
message: "Musik tidak ditemukan",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.musikDesa.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
judul: body.judul,
|
||||||
|
artis: body.artis,
|
||||||
|
deskripsi: body.deskripsi,
|
||||||
|
durasi: body.durasi,
|
||||||
|
audioFileId: body.audioFileId,
|
||||||
|
coverImageId: body.coverImageId,
|
||||||
|
genre: body.genre,
|
||||||
|
tahunRilis: body.tahunRilis,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Musik berhasil diupdate",
|
||||||
|
data: updated,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating musik:", error);
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
success: false,
|
||||||
|
message: "Terjadi kesalahan saat mengupdate musik",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default musikUpdate;
|
||||||
@@ -21,8 +21,12 @@ export default async function findUnique(
|
|||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await prisma.potensiDesa.findUnique({
|
// ✅ Filter by isActive and deletedAt
|
||||||
where: { id },
|
const data = await prisma.potensiDesa.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
image: true,
|
image: true,
|
||||||
kategori: true
|
kategori: true
|
||||||
@@ -48,5 +52,5 @@ export default async function findUnique(
|
|||||||
message: "Gagal mengambil potensi desa: " + (error instanceof Error ? error.message : 'Unknown error'),
|
message: "Gagal mengambil potensi desa: " + (error instanceof Error ? error.message : 'Unknown error'),
|
||||||
}, { status: 500 });
|
}, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,49 @@ import prisma from "@/lib/prisma";
|
|||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
export default async function kategoriPotensiDelete(context: Context) {
|
export default async function kategoriPotensiDelete(context: Context) {
|
||||||
const id = context.params.id as string;
|
try {
|
||||||
|
const id = context.params?.id as string;
|
||||||
|
|
||||||
await prisma.kategoriPotensi.delete({
|
if (!id) {
|
||||||
where: { id },
|
return Response.json({
|
||||||
});
|
success: false,
|
||||||
|
message: "ID tidak boleh kosong",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// ✅ Cek apakah kategori masih digunakan oleh potensi desa
|
||||||
status: 200,
|
const existingPotensi = await prisma.potensiDesa.findFirst({
|
||||||
success: true,
|
where: {
|
||||||
message: "Sukses Menghapus kategori potensi",
|
kategoriId: id,
|
||||||
};
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingPotensi) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Kategori masih digunakan oleh potensi desa. Tidak dapat dihapus.",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await prisma.kategoriPotensi.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
deletedAt: new Date(),
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Kategori potensi berhasil dihapus",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete kategori error:", error);
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
export default async function sejarahDesaFindFirst() {
|
||||||
|
// ✅ Authentication check
|
||||||
|
const authResult = await requireAuth();
|
||||||
|
if (!authResult.authenticated) {
|
||||||
|
return authResult.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the first active record
|
||||||
|
const data = await prisma.sejarahDesa.findFirst({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' } // Get the oldest one first
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Data tidak ditemukan",
|
||||||
|
}, {status: 404})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
}, {status: 200})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal mengambil data sejarah desa:", error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
message: "Terjadi kesalahan saat mengambil data",
|
||||||
|
}, {status: 500})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import Elysia, { t } from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
import sejarahDesaFindById from "./find-by-id";
|
import sejarahDesaFindById from "./find-by-id";
|
||||||
import sejarahDesaUpdate from "./update";
|
import sejarahDesaUpdate from "./update";
|
||||||
|
import sejarahDesaFindFirst from "./find-first";
|
||||||
|
|
||||||
const SejarahDesa = new Elysia({
|
const SejarahDesa = new Elysia({
|
||||||
prefix: "/sejarah",
|
prefix: "/sejarah",
|
||||||
tags: ["Desa/Profile"],
|
tags: ["Desa/Profile"],
|
||||||
})
|
})
|
||||||
|
.get("/first", async () => {
|
||||||
|
const response = await sejarahDesaFindFirst();
|
||||||
|
return response;
|
||||||
|
})
|
||||||
.get("/:id", async (context) => {
|
.get("/:id", async (context) => {
|
||||||
const response = await sejarahDesaFindById(new Request(context.request));
|
const response = await sejarahDesaFindById(new Request(context.request));
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user