Compare commits

...

27 Commits

Author SHA1 Message Date
239771a714 fix(apbdes): improve UI components and styling
- Update Apbdes component with better conditional rendering
- Enhance grafikRealisasi with improved percentage display
- Refine color coding and feedback messages
- Optimize layout and spacing for better UX

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 14:15:27 +08:00
03451195c8 feat(apbdes) grafik: add detailed percentage comparison
- Display percentage value prominently next to each category title
- Add formatted currency (Rupiah) for better readability
- Color-coded progress bars based on achievement level:
  * Teal: ≥100% (target tercapai)
  * Blue: ≥80% (baik)
  * Yellow: ≥60% (cukup)
  * Red: <60% (perlu perhatian)
- Add contextual feedback messages based on percentage:
  * ✓ Achievement message for 100%
  *  Positive message for 80-99%
  * ⚠️ Warning messages for <80%
- Add TOTAL KESELURUHAN summary section at the top
- Add emoji icons for better visual distinction (💰 💸 📊)
- Animated progress bars for <100% achievement

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 13:58:52 +08:00
597af7e716 fix(apbdes) landing page: fix APBDes component not displaying on darmasaba page
- Restore Apbdes component with full functionality (fetch data, year selector, tables, charts)
- Fix realisasiTable.tsx: add missing items variable
- Fix grafikRealisasi.tsx: dynamic year title instead of hardcoded 2026
- Add eslint-disable comments for TypeScript any types
- Remove unused imports in paguTable.tsx
- Integrate PaguTable, RealisasiTable, GrafikRealisasi into main Apbdes component
- Component now fetches data from Valtio state and displays 3 tables + charts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 12:51:53 +08:00
0a8a026b94 fix(apbdes): integrate new APBDes API with admin UI
- Update API schema to support name, deskripsi, and jumlah fields
- Enhance state management with additional form fields
- Add input fields for name, description, and total amount in create/edit pages
- Display description and total amount in detail page
- Fix APBDes component order in landing page
- Update TypeScript types and Prisma schema integration

API Changes:
- POST /api/landingpage/apbdes/create: Added optional fields (name, deskripsi, jumlah)
- PUT /api/landingpage/apbdes/🆔 Added optional fields (name, deskripsi, jumlah)

Admin UI Changes:
- create/page.tsx: Add TextInput for name, deskripsi, and jumlah
- edit/page.tsx: Add TextInput for name, deskripsi, and jumlah; improve reset functionality
- [id]/page.tsx: Display deskripsi and jumlah if available
- page.tsx: Minor formatting fix
- _state/apbdes.ts: Update Zod schema and default form with new fields

Landing Page:
- Move Apbdes component to top of stack for better visibility

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-03 10:56:30 +08:00
a5bd91b580 feat(berita): add multiple images gallery and YouTube video support
- Update schema: add images relation list and linkVideo field
- API: support multiple image upload and YouTube link in create/update
- Admin create page: add gallery upload (max 10) and YouTube embed preview
- Admin edit page: manage existing/new gallery images and YouTube link
- Admin detail page: display gallery grid and YouTube video embed
- Public detail page: show gallery images and YouTube video with responsive layout
- State: add imageIds[] and linkVideo fields with proper type handling
- Music player: fix seek functionality and ESLint warnings

Breaking changes:
- Prisma schema updated - requires migration
- API create/update endpoints now expect imageIds array and linkVideo string

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 16:06:53 +08:00
ae3187804e Notes slider musik belum berfungsi 2026-03-02 14:28:20 +08:00
91e32f3f1c fix(musik): fix seek slider reset ke 0 - root cause: useEffect dependency
ROOT CAUSE:
- filteredMusik di-calculate ulang setiap render (.filter() tanpa memoization)
- currentSong = filteredMusik[currentSongIndex] → object reference baru setiap render
- useEffect dependency [currentSong, currentSongIndex] trigger setiap render
- useEffect reset setCurrentTime(0) → slider kembali ke awal

