Compare commits

...

5 Commits

Author SHA1 Message Date
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
f2c9a922a6 fix(profil-module): QC improvements based on QC-PROFIL-MODULE.md
- Fix fetch method inconsistency (convert to ApiFetch)
  - programInovasi: findUnique, delete, update methods
  - mediaSosial: findUnique, delete, update methods
- Add loading state to findUnique operations
- Fix iconUrl validation (make optional instead of required)
- Add DOMPurify for HTML sanitization (XSS protection)
  - program-inovasi page.tsx (list & detail)
- Remove console.log in production (use dev-only logging)
- Install dompurify and @types/dompurify

Security: Prevent XSS attacks by sanitizing HTML content
Consistency: Use ApiFetch for all API operations
UX: Proper loading states for better user feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-23 15:11:00 +08:00
92b24440fe fix: Quality Control improvements & bug fixes
- APBDes: Fix edit form original data tracking (imageId, fileId)
- APBDes: Update formula consistency in state
- PPID modules: Various UI improvements and bug fixes
- PPID Profil: Preview and edit page improvements
- PPID Dasar Hukum: Page structure improvements
- PPID Visi Misi: Page structure improvements
- PPID Struktur: Posisi organisasi page improvements
- PPID Daftar Informasi: Edit page improvements
- Auth login: Route improvements
- Update dependencies (package.json, bun.lockb)
- Update seed data
- Update .gitignore

QC Reports added:
- QC-APBDES-MODULE.md
- QC-PROFIL-MODULE.md
- QC-SDGS-DESA.md
- QC-DESA-ANTI-KORUPSI.md
- QC-PRESTASI-DESA-MODULE.md
- QC-PPID-PROFIL-MODULE.md
- QC-STRUKTUR-PPID-MODULE.md
- QC-VISI-MISI-PPID-MODULE.md
- QC-DASAR-HUKUM-PPID-MODULE.md
- QC-PERMOHONAN-INFORMASI-PUBLIK-MODULE.md
- QC-PERMOHONAN-KEBERATAN-INFORMASI-MODULE.md
- QC-DAFTAR-INFORMASI-PUBLIK-MODULE.md
- QC-IKM-MODULE.md

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-23 14:38:28 +08:00
25 changed files with 500 additions and 263 deletions

3
.gitignore vendored
View File

@@ -31,6 +31,9 @@ yarn-error.log*
# env # env
.env* .env*
# QC
QC
# vercel # vercel
.vercel .vercel

BIN
bun.lockb

Binary file not shown.

View File

@@ -62,6 +62,7 @@
"colors": "^1.4.0", "colors": "^1.4.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.3.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"elysia": "^1.3.5", "elysia": "^1.3.5",
"embla-carousel": "^8.6.0", "embla-carousel": "^8.6.0",
@@ -112,6 +113,7 @@
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@types/cli-progress": "^3.11.6", "@types/cli-progress": "^3.11.6",
"@types/dompurify": "^3.2.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",

View File

@@ -236,7 +236,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 +245,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 +263,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 +272,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 +282,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 +292,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 +326,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 +346,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 +372,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 +383,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 +400,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 +412,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 +433,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 +442,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 +452,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 +462,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 +476,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 +533,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 +543,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 +553,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 +564,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)
} }

View File

@@ -38,11 +38,9 @@ function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer
const anggaran = item.anggaran ?? 0; const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0; const realisasi = item.realisasi ?? 0;
// ✅ Formula yang benar // ✅ Formula yang benar
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget const selisih = realisasi - anggaran; // positif = sisa anggaran, negatif = over budget
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
return { return {

View File

@@ -55,10 +55,15 @@ const programInovasi = proxy({
programInovasi.findMany.load(); programInovasi.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("Sukses menambahkan");
} }
console.log(res); if (process.env.NODE_ENV === 'development') {
console.log(res);
}
return toast.error("failed create"); return toast.error("failed create");
} catch (error) { } catch (error) {
console.log((error as Error).message); if (process.env.NODE_ENV === 'development') {
console.error("Create error:", error);
}
toast.error("Gagal menambahkan data");
} finally { } finally {
programInovasi.create.loading = false; programInovasi.create.loading = false;
} }
@@ -91,13 +96,17 @@ const programInovasi = proxy({
programInovasi.findMany.total = res.data.total || 0; programInovasi.findMany.total = res.data.total || 0;
programInovasi.findMany.totalPages = res.data.totalPages || 1; programInovasi.findMany.totalPages = res.data.totalPages || 1;
} else { } else {
console.error("Failed to load pegawai:", res.data?.message); if (process.env.NODE_ENV === 'development') {
console.error("Failed to load pegawai:", res.data?.message);
}
programInovasi.findMany.data = []; programInovasi.findMany.data = [];
programInovasi.findMany.total = 0; programInovasi.findMany.total = 0;
programInovasi.findMany.totalPages = 1; programInovasi.findMany.totalPages = 1;
} }
} catch (error) { } catch (error) {
console.error("Error loading pegawai:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error loading pegawai:", error);
}
programInovasi.findMany.data = []; programInovasi.findMany.data = [];
programInovasi.findMany.total = 0; programInovasi.findMany.total = 0;
programInovasi.findMany.totalPages = 1; programInovasi.findMany.totalPages = 1;
@@ -112,19 +121,25 @@ const programInovasi = proxy({
image: true; image: true;
}; };
}> | null, }> | null,
loading: false,
async load(id: string) { async load(id: string) {
try { try {
const res = await fetch(`/api/landingpage/programinovasi/${id}`); programInovasi.findUnique.loading = true;
if (res.ok) { const res = await (ApiFetch.api.landingpage.programinovasi as any)[id].get();
const data = await res.json(); if (res.data?.success) {
programInovasi.findUnique.data = data.data ?? null; programInovasi.findUnique.data = res.data.data ?? null;
return res.data.data;
} else { } else {
console.error("Failed to fetch program inovasi:", res.statusText); toast.error(res.data?.message || "Gagal memuat data program inovasi");
programInovasi.findUnique.data = null; programInovasi.findUnique.data = null;
return null;
} }
} catch (error) { } catch (error) {
console.error("Error fetching program inovasi:", error); console.error("Error fetching program inovasi:", error);
programInovasi.findUnique.data = null; programInovasi.findUnique.data = null;
return null;
} finally {
programInovasi.findUnique.loading = false;
} }
}, },
}, },
@@ -135,27 +150,18 @@ const programInovasi = proxy({
try { try {
programInovasi.delete.loading = true; programInovasi.delete.loading = true;
const res = await (ApiFetch.api.landingpage.programinovasi as any)["del"][id].delete();
const response = await fetch( if (res.data?.success) {
`/api/landingpage/programinovasi/del/${id}`, toast.success(res.data.message || "Program inovasi berhasil dihapus");
{ await programInovasi.findMany.load();
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Program inovasi berhasil dihapus");
await programInovasi.findMany.load(); // refresh list
} else { } else {
toast.error(result?.message || "Gagal menghapus program inovasi"); toast.error(res.data?.message || "Gagal menghapus program inovasi");
} }
} catch (error) { } catch (error) {
console.error("Gagal delete:", error); if (process.env.NODE_ENV === 'development') {
console.error("Gagal delete:", error);
}
toast.error("Terjadi kesalahan saat menghapus program inovasi"); toast.error("Terjadi kesalahan saat menghapus program inovasi");
} finally { } finally {
programInovasi.delete.loading = false; programInovasi.delete.loading = false;
@@ -174,20 +180,11 @@ const programInovasi = proxy({
} }
try { try {
const response = await fetch(`/api/landingpage/programinovasi/${id}`, { programInovasi.update.loading = true;
method: "GET", const res = await (ApiFetch.api.landingpage.programinovasi as any)[id].get();
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json(); if (res.data?.success) {
const data = res.data.data;
if (result?.success) {
const data = result.data;
this.id = data.id; this.id = data.id;
this.form = { this.form = {
name: data.name, name: data.name,
@@ -197,13 +194,15 @@ const programInovasi = proxy({
}; };
return data; return data;
} else { } else {
throw new Error( toast.error(res.data?.message || "Gagal mengambil data program inovasi");
result?.message || "Gagal mengambil data program inovasi" return null;
);
} }
} catch (error) { } catch (error) {
console.error((error as Error).message); if (process.env.NODE_ENV === 'development') {
console.error("Error loading program inovasi:", error);
}
toast.error("Terjadi kesalahan saat mengambil data program inovasi"); toast.error("Terjadi kesalahan saat mengambil data program inovasi");
return null;
} finally { } finally {
programInovasi.update.loading = false; programInovasi.update.loading = false;
} }
@@ -221,41 +220,25 @@ const programInovasi = proxy({
try { try {
programInovasi.update.loading = true; programInovasi.update.loading = true;
const res = await (ApiFetch.api.landingpage.programinovasi as any)[this.id].put({
name: this.form.name,
description: this.form.description,
imageId: this.form.imageId,
link: this.form.link,
});
const response = await fetch( if (res.data?.success) {
`/api/landingpage/programinovasi/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: this.form.name,
description: this.form.description,
imageId: this.form.imageId,
link: this.form.link,
}),
}
);
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("Berhasil update program inovasi"); toast.success("Berhasil update program inovasi");
await programInovasi.findMany.load(); // refresh list await programInovasi.findMany.load();
return true; return true;
} else { } else {
throw new Error(result.message || "Gagal update program inovasi"); toast.error(res.data?.message || "Gagal update program inovasi");
return false;
} }
} catch (error) { } catch (error) {
console.error("Error updating program inovasi:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error updating program inovasi:", error);
}
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -443,7 +426,7 @@ const pejabatDesa = proxy({
const templateMediaSosial = z.object({ const templateMediaSosial = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
imageId: z.string().nullable().optional(), imageId: z.string().nullable().optional(),
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"), iconUrl: z.string().optional(), // ✅ Optional - tidak selalu required
icon: z.string().nullable().optional(), icon: z.string().nullable().optional(),
}); });
@@ -484,10 +467,15 @@ const mediaSosial = proxy({
mediaSosial.findMany.load(); mediaSosial.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("Sukses menambahkan");
} }
console.log(res); if (process.env.NODE_ENV === 'development') {
console.log(res);
}
return toast.error("failed create"); return toast.error("failed create");
} catch (error) { } catch (error) {
console.log((error as Error).message); if (process.env.NODE_ENV === 'development') {
console.log((error as Error).message);
}
toast.error("Gagal menambahkan data");
} finally { } finally {
mediaSosial.create.loading = false; mediaSosial.create.loading = false;
} }
@@ -518,13 +506,17 @@ const mediaSosial = proxy({
mediaSosial.findMany.total = res.data.total || 0; mediaSosial.findMany.total = res.data.total || 0;
mediaSosial.findMany.totalPages = res.data.totalPages || 1; mediaSosial.findMany.totalPages = res.data.totalPages || 1;
} else { } else {
console.error("Failed to load media sosial:", res.data?.message); if (process.env.NODE_ENV === 'development') {
console.error("Failed to load media sosial:", res.data?.message);
}
mediaSosial.findMany.data = []; mediaSosial.findMany.data = [];
mediaSosial.findMany.total = 0; mediaSosial.findMany.total = 0;
mediaSosial.findMany.totalPages = 1; mediaSosial.findMany.totalPages = 1;
} }
} catch (error) { } catch (error) {
console.error("Error loading media sosial:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error loading media sosial:", error);
}
mediaSosial.findMany.data = []; mediaSosial.findMany.data = [];
mediaSosial.findMany.total = 0; mediaSosial.findMany.total = 0;
mediaSosial.findMany.totalPages = 1; mediaSosial.findMany.totalPages = 1;
@@ -539,25 +531,32 @@ const mediaSosial = proxy({
image: true; image: true;
}; };
}> | null, }> | null,
loading: false,
async load(id: string) { async load(id: string) {
if (!id) { if (!id) {
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
} }
mediaSosial.update.loading = true; mediaSosial.findUnique.loading = true;
try { try {
const res = await fetch(`/api/landingpage/mediasosial/${id}`); const res = await (ApiFetch.api.landingpage.mediasosial as any)[id].get();
if (res.ok) { if (res.data?.success) {
const data = await res.json(); mediaSosial.findUnique.data = res.data.data ?? null;
mediaSosial.findUnique.data = data.data ?? null; return res.data.data;
} else { } else {
console.error("Failed to fetch media sosial:", res.statusText); toast.error(res.data?.message || "Gagal memuat data media sosial");
mediaSosial.findUnique.data = null; mediaSosial.findUnique.data = null;
return null;
} }
} catch (error) { } catch (error) {
console.error("Error fetching media sosial:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error fetching media sosial:", error);
}
mediaSosial.findUnique.data = null; mediaSosial.findUnique.data = null;
return null;
} finally {
mediaSosial.findUnique.loading = false;
} }
}, },
}, },
@@ -568,24 +567,18 @@ const mediaSosial = proxy({
try { try {
mediaSosial.delete.loading = true; mediaSosial.delete.loading = true;
const res = await (ApiFetch.api.landingpage.mediasosial as any)["del"][id].delete();
const response = await fetch(`/api/landingpage/mediasosial/del/${id}`, { if (res.data?.success) {
method: "DELETE", toast.success(res.data.message || "Media Sosial berhasil dihapus");
headers: { await mediaSosial.findMany.load();
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Media Sosial berhasil dihapus");
await mediaSosial.findMany.load(); // refresh list
} else { } else {
toast.error(result?.message || "Gagal menghapus media sosial"); toast.error(res.data?.message || "Gagal menghapus media sosial");
} }
} catch (error) { } catch (error) {
console.error("Gagal delete:", error); if (process.env.NODE_ENV === 'development') {
console.error("Gagal delete:", error);
}
toast.error("Terjadi kesalahan saat menghapus media sosial"); toast.error("Terjadi kesalahan saat menghapus media sosial");
} finally { } finally {
mediaSosial.delete.loading = false; mediaSosial.delete.loading = false;
@@ -603,43 +596,32 @@ const mediaSosial = proxy({
return null; return null;
} }
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal mediaSosial.update.loading = true;
try { try {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, { const res = await (ApiFetch.api.landingpage.mediasosial as any)[id].get();
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) { if (res.data?.success) {
throw new Error(`HTTP error! status: ${response.status}`); const data = res.data.data;
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id; this.id = data.id;
this.form = { this.form = {
name: data.name || "", name: data.name || "",
imageId: data.imageId || null, imageId: data.imageId || null,
iconUrl: data.iconUrl || "", iconUrl: data.iconUrl || "",
icon: data.icon || null, icon: data.icon || null,
}; };
return data; return data;
} else { } else {
throw new Error( toast.error(res.data?.message || "Gagal mengambil data media sosial");
result?.message || "Gagal mengambil data media sosial" return null;
);
} }
} catch (error) { } catch (error) {
console.error((error as Error).message); if (process.env.NODE_ENV === 'development') {
console.error("Error loading media sosial:", error);
}
toast.error("Terjadi kesalahan saat mengambil data media sosial"); toast.error("Terjadi kesalahan saat mengambil data media sosial");
return null;
} finally { } finally {
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error mediaSosial.update.loading = false;
} }
}, },
@@ -655,41 +637,25 @@ const mediaSosial = proxy({
try { try {
mediaSosial.update.loading = true; mediaSosial.update.loading = true;
const res = await (ApiFetch.api.landingpage.mediasosial as any)[this.id].put({
name: this.form.name,
imageId: this.form.imageId,
iconUrl: this.form.iconUrl,
icon: this.form.icon,
});
const response = await fetch( if (res.data?.success) {
`/api/landingpage/mediasosial/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: this.form.name,
imageId: this.form.imageId,
iconUrl: this.form.iconUrl,
icon: this.form.icon,
}),
}
);
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("Berhasil update media sosial"); toast.success("Berhasil update media sosial");
await mediaSosial.findMany.load(); // refresh list await mediaSosial.findMany.load();
return true; return true;
} else { } else {
throw new Error(result.message || "Gagal update media sosial"); toast.error(res.data?.message || "Gagal update media sosial");
return false;
} }
} catch (error) { } catch (error) {
console.error("Error updating media sosial:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error updating media sosial:", error);
}
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message

View File

@@ -95,7 +95,7 @@ function Page() {
fz={{ base: 'md', md: 'lg' }} fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.4, md: 1.4 }} lh={{ base: 1.4, md: 1.4 }}
> >
I.B. Surya Prabhawa Manuaba, S.H., M.H. {perbekel.nama || "I.B. Surya Prabhawa Manuaba, S.H., M.H."}
</Text> </Text>
</Paper> </Paper>
</Stack> </Stack>

View File

@@ -53,7 +53,7 @@ function EditAPBDes() {
const params = useParams(); const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid // Check if form is valid
const isFormValid = () => { const isFormValid = () => {
return ( return (
@@ -76,33 +76,62 @@ function EditAPBDes() {
tipe: 'pendapatan', tipe: 'pendapatan',
}); });
// Type for the API response // Simpan data original untuk reset form
interface APBDesResponse { const [originalData, setOriginalData] = useState({
id: string; tahun: 0,
image?: { imageId: '',
link: string; fileId: '',
id: string; imageUrl: '',
}; fileUrl: '',
file?: { });
link: string;
id: string;
};
// Add other properties as needed
}
// Load data saat pertama kali // Load data saat pertama kali
useEffect(() => { useEffect(() => {
const id = params?.id as string; const id = params?.id as string;
if (id) { if (!id) return;
apbdesState.edit.load(id).then((response) => {
const data = response as unknown as APBDesResponse; const loadData = async () => {
if (data) { try {
// ✅ Ambil link langsung dari response const data = await apbdesState.edit.load(id);
setPreviewImage(data.image?.link || null);
setPreviewDoc(data.file?.link || null); if (!data) return;
}
}); // Set preview dari data lama
} setPreviewImage(data.image?.link || null);
setPreviewDoc(data.file?.link || null);
// Simpan data original untuk reset
setOriginalData({
tahun: data.tahun || new Date().getFullYear(),
imageId: data.imageId || '',
fileId: data.fileId || '',
imageUrl: data.image?.link || '',
fileUrl: data.file?.link || '',
});
// Set form dengan data lama (termasuk imageId dan fileId)
apbdesState.edit.form = {
tahun: data.tahun || new Date().getFullYear(),
imageId: data.imageId || '',
fileId: data.fileId || '',
items: (data.items || []).map((item: any) => ({
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
tipe: item.tipe || 'pendapatan',
})),
};
} catch (error) {
console.error('Error loading APBDes:', error);
toast.error('Gagal memuat data APBDes');
}
};
loadData();
}, [params?.id]); }, [params?.id]);
const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => { const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => {
@@ -162,23 +191,38 @@ function EditAPBDes() {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
// Upload file baru jika ada // Upload file baru jika ada perubahan
if (imageFile) { if (imageFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file: imageFile, file: imageFile,
name: imageFile.name, name: imageFile.name,
}); });
const imageId = res.data?.data?.id; const imageId = res.data?.data?.id;
if (imageId) apbdesState.edit.form.imageId = imageId; if (imageId) {
apbdesState.edit.form.imageId = imageId;
}
} }
if (docFile) { if (docFile) {
// Hapus file lama dari form jika ada file baru
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
file: docFile, file: docFile,
name: docFile.name, name: docFile.name,
}); });
const fileId = res.data?.data?.id; const fileId = res.data?.data?.id;
if (fileId) apbdesState.edit.form.fileId = fileId; if (fileId) {
apbdesState.edit.form.fileId = fileId;
}
}
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form)
// Pastikan imageId dan fileId tetap ada
if (!apbdesState.edit.form.imageId) {
return toast.warn('Gambar wajib diunggah');
}
if (!apbdesState.edit.form.fileId) {
return toast.warn('Dokumen wajib diunggah');
} }
const success = await apbdesState.edit.update(); const success = await apbdesState.edit.update();
@@ -194,21 +238,33 @@ function EditAPBDes() {
}; };
const handleReset = () => { const handleReset = () => {
const id = params?.id as string; // Reset ke data original (tahun, imageId, fileId)
if (id) { apbdesState.edit.form = {
apbdesState.edit.load(id); tahun: originalData.tahun,
setImageFile(null); imageId: originalData.imageId,
setDocFile(null); fileId: originalData.fileId,
setNewItem({ items: [...apbdesState.edit.form.items], // keep existing items
kode: '', };
uraian: '',
anggaran: 0, // Reset preview ke data original
realisasi: 0, setPreviewImage(originalData.imageUrl || null);
level: 1, setPreviewDoc(originalData.fileUrl || null);
tipe: 'pendapatan',
}); // Reset file uploads
toast.info('Form dikembalikan ke data awal'); setImageFile(null);
} setDocFile(null);
// Reset new item form
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
toast.info('Form dikembalikan ke data awal');
}; };
return ( return (

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';
function DetailProgramInovasi() { function DetailProgramInovasi() {
const stateProgramInovasi = useProxy(profileLandingPageState.programInovasi) const stateProgramInovasi = useProxy(profileLandingPageState.programInovasi)
@@ -85,7 +86,7 @@ function DetailProgramInovasi() {
<Box> <Box>
<Text fz="lg" fw="bold">Deskripsi</Text> <Text fz="lg" fw="bold">Deskripsi</Text>
<Box pl={5}> <Box pl={5}>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.description || '-' }}></Text> <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(data.description || '-') }}></Text>
</Box> </Box>
</Box> </Box>

View File

@@ -6,6 +6,7 @@ import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { 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';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import profileLandingPageState from '../../../_state/landing-page/profile'; import profileLandingPageState from '../../../_state/landing-page/profile';
@@ -90,7 +91,7 @@ function ListProgramInovasi({ search }: { search: string }) {
<Text fw={500}>{item.name}</Text> <Text fw={500}>{item.name}</Text>
</TableTd> </TableTd>
<TableTd style={{ maxWidth: 250 }}> <TableTd style={{ maxWidth: 250 }}>
<Text fz="sm" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.description || '-' }}></Text> <Text fz="sm" lineClamp={1} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.description || '-') }}></Text>
</TableTd> </TableTd>
<TableTd style={{ maxWidth: 250 }}> <TableTd style={{ maxWidth: 250 }}>
<Tooltip label="Buka tautan program" position="top" withArrow> <Tooltip label="Buka tautan program" position="top" withArrow>
@@ -144,7 +145,7 @@ function ListProgramInovasi({ search }: { search: string }) {
{/* Description */} {/* Description */}
<Box> <Box>
<Text fz="sm" fw={600} lh={1.4}>Deskripsi</Text> <Text fz="sm" fw={600} lh={1.4}>Deskripsi</Text>
<Text dangerouslySetInnerHTML={{ __html: item.description || '-' }} fz="sm" c="gray.7" lineClamp={2} /> <Text dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.description || '-') }} fz="sm" c="gray.7" lineClamp={2} />
</Box> </Box>
{/* Link */} {/* Link */}