FIX:
1. useMemo untuk filteredMusik - mencegah re-calculate setiap render
2. useEffect dependency [currentSong?.id, currentSongIndex] - hanya trigger saat lagu benar-benar berubah
3. Hapus semua debug console.log yang tidak diperlukan
4. Simplifikasi seekTo function

File Changed:
- src/app/darmasaba/(pages)/musik/musik-desa/page.tsx
- src/app/darmasaba/(pages)/musik/lib/seek.ts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 12:03:26 +08:00
4d03908f23 feat(musik): tambahkan extensive debug logging untuk tracking seek issue
- Tambah key stabil pada audio element untuk mencegah remount
- Log di seekTo: before/after currentTime
- Log di onTimeUpdate: currentTime, rounded, isSeeking
- Log di onChangeEnd slider: value, seekTime
- Log di useEffect song change: currentSong, currentSongIndex
- Log di skipBack/skipForward: index perubahan lagu

Purpose: Track urutan eksekusi dan identifikasi race condition atau re-render yang tidak diinginkan

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:58:40 +08:00
0563f9664f fix(musik): perbaiki timing dan rounding pada seek slider
- Gunakan durasi dari database sebagai acuan utama (bukan dari audio metadata)
- Ganti Math.floor dengan Math.round untuk smoothing currentTime
- Tambahkan validasi seek time: Math.min(Math.max(0, v), duration)
- Tambahkan debug logging untuk tracking seek behavior
- Hapus override duration di onLoadedMetadata untuk menghindari konflik

Root cause:
- Duration dari database (string 'MM:SS' → seconds) berbeda dengan audio.duration (float)
- Math.floor menyebabkan lompatan kasar dan kehilangan presisi
- onLoadedMetadata override duration dengan audio.duration yang tidak exact

Fix:
- Database duration = source of truth
- Math.round untuk smoothing tanpa kehilangan presisi
- Validasi bounds untuk mencegah seek negatif atau melebihi durasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:53:36 +08:00
961cc32057 fix(musik): slider seek sekarang berfungsi dengan benar
- Fix slider seek reset ke detik awal saat digeser
- Tambahkan isSeeking state untuk mencegah onTimeUpdate mereset posisi slider
- Implementasi pattern preview/commit untuk seek:
  - onChange: update UI state saja (preview)
  - onChangeEnd: commit ke audio player (commit)
- Update seekTo function untuk support optional setCurrentTime callback
- Terapkan fix ke kedua slider (Sedang Diputar dan bottom player)

Bug: Slider seek langsung kembali ke posisi awal saat digeser karena:
1. onTimeUpdate terus menerus update currentTime state
2. seekTo tidak update React state setelah set audio.currentTime
3. Tidak ada isSeeking flag untuk block onTimeUpdate saat user sedang seek

Fix:
1. Set isSeeking=true saat onChange, false saat onChangeEnd
2. onTimeUpdate check isSeeking sebelum update state
3. seekTo sekarang juga update state via callback optional

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:47:05 +08:00
fe7672e09f refactor(musik): integrate music player library functions and fix build errors
- Integrate togglePlayPause, getNextIndex, getPrevIndex, handleRepeatOrNext, seekTo, toggleShuffle, setAudioVolume, toggleMute library functions
- Fix ESLint warnings: remove unused eslint-disable, add missing useEffect dependencies
- Fix ESLint error in useMusicPlayer.ts togglePlayPause function
- Add force-dynamic export to root layout to prevent prerendering errors
- Improve seek slider with preview/commit functionality
- Add isSeeking state to prevent UI flickering during seek

Fixes: Build PageNotFoundError for admin/darmasaba pages

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:41:14 +08:00
341ff5779f Fix Durasi Musik Di Tampilan User 2026-02-27 11:52:18 +08:00
69f7b4c162 feat: integrate musik desa page with API and improve audio player
- Fetch musik data from /api/desa/musik/find-many endpoint
- Filter only active musik (isActive: true)
- Add search functionality by title, artist, and genre
- Implement real audio playback with HTML5 audio element
- Add play/pause, next/previous, shuffle, repeat controls
- Add progress bar with seek functionality
- Add volume control with mute toggle
- Auto-play next song when current song ends
- Add loading and empty states
- Use cover image and audio file from database
- Fix skip back/forward button handlers

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-26 22:24:25 +08:00
409ad4f1a2 Fix Login KodeOtp WA 2026-02-26 22:10:28 +08:00
55ea3c473a add menu musik 2026-02-26 21:32:33 +08:00
a152eaf984 Fix Login KodeOtp WA 2026-02-26 14:12:54 +08:00
223b85a714 Fix CORS config for staging environment
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 22:55:36 +08:00
f1729151b3 Fix themeTokens light mode status colors
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 21:24:39 +08:00
8e8c133eea Fix eror build 2026-02-25 21:19:56 +08:00
1e7acac193 Fix eror build 2026-02-25 21:18:26 +08:00
42dcbcfb22 fix-admin-menu-desa-berita 2026-02-25 16:25:59 +08:00
22de1aa1f3 fix-admin-menu-desa-potensi 2026-02-25 15:41:01 +08:00
b1d28a8322 fix-admin-menu-desa-profile 2026-02-25 15:25:51 +08:00
b86a3a85c3 fix: force default light mode for public pages and admin
- Set defaultColorScheme='light' in root MantineProvider
- Change darkModeStore default from system preference to false (light)
- Add MantineProvider with light theme to darmasaba/layout.tsx
- Remove dark mode dependency from ModuleView component
- Prevent system color scheme from affecting initial page load

This ensures consistent light mode on first visit for both
public pages and admin panel, regardless of OS settings.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 10:45:27 +08:00
fd63bb0fd4 feat: implement dark mode support & fix Prisma schema validation
- Add dark mode toggle component in admin header
- Integrate dark mode store across admin layout and components
- Add unified typography and surface components for consistent theming
- Implement smooth transitions for dark/light mode switching
- Fix Prisma schema: remove @default(null) from DateTime? fields
- Update form validation for inovasi, lingkungan, and pendidikan modules
- Add form validation and improve UX across multiple admin pages

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 10:41:48 +08:00
f0558aa0d0 feat: implement dark mode support for admin layout and components
- Add dark mode toggle component in admin header
- Integrate dark mode store across admin layout and child components
- Update header, judulList, and judulListTab components with theme tokens
- Add unified typography components for consistent theming
- Implement smooth transitions for dark/light mode switching
- Add mounted state to prevent hydration mismatches
- Style navbar with dark mode aware colors and hover states
- Update button styles with gradient effects for both themes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-23 10:48:00 +08:00
8132609ccb feat: add form validation for inovasi, lingkungan, and pendidikan modules
- Added isFormValid() and isHtmlEmpty() helper functions for form validation
- Disabled submit buttons when required fields are empty across multiple admin and public pages
- Applied consistent validation pattern for creating and editing records
- Commented out WhatsApp OTP sending in login route for debugging/testing
- Fixed path in NavbarMainMenu tooltip action
2026-02-20 15:08:41 +08:00
144 changed files with 7850 additions and 662 deletions

169
darkMode.md Normal file
View 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 (23px 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: 1216px
- 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.

View File

@@ -19,7 +19,6 @@ const nextConfig: NextConfig = {
}, },
]; ];
}, },
}; };
export default nextConfig; export default nextConfig;

View File

@@ -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");

View 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;

View File

@@ -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"

View File

@@ -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])
}

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
}), }),
}); });

View 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;

View File

@@ -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) => ({

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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()}

View File

@@ -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 ? (

View File

@@ -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()}

View File

@@ -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;

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View 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>
);
}

View 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 &quot;{data.judul}&quot;?
</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>
);
}

View 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>
);
}

View 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;

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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)',
}} }}

View File

@@ -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"
} }
] ]

View File

@@ -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>

View File

@@ -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,
}, },
}); });

View File

@@ -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,
}, },
}); });

View File

@@ -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()),
}), }),
} }
); );

View File

@@ -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 });
}
} }

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View 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" },
}
);
}
}

View 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;

View 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;

View 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;

View File

@@ -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 });
} }
} }

View File

@@ -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 });
}
} }

View File

@@ -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})
}
}

View File

@@ -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