View File

@@ -82,17 +82,17 @@ function EditDaftarInformasiPublik() {
await daftarInformasi.edit.update(); await daftarInformasi.edit.update();
router.push('/admin/ppid/daftar-informasi-publik'); router.push('/admin/ppid/daftar-informasi-publik');
} catch (error) { } catch (error) {
console.error('Error updating berita:', error); console.error('Error updating daftar informasi:', error);
toast.error('Terjadi kesalahan saat memperbarui berita'); toast.error('Terjadi kesalahan saat memperbarui daftar informasi');
} }
}; };
return ( return (
<Box px={{ base: 0, md: 'lg' }} py="xs"> <Box px={{ base: 0, md: 'lg' }} py="xs">
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
<Title order={4} ml="sm" c="dark"> <Title order={4} ml="sm" c="dark">
Edit Daftar Informasi Publik Edit Daftar Informasi Publik
</Title> </Title>

View File

@@ -6,6 +6,7 @@ import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import stateDasarHukumPPID from '../../_state/ppid/dasar_hukum/dasarHukum'; import stateDasarHukumPPID from '../../_state/ppid/dasar_hukum/dasarHukum';
import DOMPurify from 'dompurify';
function Page() { function Page() {
const router = useRouter(); const router = useRouter();
@@ -68,7 +69,7 @@ function Page() {
lh={{ base: 1.15, md: 1.1 }} lh={{ base: 1.15, md: 1.1 }}
fw="bold" fw="bold"
c={colors['blue-button']} c={colors['blue-button']}
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.judul }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listDasarHukum.findById.data.judul) }}
style={{ wordBreak: 'break-word', whiteSpace: 'normal' }} style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}
/> />
</GridCol> </GridCol>
@@ -77,7 +78,7 @@ function Page() {
<Divider my="xl" color={colors['blue-button']} /> <Divider my="xl" color={colors['blue-button']} />
<Text <Text
dangerouslySetInnerHTML={{ __html: listDasarHukum.findById.data.content }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listDasarHukum.findById.data.content) }}
style={{ style={{
wordBreak: 'break-word', wordBreak: 'break-word',
whiteSpace: 'normal', whiteSpace: 'normal',

View File

@@ -6,6 +6,7 @@ import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import stateProfilePPID from '../../_state/ppid/profile_ppid/profile_PPID'; import stateProfilePPID from '../../_state/ppid/profile_ppid/profile_PPID';
import DOMPurify from 'dompurify';
function Page() { function Page() {
const router = useRouter(); const router = useRouter();
@@ -114,7 +115,7 @@ function Page() {
c={colors['blue-button']} c={colors['blue-button']}
lh={1.5} lh={1.5}
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.biodata }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.biodata) }}
/> />
</Box> </Box>
@@ -129,7 +130,7 @@ function Page() {
c={colors['blue-button']} c={colors['blue-button']}
lh={1.5} lh={1.5}
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.riwayat }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.riwayat) }}
/> />
</Box> </Box>
</Box> </Box>
@@ -145,7 +146,7 @@ function Page() {
c={colors['blue-button']} c={colors['blue-button']}
lh={1.5} lh={1.5}
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.pengalaman }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.pengalaman) }}
/> />
</Box> </Box>
</Box> </Box>
@@ -161,7 +162,7 @@ function Page() {
c={colors['blue-button']} c={colors['blue-button']}
lh={1.5} lh={1.5}
style={{ wordBreak: "break-word", whiteSpace: "normal" }} style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: item.unggulan }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.unggulan) }}
/> />
</Box> </Box>
</Box> </Box>

View File

@@ -9,6 +9,7 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import stateStrukturPPID from '../../../_state/ppid/struktur_ppid/struktur_PPID'; import stateStrukturPPID from '../../../_state/ppid/struktur_ppid/struktur_PPID';
import DOMPurify from 'dompurify';
function PosisiOrganisasiPPID() { function PosisiOrganisasiPPID() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -100,7 +101,7 @@ function ListPosisiOrganisasiPPID({ search }: { search: string }) {
<Text fz="md" fw={600} lh={1.5} truncate="end" lineClamp={1}>{item.nama}</Text> <Text fz="md" fw={600} lh={1.5} truncate="end" lineClamp={1}>{item.nama}</Text>
</TableTd> </TableTd>
<TableTd w={200}> <TableTd w={200}>
<Text fz="sm" lh={1.5} c="dimmed" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi || '-' }} /> <Text fz="sm" lh={1.5} c="dimmed" lineClamp={1} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.deskripsi || '-') }} />
</TableTd> </TableTd>
<TableTd> <TableTd>
<Text fz="md" lh={1.5}>{item.hierarki || '-'}</Text> <Text fz="md" lh={1.5}>{item.hierarki || '-'}</Text>

View File

@@ -6,6 +6,7 @@ import { IconEdit } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import stateVisiMisiPPID from '../../_state/ppid/visi_misi_ppid/visimisiPPID'; import stateVisiMisiPPID from '../../_state/ppid/visi_misi_ppid/visimisiPPID';
import DOMPurify from 'dompurify'
function VisiMisiPPIDList() { function VisiMisiPPIDList() {
const router = useRouter(); const router = useRouter();
@@ -96,7 +97,7 @@ function VisiMisiPPIDList() {
</Title> </Title>
<Text <Text
ta={{ base: "center", md: "justify" }} ta={{ base: "center", md: "justify" }}
dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.visi }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listVisiMisi.findById.data.visi) }}
style={{ style={{
wordBreak: 'break-word', wordBreak: 'break-word',
whiteSpace: 'normal', whiteSpace: 'normal',
@@ -121,7 +122,7 @@ function VisiMisiPPIDList() {
</Title> </Title>
<Text <Text
ta={"justify"} ta={"justify"}
dangerouslySetInnerHTML={{ __html: listVisiMisi.findById.data.misi }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(listVisiMisi.findById.data.misi) }}
style={{ style={{
wordBreak: 'break-word', wordBreak: 'break-word',
whiteSpace: 'normal', whiteSpace: 'normal',

View File

@@ -354,7 +354,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
borderLeft: `2px solid ${tokens.colors.primary}`, borderLeft: `2px solid ${tokens.colors.primary}`,
}), }),
...(mounted && isChildActive && !isDark && { ...(mounted && isChildActive && !isDark && {
backgroundColor: tokens.colors.bg.hover, backgroundColor: 'rgba(25, 113, 194, 0.1)',
borderLeft: `2px solid ${tokens.colors.primary}`,
}), }),
} }
}} }}

View File

@@ -0,0 +1,40 @@
import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/api-auth";
export default async function sejarahDesaFindFirst(request: Request) {
// ✅ Authentication check
const headers = new Headers(request.url);
const authResult = await requireAuth({ headers });
if (!authResult.authenticated) {
return authResult.response;
}
try {
// Get the first active record
const data = await prisma.sejarahDesa.findFirst({
where: {
isActive: true,
deletedAt: null
},
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 (context) => {
const response = await sejarahDesaFindFirst(new Request(context.request));
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;

View File

@@ -1,7 +1,14 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/api-auth";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function sejarahDesaUpdate(context: Context) { export default async function sejarahDesaUpdate(context: Context) {
// ✅ Authentication check
const authResult = await requireAuth(context);
if (!authResult.authenticated) {
return authResult.response;
}
try { try {
const id = context.params?.id as string; const id = context.params?.id as string;
const body = await context.body as { const body = await context.body as {

View File

@@ -10,8 +10,7 @@ import {
SimpleGrid, SimpleGrid,
Skeleton, Skeleton,
Stack, Stack,
Text, Text
useMantineColorScheme
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
@@ -24,8 +23,6 @@ type ProgramInovasiItem = Prisma.ProgramInovasiGetPayload<{ include: { image: tr
function ModuleItem({ data }: { data: ProgramInovasiItem }) { function ModuleItem({ data }: { data: ProgramInovasiItem }) {
const router = useTransitionRouter(); const router = useTransitionRouter();
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === "dark";
return ( return (
<motion.div whileHover={{ scale: 1.03 }}> <motion.div whileHover={{ scale: 1.03 }}>
@@ -37,7 +34,7 @@ function ModuleItem({ data }: { data: ProgramInovasiItem }) {
role="button" role="button"
tabIndex={0} tabIndex={0}
className="cursor-pointer transition-all" className="cursor-pointer transition-all"
bg={isDark ? "dark.6" : "white"} bg="white"
> >
<Center h={160}> <Center h={160}>
{data.image?.link ? ( {data.image?.link ? (

View File

@@ -1,3 +1,5 @@
"use client";
import colors from "@/con/colors"; import colors from "@/con/colors";
import { Box, Space, Stack } from "@mantine/core"; import { Box, Space, Stack } from "@mantine/core";
@@ -5,21 +7,20 @@ import { Navbar } from "@/app/darmasaba/_com/Navbar";
import Footer from "./_com/Footer"; import Footer from "./_com/Footer";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<Stack gap={0} bg={colors.grey[1]}> <Stack gap={0} bg={colors.grey[1]}>
<Navbar /> <Navbar />
<Space h={{ <Space h={{
base: "3.9rem", base: "3.9rem",
md: "2.5rem" md: "2.5rem"
}} /> }} />
<Box style={{ <Box style={{
overflow: "scroll" overflow: "scroll"
}}> }}>
{children} {children}
</Box> </Box>
<Footer /> <Footer />
</Stack> </Stack>
) )
} }

View File

@@ -98,16 +98,16 @@ export default function RootLayout({
<html lang="id" {...mantineHtmlProps}> <html lang="id" {...mantineHtmlProps}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<ColorSchemeScript /> <ColorSchemeScript defaultColorScheme="light" />
</head> </head>
<body> <body>
<MantineProvider theme={theme}> <MantineProvider theme={theme} defaultColorScheme="light">
{children} {children}
<LoadDataFirstClient /> <LoadDataFirstClient />
<ToastContainer <ToastContainer
position="bottom-center" position="bottom-center"
hideProgressBar hideProgressBar
style={{ zIndex: 9999 }} style={{ zIndex: 9999 }}
/> />
</MantineProvider> </MantineProvider>
</body> </body>

84
src/lib/api-auth.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* Authentication helper untuk API endpoints
*
* Usage:
* import { requireAuth } from "@/lib/api-auth";
*
* export default async function myEndpoint(context: Context) {
* const authResult = await requireAuth(context);
* if (!authResult.authenticated) {
* return authResult.response;
* }
* // Lanjut proses dengan authResult.user
* }
*/
import { getSession } from "@/lib/session";
export type AuthResult =
| { authenticated: true; user: any }
| { authenticated: false; response: Response };
export async function requireAuth(context: any): Promise<AuthResult> {
try {
// Cek session dari cookies
const session = await getSession();
if (!session || !session.user) {
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Unauthorized - Silakan login terlebih dahulu"
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
};
}
// Check jika user masih aktif
if (!session.user.isActive) {
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Akun Anda tidak aktif. Hubungi administrator."
}), {
status: 403,
headers: { 'Content-Type': 'application/json' }
})
};
}
return {
authenticated: true,
user: session.user
};
} catch (error) {
console.error("Auth error:", error);
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Authentication error"
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
};
}
}
/**
* Optional auth - tidak error jika tidak authenticated
* Berguna untuk endpoint yang bisa diakses public atau private
*/
export async function optionalAuth(context: any): Promise<any> {
try {
const session = await getSession();
return session?.user || null;
} catch (error) {
return null;
}
}

68
src/lib/session.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Session helper menggunakan iron-session
*
* Usage:
* import { getSession } from "@/lib/session";
*
* const session = await getSession();
* if (session?.user) {
* // User authenticated
* }
*/
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
export type SessionData = {
user?: {
id: string;
name: string;
roleId: number;
menuIds?: string[] | null;
isActive?: boolean;
};
};
export type Session = SessionData & {
save: () => Promise<void>;
destroy: () => Promise<void>;
};
const SESSION_OPTIONS = {
cookieName: 'desa-session',
password: process.env.SESSION_PASSWORD || 'default-password-change-in-production',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24 * 7, // 7 days
},
};
export async function getSession(): Promise<SessionData | null> {
try {
const cookieStore = await cookies();
const session = await getIronSession<SessionData>(
cookieStore,
SESSION_OPTIONS
);
return session;
} catch (error) {
console.error('Session error:', error);
return null;
}
}
export async function destroySession(): Promise<void> {
try {
const cookieStore = await cookies();
const session = await getIronSession<SessionData>(
cookieStore,
SESSION_OPTIONS
);
await session.destroy();
} catch (error) {
console.error('Destroy session error:', error);
}
}

View File

@@ -21,17 +21,18 @@ import { proxy, useSnapshot } from 'valtio';
const STORAGE_KEY = 'darmasaba-admin-dark-mode'; const STORAGE_KEY = 'darmasaba-admin-dark-mode';
// Initialize from localStorage or system preference // Initialize from localStorage or default to light mode
const getInitialDarkMode = (): boolean => { const getInitialDarkMode = (): boolean => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) { if (stored !== null) {
return stored === 'true'; return stored === 'true';
} }
// Fallback to system preference // Default to light mode for first-time users
return window.matchMedia('(prefers-color-scheme: dark)').matches; // System preference is NOT used as default to ensure consistent UX
return false;
}; };
class DarkModeStore { class DarkModeStore {