Compare commits
5 Commits
nico/3-des
...
nico/8-des
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dbe172165 | |||
| cc318d4d54 | |||
| dcb8017594 | |||
| ec3ad12531 | |||
| dad44c0537 |
@@ -828,11 +828,11 @@ model DokterdanTenagaMedis {
|
|||||||
name String
|
name String
|
||||||
specialist String
|
specialist String
|
||||||
jadwal String
|
jadwal String
|
||||||
jadwalLibur String
|
jadwalLibur String?
|
||||||
jamBukaOperasional String
|
jamBukaOperasional String?
|
||||||
jamTutupOperasional String
|
jamTutupOperasional String?
|
||||||
jamBukaLibur String
|
jamBukaLibur String?
|
||||||
jamTutupLibur String
|
jamTutupLibur String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime @default(now())
|
||||||
|
|||||||
BIN
public/mangupuraaward.jpeg
Normal file
BIN
public/mangupuraaward.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
@@ -6,145 +6,176 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const templateForm = z.object({
|
const templateForm = z.object({
|
||||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||||
nik: z.string().min(3, "NIK minimal 3 karakter"),
|
nik: z
|
||||||
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
|
.string()
|
||||||
|
.min(3, "NIK minimal 3 karakter")
|
||||||
|
.max(16, "NIK maksimal 16 angka"),
|
||||||
|
notelp: z
|
||||||
|
.string()
|
||||||
|
.min(3, "Nomor Telepon minimal 3 karakter")
|
||||||
|
.max(15, "Nomor Telepon maksimal 15 angka"),
|
||||||
alamat: z.string().min(3, "Alamat minimal 3 karakter"),
|
alamat: z.string().min(3, "Alamat minimal 3 karakter"),
|
||||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||||
jenisInformasiDimintaId: z.string().nonempty(),
|
jenisInformasiDimintaId: z.string().nonempty(),
|
||||||
caraMemperolehInformasiId: z.string().nonempty(),
|
caraMemperolehInformasiId: z.string().nonempty(),
|
||||||
caraMemperolehSalinanInformasiId: z.string().nonempty(),
|
caraMemperolehSalinanInformasiId: z.string().nonempty(),
|
||||||
})
|
});
|
||||||
|
|
||||||
const jenisInformasiDiminta = proxy({
|
const jenisInformasiDiminta = proxy({
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| null
|
| null
|
||||||
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
|
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
|
||||||
async load(){
|
async load() {
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
|
const res =
|
||||||
if (res.status === 200) {
|
await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
|
||||||
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
|
"find-many"
|
||||||
}
|
].get();
|
||||||
}
|
if (res.status === 200) {
|
||||||
}
|
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
|
||||||
})
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const caraMemperolehInformasi = proxy({
|
const caraMemperolehInformasi = proxy({
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| null
|
| null
|
||||||
| Prisma.CaraMemperolehInformasiGetPayload<{ omit: { isActive: true } }>[],
|
| Prisma.CaraMemperolehInformasiGetPayload<{
|
||||||
async load() {
|
omit: { isActive: true };
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get();
|
}>[],
|
||||||
if (res.status === 200) {
|
async load() {
|
||||||
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
|
const res =
|
||||||
}
|
await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[
|
||||||
}
|
"find-many"
|
||||||
}
|
].get();
|
||||||
})
|
if (res.status === 200) {
|
||||||
|
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const caraMemperolehSalinanInformasi = proxy({
|
const caraMemperolehSalinanInformasi = proxy({
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as
|
||||||
| null
|
| null
|
||||||
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{ omit: { isActive: true } }>[],
|
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{
|
||||||
async load() {
|
omit: { isActive: true };
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get();
|
}>[],
|
||||||
if (res.status === 200) {
|
async load() {
|
||||||
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
|
const res =
|
||||||
}
|
await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
|
||||||
}
|
"find-many"
|
||||||
}
|
].get();
|
||||||
})
|
if (res.status === 200) {
|
||||||
console.log(caraMemperolehSalinanInformasi)
|
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(caraMemperolehSalinanInformasi);
|
||||||
|
|
||||||
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{
|
type PermohonanInformasiPublikForm =
|
||||||
|
Prisma.PermohonanInformasiPublikGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
name: true;
|
name: true;
|
||||||
nik: true;
|
nik: true;
|
||||||
notelp: true;
|
notelp: true;
|
||||||
alamat: true;
|
alamat: true;
|
||||||
email: true;
|
email: true;
|
||||||
jenisInformasiDimintaId: true;
|
jenisInformasiDimintaId: true;
|
||||||
caraMemperolehInformasiId: true;
|
caraMemperolehInformasiId: true;
|
||||||
caraMemperolehSalinanInformasiId: true;
|
caraMemperolehSalinanInformasiId: true;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const statepermohonanInformasiPublik = proxy({
|
const statepermohonanInformasiPublik = proxy({
|
||||||
create: {
|
create: {
|
||||||
form: {} as PermohonanInformasiPublikForm,
|
form: {} as PermohonanInformasiPublikForm,
|
||||||
loading: false,
|
loading: false,
|
||||||
async create(){
|
async create() {
|
||||||
const cek = templateForm.safeParse(statepermohonanInformasiPublik.create.form);
|
const cek = templateForm.safeParse(
|
||||||
if(!cek.success) {
|
statepermohonanInformasiPublik.create.form
|
||||||
const err = `[${cek.error.issues
|
);
|
||||||
.map((v) => `${v.path.join(".")}`)
|
|
||||||
.join("\n")}] required`;
|
if (!cek.success) {
|
||||||
return toast.error(err);
|
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
|
||||||
}
|
return false; // ⬅️ tambahkan return false
|
||||||
try {
|
}
|
||||||
statepermohonanInformasiPublik.create.loading = true;
|
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
|
try {
|
||||||
if (res.status === 200) {
|
statepermohonanInformasiPublik.create.loading = true;
|
||||||
statepermohonanInformasiPublik.findMany.load();
|
const res = await ApiFetch.api.ppid.permohonaninformasipublik[
|
||||||
return toast.success("Sukses menambahkan");
|
"create"
|
||||||
}
|
].post(statepermohonanInformasiPublik.create.form);
|
||||||
return toast.error("failed create");
|
|
||||||
} catch (error) {
|
if (res.data?.success === false) {
|
||||||
console.log((error as Error).message);
|
toast.error(res.data?.message);
|
||||||
} finally {
|
return false; // ⬅️ gagal
|
||||||
statepermohonanInformasiPublik.create.loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success("Sukses menambahkan");
|
||||||
|
return true; // ⬅️ sukses
|
||||||
|
} catch {
|
||||||
|
toast.error("Terjadi kesalahan server");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
statepermohonanInformasiPublik.create.loading = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
findMany: {
|
},
|
||||||
data: null as
|
findMany: {
|
||||||
| Prisma.PermohonanInformasiPublikGetPayload<{ include: {
|
data: null as
|
||||||
caraMemperolehSalinanInformasi: true,
|
| Prisma.PermohonanInformasiPublikGetPayload<{
|
||||||
jenisInformasiDiminta: true,
|
|
||||||
caraMemperolehInformasi: true,
|
|
||||||
} }>[]
|
|
||||||
| null,
|
|
||||||
async load() {
|
|
||||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get();
|
|
||||||
if (res.status === 200) {
|
|
||||||
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
findUnique: {
|
|
||||||
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
|
|
||||||
include: {
|
include: {
|
||||||
jenisInformasiDiminta: true,
|
caraMemperolehSalinanInformasi: true;
|
||||||
caraMemperolehInformasi: true,
|
jenisInformasiDiminta: true;
|
||||||
caraMemperolehSalinanInformasi: true,
|
caraMemperolehInformasi: true;
|
||||||
};
|
};
|
||||||
}> | null,
|
}>[]
|
||||||
async load(id: string) {
|
| null,
|
||||||
try {
|
async load() {
|
||||||
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
|
const res = await ApiFetch.api.ppid.permohonaninformasipublik[
|
||||||
if (res.ok) {
|
"find-many"
|
||||||
const data = await res.json();
|
].get();
|
||||||
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
|
if (res.status === 200) {
|
||||||
} else {
|
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
|
||||||
console.error("Failed to fetch program inovasi:", res.statusText);
|
}
|
||||||
statepermohonanInformasiPublik.findUnique.data = null;
|
},
|
||||||
}
|
},
|
||||||
} catch (error) {
|
findUnique: {
|
||||||
console.error("Error fetching program inovasi:", error);
|
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
|
||||||
statepermohonanInformasiPublik.findUnique.data = null;
|
include: {
|
||||||
}
|
jenisInformasiDiminta: true;
|
||||||
},
|
caraMemperolehInformasi: true;
|
||||||
},
|
caraMemperolehSalinanInformasi: true;
|
||||||
|
};
|
||||||
})
|
}> | null,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch program inovasi:", res.statusText);
|
||||||
|
statepermohonanInformasiPublik.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching program inovasi:", error);
|
||||||
|
statepermohonanInformasiPublik.findUnique.data = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const statepermohonanInformasiPublikForm = proxy({
|
const statepermohonanInformasiPublikForm = proxy({
|
||||||
statepermohonanInformasiPublik,
|
statepermohonanInformasiPublik,
|
||||||
jenisInformasiDiminta,
|
jenisInformasiDiminta,
|
||||||
caraMemperolehInformasi,
|
caraMemperolehInformasi,
|
||||||
caraMemperolehSalinanInformasi,
|
caraMemperolehSalinanInformasi,
|
||||||
})
|
});
|
||||||
|
|
||||||
export default statepermohonanInformasiPublikForm;
|
export default statepermohonanInformasiPublikForm;
|
||||||
|
|||||||
@@ -5,82 +5,99 @@ import { proxy } from "valtio";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const templateForm = z.object({
|
const templateForm = z.object({
|
||||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||||
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
|
notelp: z
|
||||||
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
|
.string()
|
||||||
})
|
.min(3, "Nomor Telepon minimal 3 karakter")
|
||||||
|
.max(15, "Nomor Telepon maksimal 15 angka"),
|
||||||
|
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
|
||||||
|
});
|
||||||
|
|
||||||
type PermohonanKeberatanInformasiForm = Prisma.FormulirPermohonanKeberatanGetPayload<{
|
type PermohonanKeberatanInformasiForm =
|
||||||
|
Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
name: true;
|
name: true;
|
||||||
email: true;
|
email: true;
|
||||||
notelp: true;
|
notelp: true;
|
||||||
alasan: true;
|
alasan: true;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const permohonanKeberatanInformasi = proxy({
|
const permohonanKeberatanInformasi = proxy({
|
||||||
create: {
|
create: {
|
||||||
form: {} as PermohonanKeberatanInformasiForm,
|
form: {} as PermohonanKeberatanInformasiForm,
|
||||||
loading: false,
|
loading: false,
|
||||||
async create(){
|
async create() {
|
||||||
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form);
|
const cek = templateForm.safeParse(
|
||||||
if(!cek.success) {
|
permohonanKeberatanInformasi.create.form
|
||||||
const err = `[${cek.error.issues
|
);
|
||||||
.map((v) => `${v.path.join(".")}`)
|
if (!cek.success) {
|
||||||
.join("\n")}] required`;
|
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
|
||||||
return toast.error(err);
|
return false; // ⬅️ tambahkan return false
|
||||||
}
|
|
||||||
try {
|
|
||||||
permohonanKeberatanInformasi.create.loading = true;
|
|
||||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
|
|
||||||
if (res.status === 200) {
|
|
||||||
permohonanKeberatanInformasi.findMany.load();
|
|
||||||
return toast.success("Sukses menambahkan");
|
|
||||||
}
|
|
||||||
return toast.error("failed create");
|
|
||||||
} catch (error) {
|
|
||||||
console.log((error as Error).message);
|
|
||||||
} finally {
|
|
||||||
permohonanKeberatanInformasi.create.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
findMany: {
|
|
||||||
data: null as
|
|
||||||
| Prisma.FormulirPermohonanKeberatanGetPayload<{omit: {isActive: true}}>[]
|
|
||||||
| null,
|
|
||||||
async load() {
|
|
||||||
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get();
|
|
||||||
if (res.status === 200) {
|
|
||||||
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
findUnique: {
|
|
||||||
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
|
|
||||||
omit: {
|
|
||||||
isActive: true;
|
|
||||||
};
|
|
||||||
}> | null,
|
|
||||||
async load(id: string) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
|
|
||||||
} else {
|
|
||||||
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
|
|
||||||
permohonanKeberatanInformasi.findUnique.data = null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching permohonan keberatan informasi:", error);
|
|
||||||
permohonanKeberatanInformasi.findUnique.data = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
permohonanKeberatanInformasi.create.loading = true;
|
||||||
|
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
|
||||||
|
"create"
|
||||||
|
].post(permohonanKeberatanInformasi.create.form);
|
||||||
|
if (res.data?.success === false) {
|
||||||
|
toast.error(res.data?.message);
|
||||||
|
return false; // ⬅️ gagal
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Sukses menambahkan");
|
||||||
|
return true; // ⬅️ sukses
|
||||||
|
} catch {
|
||||||
|
toast.error("Terjadi kesalahan server");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
permohonanKeberatanInformasi.create.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
findMany: {
|
||||||
|
data: null as
|
||||||
|
| Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||||
|
omit: { isActive: true };
|
||||||
|
}>[]
|
||||||
|
| null,
|
||||||
|
async load() {
|
||||||
|
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
|
||||||
|
"find-many"
|
||||||
|
].get();
|
||||||
|
if (res.status === 200) {
|
||||||
|
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
findUnique: {
|
||||||
|
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||||
|
omit: {
|
||||||
|
isActive: true;
|
||||||
|
};
|
||||||
|
}> | null,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/ppid/permohonankeberataninformasipublik/${id}`
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch permohonan keberatan informasi:",
|
||||||
|
res.statusText
|
||||||
|
);
|
||||||
|
permohonanKeberatanInformasi.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching permohonan keberatan informasi:", error);
|
||||||
|
permohonanKeberatanInformasi.findUnique.data = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default permohonanKeberatanInformasi;
|
export default permohonanKeberatanInformasi;
|
||||||
|
|
||||||
|
|||||||
303
src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx
Normal file
303
src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
|
||||||
|
import stateGallery from "@/app/admin/(dashboard)/_state/desa/gallery";
|
||||||
|
import colors from "@/con/colors";
|
||||||
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
|
import {
|
||||||
|
IconArrowBack,
|
||||||
|
IconPhoto,
|
||||||
|
IconUpload,
|
||||||
|
IconX,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useProxy } from "valtio/utils";
|
||||||
|
|
||||||
|
function EditFoto() {
|
||||||
|
const FotoState = useProxy(stateGallery.foto);
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
deskripsi: "",
|
||||||
|
imagesId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [originalData, setOriginalData] = useState({
|
||||||
|
name: "",
|
||||||
|
deskripsi: "",
|
||||||
|
imagesId: "",
|
||||||
|
imageUrl: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load kategori + Foto
|
||||||
|
useEffect(() => {
|
||||||
|
FotoState.findMany.load();
|
||||||
|
|
||||||
|
const loadFoto = async () => {
|
||||||
|
const id = params?.id as string;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await FotoState.update.load(id);
|
||||||
|
if (data) {
|
||||||
|
setFormData({
|
||||||
|
name: data.name || "",
|
||||||
|
deskripsi: data.deskripsi || "",
|
||||||
|
imagesId: data.imagesId || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setOriginalData({
|
||||||
|
name: data.name || "",
|
||||||
|
deskripsi: data.deskripsi || "",
|
||||||
|
imagesId: data.imagesId || "",
|
||||||
|
imageUrl: data.imageGalleryFoto?.link || ""
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data?.imageGalleryFoto?.link) {
|
||||||
|
setPreviewImage(data.imageGalleryFoto.link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading Foto:", error);
|
||||||
|
toast.error("Gagal memuat data Foto");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFoto();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
// Update global state hanya sekali di sini
|
||||||
|
FotoState.update.form = {
|
||||||
|
...FotoState.update.form,
|
||||||
|
...formData,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
});
|
||||||
|
const uploaded = res.data?.data;
|
||||||
|
|
||||||
|
if (!uploaded?.id) {
|
||||||
|
return toast.error("Gagal upload gambar");
|
||||||
|
}
|
||||||
|
|
||||||
|
FotoState.update.form.imagesId = uploaded.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await FotoState.update.update();
|
||||||
|
toast.success("Foto berhasil diperbarui!");
|
||||||
|
router.push("/admin/desa/gallery/foto");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating foto:", error);
|
||||||
|
toast.error("Terjadi kesalahan saat memperbarui foto");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: originalData.name,
|
||||||
|
deskripsi: originalData.deskripsi,
|
||||||
|
imagesId: originalData.imagesId,
|
||||||
|
});
|
||||||
|
setPreviewImage(originalData.imageUrl || null);
|
||||||
|
setFile(null);
|
||||||
|
toast.info("Form dikembalikan ke data awal");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: "sm", md: "lg" }} py="md">
|
||||||
|
{/* Header */}
|
||||||
|
<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 Foto
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<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 Foto"
|
||||||
|
placeholder="Masukkan judul foto"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleChange("name", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Upload Gambar */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Gambar Foto
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={(files) => {
|
||||||
|
const selectedFile = files[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
setFile(selectedFile);
|
||||||
|
setPreviewImage(URL.createObjectURL(selectedFile));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onReject={() =>
|
||||||
|
toast.error("File tidak valid, gunakan format gambar")
|
||||||
|
}
|
||||||
|
maxSize={5 * 1024 ** 2}
|
||||||
|
accept={{ "image/*": [] }}
|
||||||
|
radius="md"
|
||||||
|
p="xl"
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload
|
||||||
|
size={48}
|
||||||
|
color={colors["blue-button"]}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={48} color="red" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
<Stack gap="xs" align="center">
|
||||||
|
<Text size="md" fw={500}>
|
||||||
|
Seret gambar atau klik untuk memilih file
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{previewImage && (
|
||||||
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
|
<Image
|
||||||
|
src={previewImage}
|
||||||
|
alt="Preview Gambar"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: 220,
|
||||||
|
objectFit: "contain",
|
||||||
|
border: `1px solid ${colors["blue-button"]}`,
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewImage(null);
|
||||||
|
setFile(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
|
<Box>
|
||||||
|
<Text fz="sm" fw="bold">
|
||||||
|
Deskripsi Foto
|
||||||
|
</Text>
|
||||||
|
<EditEditor
|
||||||
|
value={formData.deskripsi}
|
||||||
|
onChange={(htmlContent) =>
|
||||||
|
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<Group justify="right">
|
||||||
|
{/* Tombol Batal */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={handleResetForm}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
style={{
|
||||||
|
background: `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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditFoto;
|
||||||
175
src/app/admin/(dashboard)/desa/gallery/foto/[id]/page.tsx
Normal file
175
src/app/admin/(dashboard)/desa/gallery/foto/[id]/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Alert } from '@mantine/core';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconArrowBack, IconEdit, IconTrash, IconPhoto } from '@tabler/icons-react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
|
||||||
|
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||||
|
|
||||||
|
function DetailFoto() {
|
||||||
|
const FotoState = useProxy(stateGallery.foto);
|
||||||
|
const [modalHapus, setModalHapus] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
FotoState.findUnique.load(params?.id as string);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleHapus = () => {
|
||||||
|
if (selectedId) {
|
||||||
|
FotoState.delete.byId(selectedId);
|
||||||
|
setModalHapus(false);
|
||||||
|
setSelectedId(null);
|
||||||
|
router.push("/admin/desa/gallery/foto");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!FotoState.findUnique.data) {
|
||||||
|
return (
|
||||||
|
<Stack py={10}>
|
||||||
|
<Skeleton height={500} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = FotoState.findUnique.data;
|
||||||
|
const imageUrl = data.imageGalleryFoto?.link;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py={10}>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
|
||||||
|
mb={15}
|
||||||
|
>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
// Gunakan max-width agar tidak terlalu lebar di desktop
|
||||||
|
maw={800}
|
||||||
|
w="100%"
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fz={{ base: 'xl', md: '2xl' }} fw="bold" c={colors['blue-button']}>
|
||||||
|
Detail Foto
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Box>
|
||||||
|
<Text fz="lg" fw="bold">Judul Foto</Text>
|
||||||
|
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||||
|
<Text
|
||||||
|
fz="md"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fz="lg" fw="bold">Gambar</Text>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Box
|
||||||
|
pos="relative"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '600px', // Set a maximum width
|
||||||
|
margin: '0 auto', // Center the container
|
||||||
|
aspectRatio: '16/9', // Use 16:9 aspect ratio
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={imageUrl}
|
||||||
|
alt={data.name || 'Gambar Foto'}
|
||||||
|
fill
|
||||||
|
style={{
|
||||||
|
objectFit: 'contain', // Changed from 'cover' to 'contain' to show full image
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : imageError ? (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
icon={<IconPhoto size={16} />}
|
||||||
|
title="Gagal memuat gambar"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Gambar tidak dapat ditampilkan.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<Group gap="sm" justify="flex-start">
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(data.id);
|
||||||
|
setModalHapus(true);
|
||||||
|
}}
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<IconTrash size={20} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
onClick={() => router.push(`/admin/desa/gallery/foto/${data.id}/edit`)}
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<IconEdit size={20} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<ModalKonfirmasiHapus
|
||||||
|
opened={modalHapus}
|
||||||
|
onClose={() => setModalHapus(false)}
|
||||||
|
onConfirm={handleHapus}
|
||||||
|
text="Apakah Anda yakin ingin menghapus foto ini?"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetailFoto;
|
||||||
228
src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx
Normal file
228
src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
'use client';
|
||||||
|
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
||||||
|
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import ApiFetch from '@/lib/api-fetch';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
Loader,
|
||||||
|
Image
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
|
||||||
|
function CreateFoto() {
|
||||||
|
const FotoState = useProxy(stateGallery.foto);
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
FotoState.create.form = {
|
||||||
|
name: '',
|
||||||
|
deskripsi: '',
|
||||||
|
imagesId: '',
|
||||||
|
};
|
||||||
|
setPreviewImage(null);
|
||||||
|
setFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
if (!file) {
|
||||||
|
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploaded = res.data?.data;
|
||||||
|
if (!uploaded?.id) {
|
||||||
|
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
|
||||||
|
}
|
||||||
|
|
||||||
|
FotoState.create.form.imagesId = uploaded.id;
|
||||||
|
|
||||||
|
await FotoState.create.create();
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
router.push('/admin/desa/gallery/foto');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating foto:', error);
|
||||||
|
toast.error('Terjadi kesalahan saat membuat foto');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||||
|
{/* Header Back Button + Title */}
|
||||||
|
<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 Foto
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Card Form */}
|
||||||
|
<Paper
|
||||||
|
w={{ base: '100%', md: '50%' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
shadow="sm"
|
||||||
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Judul */}
|
||||||
|
<TextInput
|
||||||
|
label="Judul Foto"
|
||||||
|
placeholder="Masukkan judul Foto"
|
||||||
|
value={FotoState.create.form.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
FotoState.create.form.name = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Gambar Berita
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={(files) => {
|
||||||
|
const selectedFile = files[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
setFile(selectedFile);
|
||||||
|
setPreviewImage(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>
|
||||||
|
|
||||||
|
{previewImage && (
|
||||||
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
|
<Image
|
||||||
|
src={previewImage}
|
||||||
|
alt="Preview Gambar"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: 200,
|
||||||
|
objectFit: 'contain',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tombol hapus (pojok kanan atas) */}
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewImage(null);
|
||||||
|
setFile(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Deskripsi Foto
|
||||||
|
</Text>
|
||||||
|
<CreateEditor
|
||||||
|
value={FotoState.create.form.deskripsi}
|
||||||
|
onChange={(val) => {
|
||||||
|
FotoState.create.form.deskripsi = val;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Button Submit */}
|
||||||
|
<Group justify="right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Tombol Simpan */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
style={{
|
||||||
|
background: `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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateFoto;
|
||||||
@@ -1,157 +1,163 @@
|
|||||||
"use client";
|
'use client'
|
||||||
import stateFileStorage from "@/state/state-list-image";
|
import colors from '@/con/colors';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Button,
|
||||||
Flex,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
|
||||||
Pagination,
|
Pagination,
|
||||||
Paper,
|
Paper,
|
||||||
SimpleGrid,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
Title
|
Title
|
||||||
} from "@mantine/core";
|
} from '@mantine/core';
|
||||||
import { useShallowEffect } from "@mantine/hooks";
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
|
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||||
import { motion } from "framer-motion";
|
import { useRouter } from 'next/navigation';
|
||||||
import toast from "react-simple-toasts";
|
import { useState } from 'react';
|
||||||
import { useSnapshot } from "valtio";
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import HeaderSearch from '../../../_com/header';
|
||||||
export default function ListImage() {
|
import stateGallery from '../../../_state/desa/gallery';
|
||||||
const { list, total } = useSnapshot(stateFileStorage);
|
|
||||||
|
|
||||||
useShallowEffect(() => {
|
|
||||||
stateFileStorage.load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
let timeOut: NodeJS.Timer;
|
|
||||||
|
|
||||||
|
function Foto() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
return (
|
return (
|
||||||
<Stack p="lg" gap="lg">
|
<Box>
|
||||||
<Flex justify="space-between" align="center" wrap="wrap" gap="md">
|
<HeaderSearch
|
||||||
<Title order={2} fw={700}>
|
title='Foto'
|
||||||
Galeri Foto
|
placeholder='Cari judul atau deskripsi foto...'
|
||||||
</Title>
|
searchIcon={<IconSearch size={20} />}
|
||||||
<TextInput
|
value={search}
|
||||||
radius="xl"
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
size="md"
|
/>
|
||||||
placeholder="Cari foto berdasarkan nama..."
|
<ListFoto search={search} />
|
||||||
leftSection={<IconSearch size={18} />}
|
</Box>
|
||||||
rightSection={
|
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
color="gray"
|
|
||||||
radius="xl"
|
|
||||||
onClick={() => stateFileStorage.load()}
|
|
||||||
>
|
|
||||||
<IconX size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (timeOut) clearTimeout(timeOut);
|
|
||||||
timeOut = setTimeout(() => {
|
|
||||||
stateFileStorage.load({ search: e.target.value });
|
|
||||||
}, 300);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Paper withBorder radius="lg" p="md" shadow="sm">
|
|
||||||
{list && list.length > 0 ? (
|
|
||||||
<SimpleGrid
|
|
||||||
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
|
|
||||||
spacing="md"
|
|
||||||
verticalSpacing="md"
|
|
||||||
>
|
|
||||||
{list.map((v, k) => (
|
|
||||||
<Card
|
|
||||||
key={k}
|
|
||||||
withBorder
|
|
||||||
radius="md"
|
|
||||||
shadow="sm"
|
|
||||||
className="hover:shadow-md transition-all duration-200"
|
|
||||||
>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<motion.div
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(v.url);
|
|
||||||
toast("Tautan foto berhasil disalin");
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={`${v.url}?size=200`}
|
|
||||||
alt={v.name}
|
|
||||||
radius="md"
|
|
||||||
h={120}
|
|
||||||
fit="cover"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Text size="sm" fw={500} lineClamp={2}>
|
|
||||||
{v.name}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Group justify="space-between" align="center" pt="xs">
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
radius="md"
|
|
||||||
onClick={() => {
|
|
||||||
stateFileStorage
|
|
||||||
.del({ id: v.id })
|
|
||||||
.finally(() => toast("Foto berhasil dihapus"));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconTrash size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
) : (
|
|
||||||
<Stack align="center" justify="center" py="xl" gap="sm">
|
|
||||||
<Image
|
|
||||||
src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png"
|
|
||||||
alt="Kosong"
|
|
||||||
w={120}
|
|
||||||
h={120}
|
|
||||||
fit="contain"
|
|
||||||
opacity={0.7}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<Text c="dimmed" ta="center">
|
|
||||||
Belum ada foto yang tersedia
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{total && total > 1 && (
|
|
||||||
<Flex justify="center">
|
|
||||||
<Pagination
|
|
||||||
total={total}
|
|
||||||
value={stateFileStorage.page} // Changed from page to value
|
|
||||||
size="md"
|
|
||||||
radius="md"
|
|
||||||
withEdges
|
|
||||||
onChange={(page) => {
|
|
||||||
stateFileStorage.load({ page });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ListFoto({ search }: { search: string }) {
|
||||||
|
const FotoState = useProxy(stateGallery.foto)
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
load,
|
||||||
|
} = FotoState.findMany;
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 10, search)
|
||||||
|
}, [page, search])
|
||||||
|
|
||||||
|
const filteredData = data || []
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<Stack py={10}>
|
||||||
|
<Skeleton height={600} radius="md" />
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py={10}>
|
||||||
|
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Title order={4}>Daftar Foto</Title>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={18} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => router.push('/admin/desa/gallery/foto/create')}
|
||||||
|
>
|
||||||
|
Tambah Baru
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh style={{ width: '25%' }}>Judul Foto</TableTh>
|
||||||
|
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
|
||||||
|
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
|
||||||
|
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item) => (
|
||||||
|
<TableTr key={item.id}>
|
||||||
|
<TableTd style={{ width: '25%' }}>
|
||||||
|
<Box w={200}>
|
||||||
|
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
|
||||||
|
</Box>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd style={{ width: '20%' }}>
|
||||||
|
<Box w={200}>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString('id-ID', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd style={{ width: '30%' }}>
|
||||||
|
<Box w={200}>
|
||||||
|
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
||||||
|
</Box>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd style={{ width: '15%' }}>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
|
||||||
|
>
|
||||||
|
<IconDeviceImac size={20} />
|
||||||
|
<Text ml={5}>Detail</Text>
|
||||||
|
</Button>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableTr>
|
||||||
|
<TableTd colSpan={4}>
|
||||||
|
<Center py={20}>
|
||||||
|
<Text c="dimmed">Tidak ada foto yang cocok</Text>
|
||||||
|
</Center>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
)}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
<Center>
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => {
|
||||||
|
load(newPage, 10)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}}
|
||||||
|
total={totalPages}
|
||||||
|
mt="md"
|
||||||
|
mb="md"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Foto;
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ function Page() {
|
|||||||
return (
|
return (
|
||||||
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Grid align="center">
|
<Grid>
|
||||||
<GridCol span={{ base: 12, md: 11 }}>
|
<GridCol span={{ base: 12, md: 11 }}>
|
||||||
<Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title>
|
<Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
<GridCol span={{ base: 12, md: 1 }}>
|
<GridCol span={{ base: 12, md: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
|
w={{base: '100%', md: "110%"}}
|
||||||
c="green"
|
c="green"
|
||||||
variant="light"
|
variant="light"
|
||||||
leftSection={<IconEdit size={18} stroke={2} />}
|
leftSection={<IconEdit size={18} stroke={2} />}
|
||||||
|
|||||||
@@ -6,33 +6,24 @@ import path from "path";
|
|||||||
const beritaDelete = async (context: Context) => {
|
const beritaDelete = async (context: Context) => {
|
||||||
const id = context.params?.id as string;
|
const id = context.params?.id as string;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) return { status: 400, body: "ID tidak diberikan" };
|
||||||
return {
|
|
||||||
status: 400,
|
|
||||||
body: "ID tidak diberikan",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const berita = await prisma.berita.findUnique({
|
const berita = await prisma.berita.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: { image: true, kategoriBerita: true },
|
||||||
image: true,
|
|
||||||
kategoriBerita: true, // pastikan relasi image sudah ada di prisma schema
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!berita) {
|
if (!berita) return { status: 404, body: "Berita tidak ditemukan" };
|
||||||
return {
|
|
||||||
status: 404,
|
|
||||||
body: "Berita tidak ditemukan",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hapus file gambar dari filesystem jika ada
|
// 1. HAPUS BERITA DULU
|
||||||
|
await prisma.berita.delete({ where: { id } });
|
||||||
|
|
||||||
|
// 2. BARU HAPUS FILE
|
||||||
if (berita.image) {
|
if (berita.image) {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(berita.image.path, berita.image.name);
|
const filePath = path.join(berita.image.path, berita.image.name);
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
|
|
||||||
await prisma.fileStorage.delete({
|
await prisma.fileStorage.delete({
|
||||||
where: { id: berita.image.id },
|
where: { id: berita.image.id },
|
||||||
});
|
});
|
||||||
@@ -41,15 +32,11 @@ const beritaDelete = async (context: Context) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hapus berita dari DB
|
|
||||||
await prisma.berita.delete({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Berita dan file terkait berhasil dihapus",
|
message: "Berita dan file terkait berhasil dihapus",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default beritaDelete;
|
export default beritaDelete;
|
||||||
|
|||||||
@@ -3,39 +3,55 @@ import { Prisma } from "@prisma/client";
|
|||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
type FormCreate = Prisma.PermohonanInformasiPublikGetPayload<{
|
type FormCreate = Prisma.PermohonanInformasiPublikGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
name: true;
|
name: true;
|
||||||
nik: true;
|
nik: true;
|
||||||
email: true;
|
email: true;
|
||||||
notelp: true;
|
notelp: true;
|
||||||
alamat: true;
|
alamat: true;
|
||||||
jenisInformasiDimintaId: true;
|
jenisInformasiDimintaId: true;
|
||||||
caraMemperolehInformasiId: true;
|
caraMemperolehInformasiId: true;
|
||||||
caraMemperolehSalinanInformasiId: true;
|
caraMemperolehSalinanInformasiId: true;
|
||||||
}
|
};
|
||||||
}>
|
}>;
|
||||||
export default async function permohonanInformasiPublikCreate(context: Context) {
|
|
||||||
const body = context.body as FormCreate;
|
|
||||||
|
|
||||||
await prisma.permohonanInformasiPublik.create({
|
|
||||||
data: {
|
|
||||||
name: body.name,
|
|
||||||
nik: body.nik,
|
|
||||||
email: body.email,
|
|
||||||
notelp: body.notelp,
|
|
||||||
alamat: body.alamat,
|
|
||||||
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
|
|
||||||
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
|
|
||||||
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
export default async function permohonanInformasiPublikCreate(context: Context) {
|
||||||
|
const body = context.body as FormCreate;
|
||||||
|
|
||||||
|
// ========== VALIDASI NIK ==========
|
||||||
|
if (body.nik && body.nik.length > 16) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
message: "Permohonan Informasi Publik Berhasil Dibuat",
|
status: 400,
|
||||||
data: {
|
message: "Maksimal NIK adalah 16 angka",
|
||||||
...body,
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// ========== VALIDASI NOMOR TELEPON ==========
|
||||||
|
if (body.notelp && body.notelp.length > 15) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 400,
|
||||||
|
message: "Maksimal nomor telepon adalah 15 angka",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.permohonanInformasiPublik.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
nik: body.nik,
|
||||||
|
email: body.email,
|
||||||
|
notelp: body.notelp,
|
||||||
|
alamat: body.alamat,
|
||||||
|
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
|
||||||
|
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
|
||||||
|
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Permohonan Informasi Publik Berhasil Dibuat",
|
||||||
|
data: { ...body },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,31 +3,42 @@ import { Prisma } from "@prisma/client";
|
|||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
type FormCreate = Prisma.FormulirPermohonanKeberatanGetPayload<{
|
type FormCreate = Prisma.FormulirPermohonanKeberatanGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
name: true;
|
name: true;
|
||||||
email: true;
|
email: true;
|
||||||
notelp: true;
|
notelp: true;
|
||||||
alasan: true;
|
alasan: true;
|
||||||
}
|
};
|
||||||
}>
|
}>;
|
||||||
|
|
||||||
export default async function permohonanKeberatanInformasiPublikCreate(context: Context) {
|
export default async function permohonanKeberatanInformasiPublikCreate(
|
||||||
const body = context.body as FormCreate;
|
context: Context
|
||||||
|
) {
|
||||||
await prisma.formulirPermohonanKeberatan.create({
|
const body = context.body as FormCreate;
|
||||||
data: {
|
|
||||||
name: body.name,
|
|
||||||
email: body.email,
|
|
||||||
notelp: body.notelp,
|
|
||||||
alasan: body.alasan,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// ========== VALIDASI NOMOR TELEPON ==========
|
||||||
|
if (body.notelp && body.notelp.length > 15) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat",
|
status: 400,
|
||||||
data: {
|
message: "Maksimal nomor telepon adalah 15 angka",
|
||||||
...body,
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
await prisma.formulirPermohonanKeberatan.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
email: body.email,
|
||||||
|
notelp: body.notelp,
|
||||||
|
alasan: body.alasan,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat",
|
||||||
|
data: {
|
||||||
|
...body,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
43
src/app/api/news/latest/route.ts
Normal file
43
src/app/api/news/latest/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// app/api/news/latest/route.ts
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const berita = await prisma.berita.findMany({
|
||||||
|
take: 3,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { kategoriBerita: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pengumuman = await prisma.pengumuman.findMany({
|
||||||
|
take: 3,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { CategoryPengumuman: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const news = [
|
||||||
|
...berita.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
type: "berita" as const,
|
||||||
|
title: b.judul,
|
||||||
|
content: b.content,
|
||||||
|
timestamp: b.createdAt,
|
||||||
|
kategoriBerita: b.kategoriBerita || undefined,
|
||||||
|
})),
|
||||||
|
...pengumuman.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
type: "pengumuman" as const,
|
||||||
|
title: p.judul,
|
||||||
|
content: p.content,
|
||||||
|
timestamp: p.createdAt,
|
||||||
|
kategoriPengumuman: p.CategoryPengumuman || undefined,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, news }); // ✅ ganti 'data' jadi 'news'
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error:", error);
|
||||||
|
return NextResponse.json({ success: false, error: "Gagal memuat data" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ function Page() {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"} px={{ base: "md", md: 0 }}>
|
<Stack pos={"relative"} bg={colors.Bg} pb={"xl"} gap={"xs"} px={{ base: "md", md: 0 }}>
|
||||||
<Group px={{ base: "md", md: 100 }}>
|
<Group px={{ base: "md", md: 100 }}>
|
||||||
<NewsReader />
|
<NewsReader />
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,12 +1,44 @@
|
|||||||
// app/desa/berita/BeritaLayoutClient.tsx
|
// app/darmasaba/(pages)/desa/berita/layout.tsx
|
||||||
'use client'
|
'use client';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
import BackButton from '../layanan/_com/BackButto';
|
||||||
|
import colors from '@/con/colors';
|
||||||
const LayoutTabsBerita = dynamic(
|
const LayoutTabsBerita = dynamic(
|
||||||
() => import('./_lib/layoutTabs'),
|
() => import('./_lib/layoutTabs'),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function BeritaLayoutClient({ children }: { children: React.ReactNode }) {
|
export default function BeritaLayoutClient({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Contoh path:
|
||||||
|
// - /darmasaba/desa/berita/semua → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
|
||||||
|
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
|
||||||
|
|
||||||
|
if (isDetailPage) {
|
||||||
|
// Tampilkan tanpa tab menu
|
||||||
|
return (
|
||||||
|
<Box bg={colors.Bg}>
|
||||||
|
<Box pt={33} px={{ base: 'md', md: 100 }}>
|
||||||
|
<BackButton />
|
||||||
|
</Box>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
|
||||||
return <LayoutTabsBerita>{children}</LayoutTabsBerita>;
|
return <LayoutTabsBerita>{children}</LayoutTabsBerita>;
|
||||||
}
|
}
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import colors from '@/con/colors';
|
|
||||||
import { Box, Center, Image, Pagination, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import ApiFetch from '@/lib/api-fetch';
|
|
||||||
|
|
||||||
interface FileItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
link: string;
|
|
||||||
realName: string;
|
|
||||||
createdAt: string | Date;
|
|
||||||
category: string;
|
|
||||||
path: string;
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FotoContent() {
|
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const limit = 9; // ✅ ambil 12 data per page
|
|
||||||
|
|
||||||
const loadData = useCallback(async (pageNum: number, searchTerm: string) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const query: Record<string, string> = {
|
|
||||||
category: 'image',
|
|
||||||
page: pageNum.toString(),
|
|
||||||
limit: limit.toString(),
|
|
||||||
};
|
|
||||||
if (searchTerm) query.search = searchTerm;
|
|
||||||
|
|
||||||
const response = await ApiFetch.api.fileStorage.findMany.get({ query });
|
|
||||||
|
|
||||||
if (response.status === 200 && response.data) {
|
|
||||||
setFiles(response.data.data || []);
|
|
||||||
setTotalPages(response.data.meta?.totalPages || 1);
|
|
||||||
} else {
|
|
||||||
setFiles([]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Load error:', err);
|
|
||||||
setFiles([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ✅ Initial load + update when URL/search changes
|
|
||||||
useEffect(() => {
|
|
||||||
const handleRouteChange = () => {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const urlSearch = urlParams.get('search') || '';
|
|
||||||
const urlPage = parseInt(urlParams.get('page') || '1');
|
|
||||||
setSearch(urlSearch);
|
|
||||||
setPage(urlPage);
|
|
||||||
loadData(urlPage, urlSearch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchUpdate = (e: Event) => {
|
|
||||||
const { search } = (e as CustomEvent).detail;
|
|
||||||
setSearch(search);
|
|
||||||
setPage(1);
|
|
||||||
loadData(1, search);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleRouteChange();
|
|
||||||
window.addEventListener('popstate', handleRouteChange);
|
|
||||||
window.addEventListener('searchUpdate', handleSearchUpdate as EventListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('popstate', handleRouteChange);
|
|
||||||
window.removeEventListener('searchUpdate', handleSearchUpdate as EventListener);
|
|
||||||
};
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
// ✅ Update when page/search changes
|
|
||||||
useEffect(() => {
|
|
||||||
loadData(page, search);
|
|
||||||
}, [page, search, loadData]);
|
|
||||||
|
|
||||||
const updateURL = (newSearch: string, newPage: number) => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
if (newSearch) url.searchParams.set('search', newSearch);
|
|
||||||
else url.searchParams.delete('search');
|
|
||||||
if (newPage > 1) url.searchParams.set('page', newPage.toString());
|
|
||||||
else url.searchParams.delete('page');
|
|
||||||
window.history.pushState({}, '', url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
|
||||||
setPage(newPage);
|
|
||||||
updateURL(search, newPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading && files.length === 0) {
|
|
||||||
return <Center>Memuat data...</Center>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
return <Center>Tidak ada foto ditemukan</Center>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box pt={20} px={{ base: 'md', md: 100 }}>
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
|
||||||
{files.map((file) => (
|
|
||||||
<Paper
|
|
||||||
key={file.id}
|
|
||||||
mb={50}
|
|
||||||
p="md"
|
|
||||||
radius={26}
|
|
||||||
bg={colors['white-trans-1']}
|
|
||||||
style={{ height: '100%' }}
|
|
||||||
>
|
|
||||||
<Box style={{ height: '250px', overflow: 'hidden', borderRadius: '12px' }}>
|
|
||||||
<Image
|
|
||||||
src={file.link}
|
|
||||||
alt={file.realName || file.name}
|
|
||||||
height={250}
|
|
||||||
width="100%"
|
|
||||||
style={{ objectFit: 'cover', height: '100%', width: '100%' }}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Stack gap="sm" py={10}>
|
|
||||||
<Text fw="bold" fz={{ base: 'h4', md: 'h3' }}>
|
|
||||||
{file.realName || file.name}
|
|
||||||
</Text>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
{new Date(file.createdAt).toLocaleDateString('id-ID', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
<Center mt="xl">
|
|
||||||
<Pagination total={totalPages} value={page} onChange={handlePageChange} />
|
|
||||||
</Center>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,168 @@
|
|||||||
'use client'
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||||
import { Suspense } from 'react';
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
Grid,
|
||||||
|
Image,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconPhoto } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
|
||||||
// ✅ Load komponen tanpa SSR
|
// Komponen kartu foto
|
||||||
const FotoContent = dynamic(
|
function FotoCard({ item }: { item: any }) {
|
||||||
() => import('./Content'),
|
const router = useRouter();
|
||||||
{
|
|
||||||
ssr: false,
|
const handleClick = () => {
|
||||||
loading: () => <div>Memuat konten...</div>
|
router.push(`/darmasaba/galeri/foto/${item.id}`);
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
|
||||||
function PageContent() {
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div>Memuat...</div>}>
|
<Grid.Col span={{ base: 12, xs: 6, md: 4 }}>
|
||||||
<FotoContent />
|
<Paper
|
||||||
</Suspense>
|
shadow="sm"
|
||||||
|
radius="md"
|
||||||
|
p={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ cursor: 'pointer', transition: 'transform 0.2s' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.02)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
|
||||||
|
>
|
||||||
|
{item.imageGalleryFoto?.link ? (
|
||||||
|
<Box
|
||||||
|
pos="relative"
|
||||||
|
style={{
|
||||||
|
paddingBottom: '100%', // ✅ Ubah ke 1:1 (square) — atau sesuaikan
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '4px 4px 0 0',
|
||||||
|
backgroundColor: '#f9f9f9', // ✅ background netral
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
radius="lg"
|
||||||
|
src={item.imageGalleryFoto.link}
|
||||||
|
alt={item.name || 'Foto Galeri'}
|
||||||
|
p={10}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain', // ✅ Tampilkan utuh, jangan crop
|
||||||
|
objectPosition: 'center', // rata tengah
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Center h={180} bg="gray.1">
|
||||||
|
<IconPhoto size={40} color="gray" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack p="md" gap={4}>
|
||||||
|
<Text fw={600} lineClamp={1}>
|
||||||
|
{item.name || 'Tanpa Judul'}
|
||||||
|
</Text>
|
||||||
|
{item.deskripsi && (
|
||||||
|
<Text fz="sm" c="dimmed" lineClamp={2} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
||||||
|
)}
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString('id-ID', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
// Komponen utama
|
||||||
return <PageContent />;
|
export default function GaleriFotoUser() {
|
||||||
|
const [search] = useState('');
|
||||||
|
return (
|
||||||
|
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Title order={2} c={colors['blue-button']} mb="lg">
|
||||||
|
Galeri Foto Desa Darmasaba
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{/* Daftar Foto */}
|
||||||
|
<FotoList search={search} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FotoList({ search }: { search: string }) {
|
||||||
|
const FotoState = useProxy(stateGallery.foto);
|
||||||
|
|
||||||
|
const { data, page, totalPages, loading, load } = FotoState.findMany;
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
load(page, 3, search); // ✅ 9 item per halaman
|
||||||
|
}, [page, search]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Grid mt="md">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Grid.Col key={i} span={{ base: 12, xs: 6, md: 4 }}>
|
||||||
|
<Skeleton height={280} radius="md" />
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Stack align="center" c="dimmed">
|
||||||
|
<IconPhoto size={48} />
|
||||||
|
<Text>Tidak ada foto ditemukan</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack mt="md" gap="xl">
|
||||||
|
<Grid>
|
||||||
|
{data.map((item) => (
|
||||||
|
<FotoCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
|
||||||
|
<Center>
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => {
|
||||||
|
load(newPage, 3, search);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
total={totalPages}
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,36 @@
|
|||||||
|
'use client'
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ReactNode } from "react";
|
||||||
import LayoutTabsGalery from "./_lib/layoutTabs";
|
import LayoutTabsGalery from "./_lib/layoutTabs";
|
||||||
|
|
||||||
export default function LayoutGalery({ children }: { children: React.ReactNode }) {
|
// export default function LayoutGalery({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
// return (
|
||||||
<LayoutTabsGalery>
|
// <LayoutTabsGalery>
|
||||||
{children}
|
// {children}
|
||||||
</LayoutTabsGalery>
|
// </LayoutTabsGalery>
|
||||||
)
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
export default function BeritaLayoutClient({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Contoh path:
|
||||||
|
// - /darmasaba/desa/berita/semua → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
|
||||||
|
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
|
||||||
|
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
|
||||||
|
|
||||||
|
if (isDetailPage) {
|
||||||
|
// Tampilkan tanpa tab menu
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
|
||||||
|
return <LayoutTabsGalery>{children}</LayoutTabsGalery>;
|
||||||
}
|
}
|
||||||
@@ -4,26 +4,27 @@ import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
|||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Center,
|
Center,
|
||||||
|
Group,
|
||||||
Pagination,
|
Pagination,
|
||||||
Paper,
|
Paper,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Spoiler,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useTransitionRouter } from 'next-view-transitions';
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
export default function VideoContent() {
|
export default function VideoContent() {
|
||||||
// ✅ expanded state per index
|
|
||||||
const [expandedMap, setExpandedMap] = useState<Record<number, boolean>>({});
|
|
||||||
const videoState = useSnapshot(stateGallery.video);
|
const videoState = useSnapshot(stateGallery.video);
|
||||||
|
const router = useTransitionRouter()
|
||||||
const { data, page, totalPages, loading } = videoState.findMany;
|
const { data, page, totalPages, loading } = videoState.findMany;
|
||||||
|
|
||||||
// Handle search and pagination changes
|
// Handle search and pagination changes
|
||||||
const loadData = useCallback((pageNum: number, searchTerm: string) => {
|
const loadData = useCallback((pageNum: number, searchTerm: string) => {
|
||||||
stateGallery.video.findMany.load(pageNum, 10, searchTerm.trim());
|
stateGallery.video.findMany.load(pageNum, 3, searchTerm.trim());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initial load and URL change handler
|
// Initial load and URL change handler
|
||||||
@@ -56,12 +57,6 @@ export default function VideoContent() {
|
|||||||
loadData(newPage, search);
|
loadData(newPage, search);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleExpanded = (index: number, value: boolean) => {
|
|
||||||
setExpandedMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[index]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataVideo = data || [];
|
const dataVideo = data || [];
|
||||||
|
|
||||||
@@ -110,27 +105,22 @@ export default function VideoContent() {
|
|||||||
<Text fw="bold" fz="sm" lineClamp={1}>
|
<Text fw="bold" fz="sm" lineClamp={1}>
|
||||||
{v.name}
|
{v.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Spoiler
|
<Text
|
||||||
showLabel={
|
ta="justify"
|
||||||
<Text fw="bold" fz="sm" c={colors['blue-button']}>
|
fz="sm"
|
||||||
Show more
|
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
|
||||||
</Text>
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
}
|
lineClamp={3}
|
||||||
hideLabel={
|
truncate="end"
|
||||||
<Text fw="bold" fz="sm" c={colors['blue-button']}>
|
/>
|
||||||
Hide details
|
<Group justify={"right"}>
|
||||||
</Text>
|
<Button
|
||||||
}
|
onClick={() => router.push(`/darmasaba/desa/galery/video/${v.id}`)}
|
||||||
expanded={expandedMap[k] || false}
|
bg={colors['blue-button']}
|
||||||
onExpandedChange={(val) => toggleExpanded(k, val)}
|
|
||||||
>
|
>
|
||||||
<Text
|
Detail
|
||||||
ta="justify"
|
</Button>
|
||||||
fz="sm"
|
</Group>
|
||||||
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
|
|
||||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
|
||||||
/>
|
|
||||||
</Spoiler>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
197
src/app/darmasaba/(pages)/desa/galery/video/[id]/page.tsx
Normal file
197
src/app/darmasaba/(pages)/desa/galery/video/[id]/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconArrowBack, IconInfoCircle, IconVideo } from '@tabler/icons-react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; // pastikan state bisa dipakai di publik
|
||||||
|
import BackButton from '../../../layanan/_com/BackButto';
|
||||||
|
|
||||||
|
// Fungsi helper: aman dan tanpa spasi
|
||||||
|
function convertToEmbedUrl(youtubeUrl: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(youtubeUrl);
|
||||||
|
let videoId = '';
|
||||||
|
|
||||||
|
if (url.hostname === 'youtu.be') {
|
||||||
|
videoId = url.pathname.slice(1);
|
||||||
|
} else if (url.hostname.includes('youtube.com')) {
|
||||||
|
videoId = url.searchParams.get('v') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return videoId ? `https://www.youtube.com/embed/${videoId}` : youtubeUrl;
|
||||||
|
} catch {
|
||||||
|
return youtubeUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailVideoUser() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const videoState = useProxy(stateGallery.video);
|
||||||
|
const [videoError, setVideoError] = useState(false);
|
||||||
|
|
||||||
|
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
videoState.findUnique.load(id);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const data = videoState.findUnique.data;
|
||||||
|
|
||||||
|
if (!videoState.findUnique && !id) {
|
||||||
|
return (
|
||||||
|
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
|
||||||
|
<Skeleton height={400} radius="md" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
|
||||||
|
<Alert
|
||||||
|
icon={<IconInfoCircle size={20} />}
|
||||||
|
title="Video tidak ditemukan"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Video yang Anda cari tidak tersedia.
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconArrowBack size={16} />}
|
||||||
|
mt="md"
|
||||||
|
onClick={() => router.push('/darmasaba/galeri/video')}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Kembali ke Galeri
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedUrl = data.linkVideo ? convertToEmbedUrl(data.linkVideo) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py="xl" px={{ base: 'md', md: 100 }}>
|
||||||
|
{/* Tombol Kembali */}
|
||||||
|
<Box >
|
||||||
|
<BackButton />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Text
|
||||||
|
ta="center"
|
||||||
|
fz={{ base: 'xl', md: '2xl' }}
|
||||||
|
fw={700}
|
||||||
|
c={colors['blue-button']}
|
||||||
|
mb="lg"
|
||||||
|
>
|
||||||
|
{data.name || 'Video Galeri Desa'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Konten Utama */}
|
||||||
|
<Card
|
||||||
|
shadow="sm"
|
||||||
|
radius="md"
|
||||||
|
p={{ base: 'md', md: 'xl' }}
|
||||||
|
bg={colors['white-1']}
|
||||||
|
>
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* Video */}
|
||||||
|
{embedUrl ? (
|
||||||
|
<Box
|
||||||
|
pos="relative"
|
||||||
|
style={{ paddingBottom: '56.25%', height: 0, overflow: 'hidden' }} // 16:9 aspect ratio
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
title={data.name}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
onError={() => setVideoError(true)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : videoError ? (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
icon={<IconVideo size={20} />}
|
||||||
|
title="Gagal memuat video"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Mohon maaf, video tidak dapat diputar.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
color="gray"
|
||||||
|
icon={<IconInfoCircle size={20} />}
|
||||||
|
title="Tidak ada video"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Konten video belum tersedia.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Informasi Tambahan */}
|
||||||
|
{data.createdAt && (
|
||||||
|
<Group gap="xs" justify="center" wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" size="sm" radius="xl">
|
||||||
|
<IconInfoCircle size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
Diunggah pada{' '}
|
||||||
|
{new Date(data.createdAt).toLocaleDateString('id-ID', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
|
{data.deskripsi && (
|
||||||
|
<Paper p="md" bg="gray.0" radius="md">
|
||||||
|
<Text
|
||||||
|
fz="md"
|
||||||
|
c="dark"
|
||||||
|
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
|
||||||
|
style={{
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ function Page() {
|
|||||||
{data.name}
|
{data.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Container>
|
</Container>
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
<Box px={{ base: "35", md: 100 }}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text
|
<Text
|
||||||
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
|
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import colors from '@/con/colors';
|
|
||||||
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
|
|
||||||
import React from 'react';
|
|
||||||
import BackButton from '../../../layanan/_com/BackButto';
|
|
||||||
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
|
|
||||||
|
|
||||||
const dataPengumuman = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
|
|
||||||
tanggal: 'Jumat, 26 April 2025',
|
|
||||||
jam: '16:00 WITA',
|
|
||||||
lokasi: 'Wantilan Adat Desa',
|
|
||||||
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
|
|
||||||
tanggal: 'Jumat, 26 April 2025',
|
|
||||||
jam: '16:00 WITA',
|
|
||||||
lokasi: 'Wantilan Adat Desa',
|
|
||||||
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
|
|
||||||
tanggal: 'Jumat, 26 April 2025',
|
|
||||||
jam: '16:00 WITA',
|
|
||||||
lokasi: 'Wantilan Adat Desa',
|
|
||||||
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
|
|
||||||
tanggal: 'Jumat, 26 April 2025',
|
|
||||||
jam: '16:00 WITA',
|
|
||||||
lokasi: 'Wantilan Adat Desa',
|
|
||||||
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
judul: 'Sosialisasi Perarem Konservasi Adat di Darmasaba',
|
|
||||||
tanggal: 'Jumat, 26 April 2025',
|
|
||||||
jam: '16:00 WITA',
|
|
||||||
lokasi: 'Wantilan Adat Desa',
|
|
||||||
deskripsi: 'Para krama diundang hadir dalam sosialisasi perarem (aturan adat) baru yang berkaitan dengan konservasi lingkungan berbasis budaya, termasuk larangan alih fungsi lahan pekarangan suci.'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
function Page() {
|
|
||||||
return (
|
|
||||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
|
|
||||||
{/* Header */}
|
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
|
||||||
<BackButton />
|
|
||||||
</Box>
|
|
||||||
<Container size="lg" px="md" >
|
|
||||||
<Stack align="center" gap="0" >
|
|
||||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
|
||||||
Pengumuman Adat & Budaya
|
|
||||||
</Text>
|
|
||||||
<Text ta="center" px="md" pb={10}>
|
|
||||||
Informasi dan pengumuman resmi terkait pengumuman adat & budaya di Desa Darmasaba.
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
|
||||||
{dataPengumuman.map((v, k) => {
|
|
||||||
return (
|
|
||||||
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
|
|
||||||
<Text fz={'h3'}>{v.judul}</Text>
|
|
||||||
<Group style={{ color: 'black' }} pb={20}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconCalendar size={18} />
|
|
||||||
<Text size="sm">{v.tanggal}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconClock size={18} />
|
|
||||||
<Text size="sm">{v.jam}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconMapPin size={18} />
|
|
||||||
<Text size="sm">{v.lokasi}</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Text ta={'justify'}>
|
|
||||||
{v.deskripsi}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import colors from '@/con/colors';
|
|
||||||
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
|
|
||||||
import React from 'react';
|
|
||||||
import BackButton from '../../../layanan/_com/BackButto';
|
|
||||||
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
|
|
||||||
|
|
||||||
const dataPengumuman = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
|
|
||||||
tanggal: 'Selasa, 30 April 2025',
|
|
||||||
jam: '09:00 WITA',
|
|
||||||
lokasi: 'Perpustakaan Desa',
|
|
||||||
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
|
|
||||||
tanggal: 'Selasa, 30 April 2025',
|
|
||||||
jam: '09:00 WITA',
|
|
||||||
lokasi: 'Perpustakaan Desa',
|
|
||||||
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
|
|
||||||
tanggal: 'Selasa, 30 April 2025',
|
|
||||||
jam: '09:00 WITA',
|
|
||||||
lokasi: 'Perpustakaan Desa',
|
|
||||||
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
|
|
||||||
tanggal: 'Selasa, 30 April 2025',
|
|
||||||
jam: '09:00 WITA',
|
|
||||||
lokasi: 'Perpustakaan Desa',
|
|
||||||
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
judul: 'Pelatihan Dasar Komputer untuk Lansia dan Ibu Rumah Tangga',
|
|
||||||
tanggal: 'Selasa, 30 April 2025',
|
|
||||||
jam: '09:00 WITA',
|
|
||||||
lokasi: 'Perpustakaan Desa',
|
|
||||||
deskripsi: 'Belajar dari nol: cara menyalakan komputer, menulis di Word, membuat email, dan dasar penggunaan HP Android untuk komunikasi dan akses layanan desa online. Terbuka untuk 20 peserta.'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
function Page() {
|
|
||||||
return (
|
|
||||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
|
|
||||||
{/* Header */}
|
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
|
||||||
<BackButton />
|
|
||||||
</Box>
|
|
||||||
<Container size="lg" px="md" >
|
|
||||||
<Stack align="center" gap="0" >
|
|
||||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
|
||||||
Pengumuman Digitalisasi Desa
|
|
||||||
</Text>
|
|
||||||
<Text ta="center" px="md" pb={10}>
|
|
||||||
Informasi dan pengumuman resmi terkait pengumuman digitalisasi desa
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
|
||||||
{dataPengumuman.map((v, k) => {
|
|
||||||
return (
|
|
||||||
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
|
|
||||||
<Text fz={'h3'}>{v.judul}</Text>
|
|
||||||
<Group style={{ color: 'black' }} pb={20}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconCalendar size={18} />
|
|
||||||
<Text size="sm">{v.tanggal}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconClock size={18} />
|
|
||||||
<Text size="sm">{v.jam}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconMapPin size={18} />
|
|
||||||
<Text size="sm">{v.lokasi}</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Text ta={'justify'}>
|
|
||||||
{v.deskripsi}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import colors from '@/con/colors';
|
|
||||||
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
|
|
||||||
import React from 'react';
|
|
||||||
import BackButton from '../../../layanan/_com/BackButto';
|
|
||||||
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
|
|
||||||
|
|
||||||
const dataPengumuman = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
|
|
||||||
tanggal: 'Rabu, 23 April 2025',
|
|
||||||
jam: '13:00 WITA',
|
|
||||||
lokasi: 'Aula Kantor Desa',
|
|
||||||
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
|
|
||||||
tanggal: 'Rabu, 23 April 2025',
|
|
||||||
jam: '13:00 WITA',
|
|
||||||
lokasi: 'Aula Kantor Desa',
|
|
||||||
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
|
|
||||||
tanggal: 'Rabu, 23 April 2025',
|
|
||||||
jam: '13:00 WITA',
|
|
||||||
lokasi: 'Aula Kantor Desa',
|
|
||||||
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
|
|
||||||
tanggal: 'Rabu, 23 April 2025',
|
|
||||||
jam: '13:00 WITA',
|
|
||||||
lokasi: 'Aula Kantor Desa',
|
|
||||||
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
judul: 'Pelatihan Digital Marketing untuk UMKM Desa',
|
|
||||||
tanggal: 'Rabu, 23 April 2025',
|
|
||||||
jam: '13:00 WITA',
|
|
||||||
lokasi: 'Aula Kantor Desa',
|
|
||||||
deskripsi: 'Para pelaku UMKM diundang untuk mengikuti pelatihan dasar digital marketing: mulai dari membuat akun bisnis, copywriting produk, hingga promosi melalui media sosial. Peserta akan mendapat sertifikat dan materi pelatihan.'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
function Page() {
|
|
||||||
return (
|
|
||||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
|
|
||||||
{/* Header */}
|
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
|
||||||
<BackButton />
|
|
||||||
</Box>
|
|
||||||
<Container size="lg" px="md" >
|
|
||||||
<Stack align="center" gap="0" >
|
|
||||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
|
||||||
Pengumuman Ekonomi & UMKM
|
|
||||||
</Text>
|
|
||||||
<Text ta="center" px="md" pb={10}>
|
|
||||||
Informasi dan pengumuman resmi terkait pengumuman ekonomi & umkm
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
|
||||||
{dataPengumuman.map((v, k) => {
|
|
||||||
return (
|
|
||||||
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
|
|
||||||
<Text fz={'h3'}>{v.judul}</Text>
|
|
||||||
<Group style={{ color: 'black' }} pb={20}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconCalendar size={18} />
|
|
||||||
<Text size="sm">{v.tanggal}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconClock size={18} />
|
|
||||||
<Text size="sm">{v.jam}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconMapPin size={18} />
|
|
||||||
<Text size="sm">{v.lokasi}</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Text ta={'justify'}>
|
|
||||||
{v.deskripsi}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import colors from '@/con/colors';
|
|
||||||
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
|
|
||||||
import React from 'react';
|
|
||||||
import BackButton from '../../../layanan/_com/BackButto';
|
|
||||||
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
|
|
||||||
|
|
||||||
const dataPengumuman = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
judul: 'Gotong Royong Bersih Sungai dan Drainase',
|
|
||||||
tanggal: 'Minggu, 21 April 2025',
|
|
||||||
jam: '06:30 WITA',
|
|
||||||
lokasi: 'Titik Kumpul: Poskamling RW 02',
|
|
||||||
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
judul: 'Gotong Royong Bersih Sungai dan Drainase',
|
|
||||||
tanggal: 'Minggu, 21 April 2025',
|
|
||||||
jam: '06:30 WITA',
|
|
||||||
lokasi: 'Titik Kumpul: Poskamling RW 02',
|
|
||||||
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
judul: 'Gotong Royong Bersih Sungai dan Drainase',
|
|
||||||
tanggal: 'Minggu, 21 April 2025',
|
|
||||||
jam: '06:30 WITA',
|
|
||||||
lokasi: 'Titik Kumpul: Poskamling RW 02',
|
|
||||||
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
judul: 'Gotong Royong Bersih Sungai dan Drainase',
|
|
||||||
tanggal: 'Minggu, 21 April 2025',
|
|
||||||
jam: '06:30 WITA',
|
|
||||||
lokasi: 'Titik Kumpul: Poskamling RW 02',
|
|
||||||
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
judul: 'Gotong Royong Bersih Sungai dan Drainase',
|
|
||||||
tanggal: 'Minggu, 21 April 2025',
|
|
||||||
jam: '06:30 WITA',
|
|
||||||
lokasi: 'Titik Kumpul: Poskamling RW 02',
|
|
||||||
deskripsi: 'Seluruh warga RW 02 diimbau ikut serta dalam kegiatan bersih-bersih aliran sungai dan saluran air untuk mencegah banjir saat musim hujan. Disediakan alat kebersihan dan konsumsi ringan.'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
function Page() {
|
|
||||||
return (
|
|
||||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
|
|
||||||
{/* Header */}
|
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
|
||||||
<BackButton />
|
|
||||||
</Box>
|
|
||||||
<Container size="lg" px="md" >
|
|
||||||
<Stack align="center" gap="0" >
|
|
||||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
|
||||||
Pengumuman Lingkungan & Bencana
|
|
||||||
</Text>
|
|
||||||
<Text ta="center" px="md" pb={10}>
|
|
||||||
Informasi dan pengumuman resmi terkait pengumuman lingkungan & bencana
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
|
||||||
{dataPengumuman.map((v, k) => {
|
|
||||||
return (
|
|
||||||
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
|
|
||||||
<Text fz={'h3'}>{v.judul}</Text>
|
|
||||||
<Group style={{ color: 'black' }} pb={20}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconCalendar size={18} />
|
|
||||||
<Text size="sm">{v.tanggal}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconClock size={18} />
|
|
||||||
<Text size="sm">{v.jam}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconMapPin size={18} />
|
|
||||||
<Text size="sm">{v.lokasi}</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Text ta={'justify'}>
|
|
||||||
{v.deskripsi}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import colors from '@/con/colors';
|
|
||||||
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
|
|
||||||
import React from 'react';
|
|
||||||
import BackButton from '../../../layanan/_com/BackButto';
|
|
||||||
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
|
|
||||||
|
|
||||||
const dataPengumuman = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
judul: 'Pendaftaran Bimbingan Belajar Gratis untuk Pelajar',
|
|
||||||
tanggal: 'Sabtu, 20 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Balai Banjar Desa Darmasaba',
|
|
||||||
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
judul: 'Lomba Video Pendek Hari Lingkungan',
|
|
||||||
tanggal: 'Deadline: 28 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Online Submission',
|
|
||||||
deskripsi: 'Karang Taruna Desa mengadakan lomba video pendek bertema "Lingkunganku, Tanggung Jawabku". Pemenang akan diumumkan saat acara Hari Desa Hijau. Total hadiah Rp1.000.000.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
judul: 'Pendaftaran Bimbingan Belajar Gratis untuk Pelajar',
|
|
||||||
tanggal: 'Sabtu, 20 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Balai Banjar Desa Darmasaba',
|
|
||||||
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
judul: 'Lomba Video Pendek Hari Lingkungan',
|
|
||||||
tanggal: 'Deadline: 28 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Online Submission',
|
|
||||||
deskripsi: 'Karang Taruna Desa mengadakan lomba video pendek bertema "Lingkunganku, Tanggung Jawabku". Pemenang akan diumumkan saat acara Hari Desa Hijau. Total hadiah Rp1.000.000.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
judul: 'Pendaftaran Bimbingan Belajar Gratis untuk Pelajar',
|
|
||||||
tanggal: 'Sabtu, 20 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Balai Banjar Desa Darmasaba',
|
|
||||||
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
function Page() {
|
|
||||||
return (
|
|
||||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
|
|
||||||
{/* Header */}
|
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
|
||||||
<BackButton />
|
|
||||||
</Box>
|
|
||||||
<Container size="lg" px="md" >
|
|
||||||
<Stack align="center" gap="0" >
|
|
||||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
|
||||||
Pengumuman Pendidikan & Kepemudaan
|
|
||||||
</Text>
|
|
||||||
<Text ta="center" px="md" pb={10}>
|
|
||||||
Informasi dan pengumuman resmi terkait pengumuman pendidikan & kepemudaan
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
|
||||||
{dataPengumuman.map((v, k) => {
|
|
||||||
return (
|
|
||||||
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
|
|
||||||
<Text fz={'h3'}>{v.judul}</Text>
|
|
||||||
<Group style={{ color: 'black' }} pb={20}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconCalendar size={18} />
|
|
||||||
<Text size="sm">{v.tanggal}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconClock size={18} />
|
|
||||||
<Text size="sm">{v.jam}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconMapPin size={18} />
|
|
||||||
<Text size="sm">{v.lokasi}</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Text ta={'justify'}>
|
|
||||||
{v.deskripsi}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import colors from '@/con/colors';
|
|
||||||
import { Stack, Box, Container, Text, Paper, Group } from '@mantine/core';
|
|
||||||
import React from 'react';
|
|
||||||
import BackButton from '../../../layanan/_com/BackButto';
|
|
||||||
import { IconCalendar, IconClock, IconMapPin } from '@tabler/icons-react';
|
|
||||||
|
|
||||||
const dataPengumuman = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
|
|
||||||
tanggal: 'Sabtu, 20 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Balai Banjar Desa Darmasaba',
|
|
||||||
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
|
|
||||||
tanggal: 'Sabtu, 20 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Balai Banjar Desa Darmasaba',
|
|
||||||
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
|
|
||||||
tanggal: 'Sabtu, 20 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Balai Banjar Desa Darmasaba',
|
|
||||||
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
|
|
||||||
tanggal: 'Sabtu, 20 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Balai Banjar Desa Darmasaba',
|
|
||||||
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
judul: 'Pemeriksaan Kesehatan Gratis Untuk Warga Desa',
|
|
||||||
tanggal: 'Sabtu, 20 April 2025',
|
|
||||||
jam: '08:00 WITA',
|
|
||||||
lokasi: 'Balai Banjar Desa Darmasaba',
|
|
||||||
deskripsi: 'Dalam rangka meningkatkan kesadaran kesehatan, Pemerintah Desa Darmasaba bekerja sama dengan Puskesmas Abiansemal akan mengadakan pemeriksaan kesehatan gratis meliputi cek tekanan darah, kolesterol, gula darah, dan konsultasi gizi.'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
function Page() {
|
|
||||||
return (
|
|
||||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
|
|
||||||
{/* Header */}
|
|
||||||
<Box px={{ base: "md", md: 100 }}>
|
|
||||||
<BackButton />
|
|
||||||
</Box>
|
|
||||||
<Container size="lg" px="md" >
|
|
||||||
<Stack align="center" gap="0" >
|
|
||||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
|
||||||
Pengumuman Sosial & Kesehatan
|
|
||||||
</Text>
|
|
||||||
<Text ta="center" px="md" pb={10}>
|
|
||||||
Informasi dan pengumuman resmi terkait pengumuman sosial & kesehatan
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
<Box px={{ base: 'md', md: 100 }}>
|
|
||||||
{dataPengumuman.map((v, k) => {
|
|
||||||
return (
|
|
||||||
<Paper mb={10} key={k} withBorder p="lg" radius="md" shadow="md" bg={colors["white-1"]}>
|
|
||||||
<Text fz={'h3'}>{v.judul}</Text>
|
|
||||||
<Group style={{ color: 'black' }} pb={20}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconCalendar size={18} />
|
|
||||||
<Text size="sm">{v.tanggal}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconClock size={18} />
|
|
||||||
<Text size="sm">{v.jam}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconMapPin size={18} />
|
|
||||||
<Text size="sm">{v.lokasi}</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Text ta={'justify'}>
|
|
||||||
{v.deskripsi}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -46,8 +46,8 @@ function Page() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Paper bg={colors["white-1"]} p="md">
|
<Paper bg={colors["white-1"]} p="md">
|
||||||
<Text id='news-content' fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: detail.data?.content }} />
|
<Text px="lg" id='news-content' fz={"md"} style={{wordBreak: "break-word", whiteSpace: "normal"}} dangerouslySetInnerHTML={{ __html: detail.data?.content }} />
|
||||||
<Text fz={"md"} c={colors["blue-button"]} fw="bold" >
|
<Text px="lg" fz={"md"} c={colors["blue-button"]} fw="bold" >
|
||||||
{new Date(detail.data?.createdAt).toLocaleDateString('id-ID', {
|
{new Date(detail.data?.createdAt).toLocaleDateString('id-ID', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
TabsList,
|
||||||
|
TabsTab,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
@@ -35,6 +38,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { useProxy } from 'valtio/utils'
|
import { useProxy } from 'valtio/utils'
|
||||||
import './struktur.css'
|
import './struktur.css'
|
||||||
import BackButton from '../_com/BackButto'
|
import BackButton from '../_com/BackButto'
|
||||||
|
import { useMediaQuery } from '@mantine/hooks'
|
||||||
|
|
||||||
export default function StrukturPerangkatDesa() {
|
export default function StrukturPerangkatDesa() {
|
||||||
return (
|
return (
|
||||||
@@ -231,87 +235,121 @@ function StrukturPerangkatDesaNode() {
|
|||||||
p="md"
|
p="md"
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{
|
style={{
|
||||||
background: colors['blue-button']
|
background: colors['blue-button'],
|
||||||
|
width: '100%', // ⬅️ penting
|
||||||
|
maxWidth: '100%', // ⬅️ penting
|
||||||
|
overflowX: 'auto' // ⬅️ untuk mencegah overflow
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap="sm" wrap="wrap" justify="center">
|
|
||||||
<TextInput
|
<Stack gap="sm">
|
||||||
placeholder="Cari nama atau jabatan..."
|
<Group justify='center'>
|
||||||
leftSection={<IconSearch size={16} />}
|
<TextInput
|
||||||
onChange={(e) => debouncedSearch(e.target.value)}
|
placeholder="Cari nama atau jabatan..."
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
onChange={(e) => debouncedSearch(e.target.value)}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
minWidth: 250,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Tabs
|
||||||
|
defaultValue="zoom-out"
|
||||||
|
variant="outline"
|
||||||
|
radius="md"
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
panel: { display: 'none' },
|
||||||
minWidth: 250,
|
tab: {
|
||||||
|
color: colors['blue-button'],
|
||||||
|
backgroundColor: colors['blue-button-2'],
|
||||||
|
border: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
padding: '6px 12px',
|
||||||
|
minHeight: 'auto',
|
||||||
|
flexShrink: 0, // 👈 PENTING: mencegah tab mengecil
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<TabsList
|
||||||
<Group gap="xs">
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
bg={colors['blue-button-2']}
|
|
||||||
size="sm"
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
leftSection={<IconZoomOut size={16} />}
|
|
||||||
c={colors['blue-button']}
|
|
||||||
>
|
|
||||||
Zoom Out
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
bg={colors['blue-button-2']}
|
|
||||||
c={colors['blue-button']}
|
|
||||||
px={16}
|
|
||||||
py={8}
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
display: 'flex',
|
||||||
fontWeight: 700,
|
overflowX: 'auto',
|
||||||
borderRadius: '8px',
|
overflowY: 'hidden', // 👈 tambahkan ini
|
||||||
minWidth: 70,
|
gap: '4px',
|
||||||
textAlign: 'center',
|
paddingBottom: '4px',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
WebkitOverflowScrolling: 'touch', // 👈 smooth scroll di iOS
|
||||||
|
scrollbarWidth: 'thin', // 👈 scrollbar tipis di Firefox
|
||||||
|
msOverflowStyle: '-ms-autohiding-scrollbar', // 👈 untuk IE/Edge
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Math.round(scale * 100)}%
|
<TabsTab
|
||||||
</Box>
|
value="zoom-out"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
leftSection={<IconZoomOut size={16} />}
|
||||||
|
style={{ flexShrink: 0 }} // 👈 pastikan tidak mengecil
|
||||||
|
>
|
||||||
|
Zoom Out
|
||||||
|
</TabsTab>
|
||||||
|
|
||||||
<Button
|
<Box
|
||||||
bg={colors['blue-button-2']}
|
bg={colors['blue-button-2']}
|
||||||
c={colors['blue-button']}
|
c={colors['blue-button']}
|
||||||
variant="light"
|
px={12}
|
||||||
size="sm"
|
py={6}
|
||||||
onClick={handleZoomIn}
|
style={{
|
||||||
leftSection={<IconZoomIn size={16} />}
|
fontSize: 14,
|
||||||
>
|
fontWeight: 700,
|
||||||
Zoom In
|
borderRadius: '6px',
|
||||||
</Button>
|
minWidth: 60,
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: 'nowrap', // 👈 mencegah text wrap
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(scale * 100)}%
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Button
|
<TabsTab
|
||||||
bg={colors['blue-button-2']}
|
value="zoom-in"
|
||||||
c={colors['blue-button']}
|
onClick={handleZoomIn}
|
||||||
variant="light"
|
leftSection={<IconZoomIn size={16} />}
|
||||||
size="sm"
|
style={{ flexShrink: 0 }}
|
||||||
onClick={resetZoom}
|
>
|
||||||
>
|
Zoom In
|
||||||
Reset
|
</TabsTab>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<TabsTab
|
||||||
bg={colors['blue-button-2']}
|
value="reset"
|
||||||
c={colors['blue-button']}
|
onClick={resetZoom}
|
||||||
size="sm"
|
style={{ flexShrink: 0 }}
|
||||||
onClick={toggleFullscreen}
|
>
|
||||||
leftSection={
|
Reset
|
||||||
isFullscreen ? (
|
</TabsTab>
|
||||||
<IconArrowsMinimize size={16} />
|
|
||||||
) : (
|
<TabsTab
|
||||||
<IconArrowsMaximize size={16} />
|
value="fullscreen"
|
||||||
)
|
onClick={toggleFullscreen}
|
||||||
}
|
leftSection={
|
||||||
>
|
isFullscreen ? (
|
||||||
Fullscreen
|
<IconArrowsMinimize size={16} />
|
||||||
</Button>
|
) : (
|
||||||
</Group>
|
<IconArrowsMaximize size={16} />
|
||||||
</Group>
|
)
|
||||||
|
}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
||||||
|
</TabsTab>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* 🧩 Chart Container */}
|
{/* 🧩 Chart Container */}
|
||||||
@@ -325,15 +363,20 @@ function StrukturPerangkatDesaNode() {
|
|||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
padding: '32px 16px',
|
padding: '32px 16px',
|
||||||
transition: 'transform 0.2s ease',
|
transition: 'transform 0.2s ease',
|
||||||
transform: `scale(${scale})`,
|
|
||||||
transformOrigin: 'center top',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OrganizationChart
|
<Box style={{
|
||||||
value={chartData}
|
transform: `scale(${scale})`,
|
||||||
nodeTemplate={(node) => <NodeCard node={node} router={router} />}
|
transformOrigin: 'center top',
|
||||||
className="p-organizationchart p-organizationchart-horizontal"
|
display: 'inline-block', // 👈 agar tidak memenuhi lebar parent
|
||||||
/>
|
minWidth: 'min-content', // 👈 penting agar chart tidak dipaksa muat di width 100%
|
||||||
|
}}>
|
||||||
|
<OrganizationChart
|
||||||
|
value={chartData}
|
||||||
|
nodeTemplate={(node) => <NodeCard node={node} router={router} />}
|
||||||
|
className="p-organizationchart p-organizationchart-horizontal"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Center>
|
</Center>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -345,6 +388,7 @@ function NodeCard({ node, router }: any) {
|
|||||||
const name = node?.data?.name || 'Tanpa Nama'
|
const name = node?.data?.name || 'Tanpa Nama'
|
||||||
const title = node?.data?.title || 'Tanpa Jabatan'
|
const title = node?.data?.title || 'Tanpa Jabatan'
|
||||||
const hasId = Boolean(node?.data?.id)
|
const hasId = Boolean(node?.data?.id)
|
||||||
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition mounted transition="pop" duration={300}>
|
<Transition mounted transition="pop" duration={300}>
|
||||||
@@ -355,9 +399,10 @@ function NodeCard({ node, router }: any) {
|
|||||||
withBorder
|
withBorder
|
||||||
style={{
|
style={{
|
||||||
...styles,
|
...styles,
|
||||||
width: 240,
|
width: '100%',
|
||||||
minHeight: 280,
|
maxWidth: isMobile ? 200 : 240, // lebih kecil di mobile
|
||||||
padding: 20,
|
minHeight: isMobile ? 240 : 280,
|
||||||
|
padding: isMobile ? 16 : 20,
|
||||||
background: 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)',
|
background: 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)',
|
||||||
borderColor: 'rgba(28, 110, 164, 0.3)',
|
borderColor: 'rgba(28, 110, 164, 0.3)',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
|
|||||||
@@ -175,7 +175,9 @@ function Page() {
|
|||||||
<Title order={4}>Layanan Unggulan</Title>
|
<Title order={4}>Layanan Unggulan</Title>
|
||||||
<Divider />
|
<Divider />
|
||||||
{layananUnggulan ? (
|
{layananUnggulan ? (
|
||||||
<Text fz="md" style={{ lineHeight: 1.7, wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: layananUnggulan }} />
|
<Box pl={"lg"}>
|
||||||
|
<Text fz="md" style={{ lineHeight: 1.7, wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: layananUnggulan }} />
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Paper withBorder radius="md" p="md">
|
<Paper withBorder radius="md" p="md">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
@@ -251,7 +253,9 @@ function Page() {
|
|||||||
<Title order={3}>Fasilitas Pendukung</Title>
|
<Title order={3}>Fasilitas Pendukung</Title>
|
||||||
<Divider />
|
<Divider />
|
||||||
{fasilitasPendukungHtml ? (
|
{fasilitasPendukungHtml ? (
|
||||||
<Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: fasilitasPendukungHtml }} />
|
<Box pl="lg">
|
||||||
|
<Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: fasilitasPendukungHtml }} />
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Paper withBorder radius="md" p="md">
|
<Paper withBorder radius="md" p="md">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
@@ -313,7 +317,7 @@ function Page() {
|
|||||||
<Title order={3}>Prosedur Pendaftaran</Title>
|
<Title order={3}>Prosedur Pendaftaran</Title>
|
||||||
<Divider />
|
<Divider />
|
||||||
{prosedur ? (
|
{prosedur ? (
|
||||||
<Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: prosedur }} />
|
<Box pl="lg"><Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: prosedur }} /></Box>
|
||||||
) : (
|
) : (
|
||||||
<Text fz="md" c="dimmed">Belum ada prosedur pendaftaran</Text>
|
<Text fz="md" c="dimmed">Belum ada prosedur pendaftaran</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function DetailInformasiPublikUser() {
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Box>
|
<Box px="lg">
|
||||||
<Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}>
|
<Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}>
|
||||||
Jenis Informasi
|
Jenis Informasi
|
||||||
</Text>
|
</Text>
|
||||||
@@ -96,7 +96,7 @@ export default function DetailInformasiPublikUser() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box px="lg">
|
||||||
<Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}>
|
<Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}>
|
||||||
Tanggal Publikasi
|
Tanggal Publikasi
|
||||||
</Text>
|
</Text>
|
||||||
@@ -111,15 +111,19 @@ export default function DetailInformasiPublikUser() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box px="lg">
|
||||||
<Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}>
|
<Text fz={{ base: 'md', md: 'lg' }} fw="bold" mb={4}>
|
||||||
Deskripsi
|
Deskripsi
|
||||||
</Text>
|
</Text>
|
||||||
<Box
|
<Box>
|
||||||
className="prose max-w-none leading-relaxed"
|
<Text
|
||||||
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
|
ta={"justify"}
|
||||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
className="prose max-w-none leading-relaxed"
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
|
||||||
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
|
fz={{ base: 'md', md: 'lg' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ function Page() {
|
|||||||
<Box px={{ base: 'md', md: 100 }}>
|
<Box px={{ base: 'md', md: 100 }}>
|
||||||
<BackButton />
|
<BackButton />
|
||||||
</Box>
|
</Box>
|
||||||
<Stack align="center" gap="xs">
|
<Stack
|
||||||
|
align="center"
|
||||||
|
gap="xs"
|
||||||
|
px={{ base: 'md', md: 100 }}
|
||||||
|
>
|
||||||
<IconBook2 size={42} stroke={1.5} color={colors["blue-button"]} />
|
<IconBook2 size={42} stroke={1.5} color={colors["blue-button"]} />
|
||||||
<Text
|
<Text
|
||||||
ta="center"
|
ta="center"
|
||||||
@@ -42,7 +46,7 @@ function Page() {
|
|||||||
>
|
>
|
||||||
Dasar Hukum
|
Dasar Hukum
|
||||||
</Text>
|
</Text>
|
||||||
<Text ta="center" fz="md" >
|
<Text ta="center" fz="md" c={"black"}>
|
||||||
Informasi regulasi dan kebijakan resmi yang menjadi dasar hukum
|
Informasi regulasi dan kebijakan resmi yang menjadi dasar hukum
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -71,12 +75,15 @@ function Page() {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text
|
<Text
|
||||||
ta="center"
|
ta="center"
|
||||||
|
c={"black"}
|
||||||
fw="bold"
|
fw="bold"
|
||||||
fz={{ base: 'lg', md: 'xl' }}
|
fz={{ base: 'lg', md: 'xl' }}
|
||||||
style={{ lineHeight: 1.4 }}
|
style={{ lineHeight: 1.4 }}
|
||||||
dangerouslySetInnerHTML={{ __html: item.judul }}
|
dangerouslySetInnerHTML={{ __html: item.judul }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
|
c={"black"}
|
||||||
|
ta={"justify"}
|
||||||
fz={{ base: 'sm', md: 'md' }}
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
style={{ lineHeight: 1.7, wordBreak: "break-word", whiteSpace: "normal" }}
|
style={{ lineHeight: 1.7, wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
dangerouslySetInnerHTML={{ __html: item.content }}
|
dangerouslySetInnerHTML={{ __html: item.content }}
|
||||||
|
|||||||
@@ -598,7 +598,7 @@ const state = useProxy(indeksKepuasanState.responden);
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Nama"
|
label="Nama"
|
||||||
type='text'
|
type='text'
|
||||||
placeholder="masukkan nama"
|
placeholder="Masukkan nama"
|
||||||
value={state.create.form.name}
|
value={state.create.form.name}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
state.create.form.name = val.currentTarget.value;
|
state.create.form.name = val.currentTarget.value;
|
||||||
@@ -607,7 +607,7 @@ const state = useProxy(indeksKepuasanState.responden);
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Tanggal Pengisian"
|
label="Tanggal Pengisian"
|
||||||
type="date"
|
type="date"
|
||||||
placeholder="masukkan tanggal"
|
placeholder="Masukkan tanggal"
|
||||||
value={state.create.form.tanggal}
|
value={state.create.form.tanggal}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
state.create.form.tanggal = val.currentTarget.value;
|
state.create.form.tanggal = val.currentTarget.value;
|
||||||
|
|||||||
@@ -53,23 +53,11 @@ function Page() {
|
|||||||
const permohonanInformasiPublikState = useProxy(statePermohonanInformasi);
|
const permohonanInformasiPublikState = useProxy(statePermohonanInformasi);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const submitForms = () => {
|
const submitForms = async () => {
|
||||||
const { create } = permohonanInformasiPublikState.statepermohonanInformasiPublik;
|
const { create } = permohonanInformasiPublikState.statepermohonanInformasiPublik;
|
||||||
|
const hasil = await create.create(); // tunggu hasilnya
|
||||||
if (
|
if (hasil) {
|
||||||
create.form.name &&
|
|
||||||
create.form.nik &&
|
|
||||||
create.form.notelp &&
|
|
||||||
create.form.alamat &&
|
|
||||||
create.form.email &&
|
|
||||||
create.form.jenisInformasiDimintaId &&
|
|
||||||
create.form.caraMemperolehInformasiId &&
|
|
||||||
create.form.caraMemperolehSalinanInformasiId
|
|
||||||
) {
|
|
||||||
create.create();
|
|
||||||
router.push('/darmasaba/permohonan/berhasil');
|
router.push('/darmasaba/permohonan/berhasil');
|
||||||
} else {
|
|
||||||
console.log('Validasi gagal, form tidak lengkap');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -55,17 +55,13 @@ function Page() {
|
|||||||
const stateKeberatan = useProxy(permohonanKeberatanInformasi);
|
const stateKeberatan = useProxy(permohonanKeberatanInformasi);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const submit = () => {
|
const submit = async () => {
|
||||||
if (
|
const { create } = stateKeberatan;
|
||||||
stateKeberatan.create.form.name &&
|
|
||||||
stateKeberatan.create.form.email &&
|
const hasil = await create.create(); // tunggu hasilnya
|
||||||
stateKeberatan.create.form.notelp &&
|
|
||||||
stateKeberatan.create.form.alasan
|
if (hasil) {
|
||||||
) {
|
|
||||||
stateKeberatan.create.create();
|
|
||||||
router.push('/darmasaba/permohonan/berhasil');
|
router.push('/darmasaba/permohonan/berhasil');
|
||||||
} else {
|
|
||||||
console.log('Formulir belum lengkap');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,7 +186,7 @@ function Page() {
|
|||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Nomor Telepon"
|
label="Nomor Telepon"
|
||||||
placeholder="Contoh: 0812-3456-7890"
|
placeholder="Contoh: 081234567890"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
|
|||||||
@@ -96,14 +96,20 @@ function Page() {
|
|||||||
<IconUser size={28} />
|
<IconUser size={28} />
|
||||||
<Text fz={{ base: "1.25rem", md: "1.5rem" }} fw="bold">Biografi</Text>
|
<Text fz={{ base: "1.25rem", md: "1.5rem" }} fw="bold">Biografi</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Text fz={{ base: "1rem", md: "1.125rem", lg: "1.25rem" }} ta="justify" dangerouslySetInnerHTML={{ __html: item.biodata }} style={{ wordBreak: "break-word", whiteSpace: "normal" }} />
|
<Box px={20}>
|
||||||
|
<Text fz={{ base: "1rem", md: "1.125rem", lg: "1.25rem" }} ta="justify" dangerouslySetInnerHTML={{ __html: item.biodata }} style={{ wordBreak: "break-word", whiteSpace: "normal" }} />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Flex align="center" gap="sm" mb="sm">
|
<Flex align="center" gap="sm" mb="sm">
|
||||||
<IconTimeline size={28} />
|
<IconTimeline size={28} />
|
||||||
<Text fz={{ base: "1.25rem", md: "1.5rem" }} fw="bold">Riwayat Karir</Text>
|
<Text fz={{ base: "1.25rem", md: "1.5rem" }} fw="bold">Riwayat Karir</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Text fz={{ base: "1rem", md: "1.125rem", lg: "1.25rem" }} dangerouslySetInnerHTML={{ __html: item.riwayat }} style={{ wordBreak: "break-word", whiteSpace: "normal" }} />
|
<List spacing="xs" size="sm">
|
||||||
|
<Box px={20}>
|
||||||
|
<Text fz={{ base: "1rem", md: "1.125rem", lg: "1.25rem" }} dangerouslySetInnerHTML={{ __html: item.riwayat }} style={{ wordBreak: "break-word", whiteSpace: "normal" }} />
|
||||||
|
</Box>
|
||||||
|
</List>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
TabsList,
|
||||||
|
TabsTab,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
@@ -35,6 +38,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { useProxy } from 'valtio/utils'
|
import { useProxy } from 'valtio/utils'
|
||||||
import BackButton from '../../desa/layanan/_com/BackButto'
|
import BackButton from '../../desa/layanan/_com/BackButto'
|
||||||
import './struktur.css'
|
import './struktur.css'
|
||||||
|
import { useMediaQuery } from '@mantine/hooks'
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
@@ -231,87 +235,121 @@ function StrukturOrganisasiPPID() {
|
|||||||
p="md"
|
p="md"
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{
|
style={{
|
||||||
background: colors['blue-button']
|
background: colors['blue-button'],
|
||||||
|
width: '100%', // ⬅️ penting
|
||||||
|
maxWidth: '100%', // ⬅️ penting
|
||||||
|
overflowX: 'auto' // ⬅️ untuk mencegah overflow
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap="sm" wrap="wrap" justify="center">
|
|
||||||
<TextInput
|
<Stack gap="sm">
|
||||||
placeholder="Cari nama atau jabatan..."
|
<Group justify='center'>
|
||||||
leftSection={<IconSearch size={16} />}
|
<TextInput
|
||||||
onChange={(e) => debouncedSearch(e.target.value)}
|
placeholder="Cari nama atau jabatan..."
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
onChange={(e) => debouncedSearch(e.target.value)}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
minWidth: 250,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Tabs
|
||||||
|
defaultValue="zoom-out"
|
||||||
|
variant="outline"
|
||||||
|
radius="md"
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
panel: { display: 'none' },
|
||||||
minWidth: 250,
|
tab: {
|
||||||
|
color: colors['blue-button'],
|
||||||
|
backgroundColor: colors['blue-button-2'],
|
||||||
|
border: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
padding: '6px 12px',
|
||||||
|
minHeight: 'auto',
|
||||||
|
flexShrink: 0, // 👈 PENTING: mencegah tab mengecil
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<TabsList
|
||||||
<Group gap="xs">
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
bg={colors['blue-button-2']}
|
|
||||||
size="sm"
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
leftSection={<IconZoomOut size={16} />}
|
|
||||||
c={colors['blue-button']}
|
|
||||||
>
|
|
||||||
Zoom Out
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
bg={colors['blue-button-2']}
|
|
||||||
c={colors['blue-button']}
|
|
||||||
px={16}
|
|
||||||
py={8}
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
display: 'flex',
|
||||||
fontWeight: 700,
|
overflowX: 'auto',
|
||||||
borderRadius: '8px',
|
overflowY: 'hidden', // 👈 tambahkan ini
|
||||||
minWidth: 70,
|
gap: '4px',
|
||||||
textAlign: 'center',
|
paddingBottom: '4px',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
WebkitOverflowScrolling: 'touch', // 👈 smooth scroll di iOS
|
||||||
|
scrollbarWidth: 'thin', // 👈 scrollbar tipis di Firefox
|
||||||
|
msOverflowStyle: '-ms-autohiding-scrollbar', // 👈 untuk IE/Edge
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Math.round(scale * 100)}%
|
<TabsTab
|
||||||
</Box>
|
value="zoom-out"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
leftSection={<IconZoomOut size={16} />}
|
||||||
|
style={{ flexShrink: 0 }} // 👈 pastikan tidak mengecil
|
||||||
|
>
|
||||||
|
Zoom Out
|
||||||
|
</TabsTab>
|
||||||
|
|
||||||
<Button
|
<Box
|
||||||
bg={colors['blue-button-2']}
|
bg={colors['blue-button-2']}
|
||||||
c={colors['blue-button']}
|
c={colors['blue-button']}
|
||||||
variant="light"
|
px={12}
|
||||||
size="sm"
|
py={6}
|
||||||
onClick={handleZoomIn}
|
style={{
|
||||||
leftSection={<IconZoomIn size={16} />}
|
fontSize: 14,
|
||||||
>
|
fontWeight: 700,
|
||||||
Zoom In
|
borderRadius: '6px',
|
||||||
</Button>
|
minWidth: 60,
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: 'nowrap', // 👈 mencegah text wrap
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(scale * 100)}%
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Button
|
<TabsTab
|
||||||
bg={colors['blue-button-2']}
|
value="zoom-in"
|
||||||
c={colors['blue-button']}
|
onClick={handleZoomIn}
|
||||||
variant="light"
|
leftSection={<IconZoomIn size={16} />}
|
||||||
size="sm"
|
style={{ flexShrink: 0 }}
|
||||||
onClick={resetZoom}
|
>
|
||||||
>
|
Zoom In
|
||||||
Reset
|
</TabsTab>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<TabsTab
|
||||||
bg={colors['blue-button-2']}
|
value="reset"
|
||||||
c={colors['blue-button']}
|
onClick={resetZoom}
|
||||||
size="sm"
|
style={{ flexShrink: 0 }}
|
||||||
onClick={toggleFullscreen}
|
>
|
||||||
leftSection={
|
Reset
|
||||||
isFullscreen ? (
|
</TabsTab>
|
||||||
<IconArrowsMinimize size={16} />
|
|
||||||
) : (
|
<TabsTab
|
||||||
<IconArrowsMaximize size={16} />
|
value="fullscreen"
|
||||||
)
|
onClick={toggleFullscreen}
|
||||||
}
|
leftSection={
|
||||||
>
|
isFullscreen ? (
|
||||||
Fullscreen
|
<IconArrowsMinimize size={16} />
|
||||||
</Button>
|
) : (
|
||||||
</Group>
|
<IconArrowsMaximize size={16} />
|
||||||
</Group>
|
)
|
||||||
|
}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
||||||
|
</TabsTab>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* 🧩 Chart Container */}
|
{/* 🧩 Chart Container */}
|
||||||
@@ -325,15 +363,20 @@ function StrukturOrganisasiPPID() {
|
|||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
padding: '32px 16px',
|
padding: '32px 16px',
|
||||||
transition: 'transform 0.2s ease',
|
transition: 'transform 0.2s ease',
|
||||||
transform: `scale(${scale})`,
|
|
||||||
transformOrigin: 'center top',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OrganizationChart
|
<Box style={{
|
||||||
value={chartData}
|
transform: `scale(${scale})`,
|
||||||
nodeTemplate={(node) => <NodeCard node={node} router={router} />}
|
transformOrigin: 'center top',
|
||||||
className="p-organizationchart p-organizationchart-horizontal"
|
display: 'inline-block', // 👈 agar tidak memenuhi lebar parent
|
||||||
/>
|
minWidth: 'min-content', // 👈 penting agar chart tidak dipaksa muat di width 100%
|
||||||
|
}}>
|
||||||
|
<OrganizationChart
|
||||||
|
value={chartData}
|
||||||
|
nodeTemplate={(node) => <NodeCard node={node} router={router} />}
|
||||||
|
className="p-organizationchart p-organizationchart-horizontal"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Center>
|
</Center>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -345,6 +388,7 @@ function NodeCard({ node, router }: any) {
|
|||||||
const name = node?.data?.name || 'Tanpa Nama'
|
const name = node?.data?.name || 'Tanpa Nama'
|
||||||
const title = node?.data?.title || 'Tanpa Jabatan'
|
const title = node?.data?.title || 'Tanpa Jabatan'
|
||||||
const hasId = Boolean(node?.data?.id)
|
const hasId = Boolean(node?.data?.id)
|
||||||
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition mounted transition="pop" duration={300}>
|
<Transition mounted transition="pop" duration={300}>
|
||||||
@@ -355,9 +399,10 @@ function NodeCard({ node, router }: any) {
|
|||||||
withBorder
|
withBorder
|
||||||
style={{
|
style={{
|
||||||
...styles,
|
...styles,
|
||||||
width: 240,
|
width: '100%',
|
||||||
minHeight: 280,
|
maxWidth: isMobile ? 200 : 240, // lebih kecil di mobile
|
||||||
padding: 20,
|
minHeight: isMobile ? 240 : 280,
|
||||||
|
padding: isMobile ? 16 : 20,
|
||||||
background: 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)',
|
background: 'linear-gradient(135deg, rgba(28,110,164,0.15) 0%, rgba(255,255,255,0.95) 100%)',
|
||||||
borderColor: 'rgba(28, 110, 164, 0.3)',
|
borderColor: 'rgba(28, 110, 164, 0.3)',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
@@ -411,6 +456,7 @@ function NodeCard({ node, router }: any) {
|
|||||||
c={colors['blue-button']}
|
c={colors['blue-button']}
|
||||||
lineClamp={2}
|
lineClamp={2}
|
||||||
style={{
|
style={{
|
||||||
|
// fontSize: 'clamp(12px, 4vw, 16px)', // 👈 responsif font size
|
||||||
minHeight: 40,
|
minHeight: 40,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function Page() {
|
|||||||
lh={1.7}
|
lh={1.7}
|
||||||
ta="center"
|
ta="center"
|
||||||
dangerouslySetInnerHTML={{ __html: item.visi }}
|
dangerouslySetInnerHTML={{ __html: item.visi }}
|
||||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -86,12 +86,15 @@ function Page() {
|
|||||||
c={colors['blue-button']} mb="sm">
|
c={colors['blue-button']} mb="sm">
|
||||||
Misi PPID
|
Misi PPID
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Box px={{ base: 'md', md: 100 }}>
|
||||||
fz={{ base: 'md', md: 'lg' }}
|
<Text
|
||||||
lh={1.7}
|
ta={"justify"}
|
||||||
dangerouslySetInnerHTML={{ __html: item.misi }}
|
fz={{ base: 'md', md: 'lg' }}
|
||||||
style={{wordBreak: "break-word", whiteSpace: "normal"}}
|
lh={1.7}
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: item.misi }}
|
||||||
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,85 +1,117 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import {
|
||||||
import { Box, Paper, Text, Group, CloseButton, Badge, ActionIcon, Stack, Transition } from "@mantine/core";
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
CloseButton,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Transition,
|
||||||
|
} from "@mantine/core";
|
||||||
import { IconBell, IconChevronRight } from "@tabler/icons-react";
|
import { IconBell, IconChevronRight } from "@tabler/icons-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface NewsItem {
|
// === Tipe yang bisa diimpor di tempat lain ===
|
||||||
id: string | number;
|
export interface KategoriBerita {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KategoriPengumuman {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsItem {
|
||||||
|
id: string;
|
||||||
type: "berita" | "pengumuman";
|
type: "berita" | "pengumuman";
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
timestamp?: string | Date;
|
timestamp?: string | Date;
|
||||||
|
kategoriBerita?: KategoriBerita;
|
||||||
|
kategoriPengumuman?: KategoriPengumuman;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModernNewsNotificationProps {
|
export interface ModernNewsNotificationProps {
|
||||||
news: NewsItem[];
|
news: NewsItem[];
|
||||||
|
hasNewContent?: boolean;
|
||||||
|
newItemCount?: number;
|
||||||
|
onSeen?: () => void;
|
||||||
autoShowDelay?: number;
|
autoShowDelay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Helper ===
|
||||||
function stripHtml(html: string): string {
|
function stripHtml(html: string): string {
|
||||||
return html
|
return html
|
||||||
.replace(/<[^>]+>/g, '')
|
.replace(/<[^>]+>/g, "")
|
||||||
.replace(/ /gi, ' ')
|
.replace(/ /gi, " ")
|
||||||
.replace(/&/gi, '&')
|
.replace(/&/gi, "&")
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Komponen Utama ===
|
||||||
export default function ModernNewsNotification({
|
export default function ModernNewsNotification({
|
||||||
news = [],
|
news = [],
|
||||||
autoShowDelay = 2000
|
hasNewContent = false,
|
||||||
|
newItemCount = 0,
|
||||||
|
onSeen,
|
||||||
|
autoShowDelay = 2000,
|
||||||
}: ModernNewsNotificationProps) {
|
}: ModernNewsNotificationProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [toastVisible, setToastVisible] = useState(false);
|
|
||||||
const [widgetOpen, setWidgetOpen] = useState(false);
|
|
||||||
const [hasNewNotifications, setHasNewNotifications] = useState(true);
|
|
||||||
const [hasShownToast, setHasShownToast] = useState(false);
|
|
||||||
const [iconVisible, setIconVisible] = useState(true);
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Auto show toast on page load
|
const [toastVisible, setToastVisible] = useState(false);
|
||||||
|
const [widgetOpen, setWidgetOpen] = useState(false);
|
||||||
|
const [hasNewNotifications, setHasNewNotifications] = useState(hasNewContent);
|
||||||
|
const [hasShownToast, setHasShownToast] = useState(false);
|
||||||
|
const [iconVisible, setIconVisible] = useState(true);
|
||||||
|
|
||||||
|
// Sinkronisasi prop eksternal
|
||||||
|
useEffect(() => {
|
||||||
|
setHasNewNotifications(hasNewContent);
|
||||||
|
}, [hasNewContent]);
|
||||||
|
|
||||||
|
// Tampilkan toast pertama kali
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (news.length > 0 && !toastVisible && !hasShownToast) {
|
if (news.length > 0 && !toastVisible && !hasShownToast) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setToastVisible(true);
|
setToastVisible(true);
|
||||||
setHasShownToast(true);
|
setHasShownToast(true);
|
||||||
|
if (hasNewNotifications) onSeen?.();
|
||||||
}, autoShowDelay);
|
}, autoShowDelay);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [news.length, autoShowDelay, toastVisible, hasShownToast]);
|
}, [news.length, autoShowDelay, toastVisible, hasShownToast, hasNewNotifications, onSeen]);
|
||||||
|
|
||||||
// Auto hide toast after 8 seconds
|
// Sembunyikan toast otomatis
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toastVisible) {
|
if (toastVisible) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => setToastVisible(false), 8000);
|
||||||
setToastVisible(false);
|
|
||||||
}, 8000);
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [toastVisible]);
|
}, [toastVisible]);
|
||||||
|
|
||||||
// Enhanced scroll handler with better thresholds
|
// Kontrol visibilitas ikon saat scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let lastScrollY = window.scrollY;
|
let lastScrollY = window.scrollY;
|
||||||
const HIDE_THRESHOLD = 100; // Mulai hide saat scroll > 100px
|
const HIDE_THRESHOLD = 100;
|
||||||
const SHOW_THRESHOLD = 50; // Hanya show ketika benar-benar di atas (< 50px)
|
const SHOW_THRESHOLD = 50;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const currentScrollY = window.scrollY;
|
const currentScrollY = window.scrollY;
|
||||||
const scrollDirection = currentScrollY > lastScrollY ? 'down' : 'up';
|
const scrollDirection = currentScrollY > lastScrollY ? "down" : "up";
|
||||||
|
|
||||||
// Logic untuk hide/show icon
|
if (scrollDirection === "down" && currentScrollY > HIDE_THRESHOLD) {
|
||||||
if (scrollDirection === 'down' && currentScrollY > HIDE_THRESHOLD) {
|
|
||||||
// Scroll ke bawah dan sudah melewati threshold → hide
|
|
||||||
setIconVisible(false);
|
setIconVisible(false);
|
||||||
} else if (scrollDirection === 'up' && currentScrollY < SHOW_THRESHOLD) {
|
} else if (scrollDirection === "up" && currentScrollY < SHOW_THRESHOLD) {
|
||||||
// Scroll ke atas dan sudah di posisi paling atas → show
|
|
||||||
setIconVisible(true);
|
setIconVisible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide toast saat scroll ke bawah melewati 150px
|
|
||||||
if (currentScrollY > 150 && toastVisible) {
|
if (currentScrollY > 150 && toastVisible) {
|
||||||
setToastVisible(false);
|
setToastVisible(false);
|
||||||
}
|
}
|
||||||
@@ -87,19 +119,25 @@ export default function ModernNewsNotification({
|
|||||||
lastScrollY = currentScrollY;
|
lastScrollY = currentScrollY;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
}, [toastVisible]);
|
}, [toastVisible]);
|
||||||
|
|
||||||
const currentNews = news[0];
|
const currentNews = news[0];
|
||||||
|
|
||||||
// Handle notification click
|
// 🔗 Arahkan ke detail dengan kategori aman
|
||||||
const handleNotificationClick = (item: NewsItem) => {
|
const handleNotificationClick = (item: NewsItem) => {
|
||||||
setWidgetOpen(false);
|
setWidgetOpen(false);
|
||||||
|
onSeen?.();
|
||||||
|
|
||||||
if (item.type === "berita") {
|
if (item.type === "berita") {
|
||||||
router.push("/darmasaba/desa/berita/semua");
|
const kategori = item.kategoriBerita?.name || "umum";
|
||||||
|
const safeKategori = encodeURIComponent(kategori);
|
||||||
|
router.push(`/darmasaba/desa/berita/${safeKategori}/${item.id}`);
|
||||||
} else if (item.type === "pengumuman") {
|
} else if (item.type === "pengumuman") {
|
||||||
router.push("/darmasaba/desa/pengumuman");
|
const kategori = item.kategoriPengumuman?.name || "umum";
|
||||||
|
const safeKategori = encodeURIComponent(kategori);
|
||||||
|
router.push(`/darmasaba/desa/pengumuman/${safeKategori}/${item.id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,12 +145,17 @@ export default function ModernNewsNotification({
|
|||||||
setToastVisible(false);
|
setToastVisible(false);
|
||||||
setWidgetOpen(true);
|
setWidgetOpen(true);
|
||||||
setHasNewNotifications(false);
|
setHasNewNotifications(false);
|
||||||
|
onSeen?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only show on landing page
|
const handleDismissToast = (e: React.MouseEvent) => {
|
||||||
if (pathname !== '/darmasaba') {
|
e.stopPropagation();
|
||||||
return null;
|
setToastVisible(false);
|
||||||
}
|
onSeen?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hanya tampilkan di landing page
|
||||||
|
if (pathname !== "/darmasaba") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -133,8 +176,9 @@ export default function ModernNewsNotification({
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
color="#1e5a7e"
|
color="#1e5a7e"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setWidgetOpen(!widgetOpen);
|
setWidgetOpen((open) => !open);
|
||||||
setHasNewNotifications(false);
|
setHasNewNotifications(false);
|
||||||
|
onSeen?.();
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: "60px",
|
width: "60px",
|
||||||
@@ -146,20 +190,22 @@ export default function ModernNewsNotification({
|
|||||||
<IconBell size={28} />
|
<IconBell size={28} />
|
||||||
{hasNewNotifications && news.length > 0 && (
|
{hasNewNotifications && news.length > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="red"
|
color="red"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "6px",
|
top: "6px",
|
||||||
right: "6px",
|
right: "6px",
|
||||||
minWidth: "22px",
|
minWidth: "22px",
|
||||||
height: "22px",
|
height: "22px",
|
||||||
padding: "0 6px",
|
display: "flex",
|
||||||
}}
|
alignItems: "center",
|
||||||
>
|
justifyContent: "center",
|
||||||
{news.length}
|
}}
|
||||||
</Badge>
|
>
|
||||||
|
{newItemCount || news.length}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -174,8 +220,9 @@ export default function ModernNewsNotification({
|
|||||||
...styles,
|
...styles,
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
bottom: "100px",
|
bottom: "100px",
|
||||||
right: "24px",
|
left: "24px",
|
||||||
width: "380px",
|
width: "90vw",
|
||||||
|
maxWidth: 380,
|
||||||
maxHeight: "500px",
|
maxHeight: "500px",
|
||||||
boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
|
boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
|
||||||
borderRadius: "16px",
|
borderRadius: "16px",
|
||||||
@@ -192,32 +239,33 @@ export default function ModernNewsNotification({
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<IconBell size={20} />
|
<IconBell size={20} />
|
||||||
<Text c="white" fw={600} size="md">Berita & Pengumuman</Text>
|
<Text c="white" fw={600} size="md">
|
||||||
|
Berita & Pengumuman
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<CloseButton
|
<CloseButton
|
||||||
onClick={() => setWidgetOpen(false)}
|
onClick={() => {
|
||||||
|
setWidgetOpen(false);
|
||||||
|
onSeen?.();
|
||||||
|
}}
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="white"
|
c="white"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
<Box style={{ maxHeight: "400px", overflowY: "auto", padding: "12px" }}>
|
||||||
style={{
|
|
||||||
maxHeight: "400px",
|
|
||||||
overflowY: "auto",
|
|
||||||
padding: "12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{news.length === 0 ? (
|
{news.length === 0 ? (
|
||||||
<Box p="xl" style={{ textAlign: "center" }}>
|
<Box p="xl" style={{ textAlign: "center" }}>
|
||||||
<Text c="dimmed" size="sm">Tidak ada berita terbaru</Text>
|
<Text c="dimmed" size="sm">
|
||||||
|
Tidak ada berita terbaru
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{news.map((item, index) => (
|
{news.map((item) => (
|
||||||
<Paper
|
<Paper
|
||||||
key={item.id || index}
|
key={item.id}
|
||||||
p="md"
|
p="md"
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{
|
style={{
|
||||||
@@ -243,7 +291,7 @@ export default function ModernNewsNotification({
|
|||||||
color={item.type === "berita" ? "blue" : "orange"}
|
color={item.type === "berita" ? "blue" : "orange"}
|
||||||
variant="light"
|
variant="light"
|
||||||
>
|
>
|
||||||
{item.type === "berita" ? "📰 Berita" : "📢 Pengumuman"}
|
{item.type === "berita" ? "Berita" : "Pengumuman"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<IconChevronRight size={16} color="#adb5bd" />
|
<IconChevronRight size={16} color="#adb5bd" />
|
||||||
</Group>
|
</Group>
|
||||||
@@ -263,15 +311,20 @@ export default function ModernNewsNotification({
|
|||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
{/* Toast Notification */}
|
{/* Toast Notification */}
|
||||||
<Transition mounted={toastVisible && !!currentNews} transition="slide-left" duration={300}>
|
<Transition
|
||||||
|
mounted={toastVisible && !!currentNews}
|
||||||
|
transition="slide-left"
|
||||||
|
duration={300}
|
||||||
|
>
|
||||||
{(styles) => (
|
{(styles) => (
|
||||||
<Paper
|
<Paper
|
||||||
style={{
|
style={{
|
||||||
...styles,
|
...styles,
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
bottom: "100px",
|
bottom: "100px",
|
||||||
right: "24px",
|
left: "24px",
|
||||||
width: "380px",
|
width: "90vw",
|
||||||
|
maxWidth: 380,
|
||||||
boxShadow: "0 8px 32px rgba(0,0,0,0.15)",
|
boxShadow: "0 8px 32px rgba(0,0,0,0.15)",
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
@@ -299,17 +352,12 @@ export default function ModernNewsNotification({
|
|||||||
size="md"
|
size="md"
|
||||||
color={currentNews?.type === "berita" ? "blue" : "orange"}
|
color={currentNews?.type === "berita" ? "blue" : "orange"}
|
||||||
variant="light"
|
variant="light"
|
||||||
leftSection={currentNews?.type === "berita" ? "📰" : "📢"}
|
|
||||||
>
|
>
|
||||||
{currentNews?.type === "berita" ? "Berita Terbaru" : "Pengumuman"}
|
{currentNews?.type === "berita"
|
||||||
|
? "Berita Terbaru"
|
||||||
|
: "Pengumuman"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<CloseButton
|
<CloseButton onClick={handleDismissToast} size="sm" />
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setToastVisible(false);
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text fw={600} size="sm" mb={6}>
|
<Text fw={600} size="sm" mb={6}>
|
||||||
@@ -322,7 +370,7 @@ export default function ModernNewsNotification({
|
|||||||
|
|
||||||
<Group justify="space-between" mt="md">
|
<Group justify="space-between" mt="md">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{news.length > 1 ? `${news.length} berita tersedia` : '1 berita'}
|
{news.length > 1 ? `${news.length} berita tersedia` : "1 berita"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -4,7 +4,7 @@ import indeksKepuasanState from "@/app/admin/(dashboard)/_state/landing-page/ind
|
|||||||
import colors from "@/con/colors";
|
import colors from "@/con/colors";
|
||||||
import { BarChart, PieChart } from '@mantine/charts';
|
import { BarChart, PieChart } from '@mantine/charts';
|
||||||
import { Box, Button, Center, Container, Flex, Modal, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from "@mantine/core";
|
import { Box, Button, Center, Container, Flex, Modal, Paper, Select, SimpleGrid, Skeleton, Stack, Text, TextInput, Title } from "@mantine/core";
|
||||||
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
|
import { useDisclosure, useMediaQuery, useShallowEffect } from "@mantine/hooks";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useProxy } from "valtio/utils";
|
import { useProxy } from "valtio/utils";
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ function Kepuasan() {
|
|||||||
const [donutDataKelompokUmur, setDonutDataKelompokUmur] = useState<ChartDataItem[]>([]);
|
const [donutDataKelompokUmur, setDonutDataKelompokUmur] = useState<ChartDataItem[]>([]);
|
||||||
const [barChartData, setBarChartData] = useState<Array<{ month: string; Responden: number }>>([]);
|
const [barChartData, setBarChartData] = useState<Array<{ month: string; Responden: number }>>([]);
|
||||||
const [opened, { open, close }] = useDisclosure(false)
|
const [opened, { open, close }] = useDisclosure(false)
|
||||||
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
state.create.form = {
|
state.create.form = {
|
||||||
@@ -41,7 +42,7 @@ function Kepuasan() {
|
|||||||
indeksKepuasanState.jenisKelaminResponden.findMany.load()
|
indeksKepuasanState.jenisKelaminResponden.findMany.load()
|
||||||
indeksKepuasanState.pilihanRatingResponden.findMany.load()
|
indeksKepuasanState.pilihanRatingResponden.findMany.load()
|
||||||
indeksKepuasanState.kelompokUmurResponden.findMany.load()
|
indeksKepuasanState.kelompokUmurResponden.findMany.load()
|
||||||
},[])
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -82,13 +83,13 @@ function Kepuasan() {
|
|||||||
|
|
||||||
// Update gender chart data
|
// Update gender chart data
|
||||||
setDonutDataJenisKelamin([
|
setDonutDataJenisKelamin([
|
||||||
{ name: 'Laki-laki', value: totalLaki, color: colors['blue-button'] },
|
{ name: 'Laki-laki', value: totalLaki, color: '#52ABE3FF' },
|
||||||
{ name: 'Perempuan', value: totalPerempuan, color: '#10A85AFF' },
|
{ name: 'Perempuan', value: totalPerempuan, color: '#10A85AFF' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Update rating chart data
|
// Update rating chart data
|
||||||
setDonutDataRating([
|
setDonutDataRating([
|
||||||
{ name: 'Sangat Baik', value: totalSangatBaik, color: colors['blue-button'] },
|
{ name: 'Sangat Baik', value: totalSangatBaik, color: '#52ABE3FF' },
|
||||||
{ name: 'Baik', value: totalBaik, color: '#10A85AFF' },
|
{ name: 'Baik', value: totalBaik, color: '#10A85AFF' },
|
||||||
{ name: 'Kurang Baik', value: totalKurangBaik, color: '#FFA500' },
|
{ name: 'Kurang Baik', value: totalKurangBaik, color: '#FFA500' },
|
||||||
{ name: 'Sangat Kurang Baik', value: totalSangatKurangBaik, color: '#FF4500' },
|
{ name: 'Sangat Kurang Baik', value: totalSangatKurangBaik, color: '#FF4500' },
|
||||||
@@ -96,7 +97,7 @@ function Kepuasan() {
|
|||||||
|
|
||||||
// Update age group chart data
|
// Update age group chart data
|
||||||
setDonutDataKelompokUmur([
|
setDonutDataKelompokUmur([
|
||||||
{ name: 'Muda', value: totalMuda, color: colors['blue-button'] },
|
{ name: 'Muda', value: totalMuda, color: '#52ABE3FF' },
|
||||||
{ name: 'Dewasa', value: totalDewasa, color: '#10A85AFF' },
|
{ name: 'Dewasa', value: totalDewasa, color: '#10A85AFF' },
|
||||||
{ name: 'Lansia', value: totalLansia, color: '#FFA500' },
|
{ name: 'Lansia', value: totalLansia, color: '#FFA500' },
|
||||||
]);
|
]);
|
||||||
@@ -220,10 +221,13 @@ function Kepuasan() {
|
|||||||
<Box style={{ position: 'relative', width: '100%' }}>
|
<Box style={{ position: 'relative', width: '100%' }}>
|
||||||
<Center>
|
<Center>
|
||||||
<PieChart
|
<PieChart
|
||||||
withLabels
|
|
||||||
withTooltip
|
withTooltip
|
||||||
|
tooltipAnimationDuration={200}
|
||||||
|
withLabels
|
||||||
|
labelsPosition="inside" // 👈 ini yang penting!
|
||||||
labelsType="percent"
|
labelsType="percent"
|
||||||
size={250} // Fixed size in pixels
|
withLabelsLine
|
||||||
|
size={isMobile ? 180 : 250} // 👈 kecilkan ukuran di mobile
|
||||||
data={donutDataJenisKelamin}
|
data={donutDataJenisKelamin}
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -259,10 +263,10 @@ function Kepuasan() {
|
|||||||
withTooltip
|
withTooltip
|
||||||
tooltipAnimationDuration={200}
|
tooltipAnimationDuration={200}
|
||||||
withLabels
|
withLabels
|
||||||
labelsPosition="outside"
|
labelsPosition="inside" // 👈 ini yang penting!
|
||||||
labelsType="percent"
|
labelsType="percent"
|
||||||
withLabelsLine
|
withLabelsLine
|
||||||
size={250}
|
size={isMobile ? 180 : 250} // 👈 kecilkan ukuran di mobile
|
||||||
data={donutDataRating}
|
data={donutDataRating}
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -302,10 +306,10 @@ function Kepuasan() {
|
|||||||
withTooltip
|
withTooltip
|
||||||
tooltipAnimationDuration={200}
|
tooltipAnimationDuration={200}
|
||||||
withLabels
|
withLabels
|
||||||
labelsPosition="outside"
|
labelsPosition="inside"// 👈 ini yang penting!
|
||||||
labelsType="percent"
|
labelsType="percent"
|
||||||
withLabelsLine
|
withLabelsLine
|
||||||
size={250}
|
size={isMobile ? 180 : 250} // 👈 kecilkan ukuran di mobile
|
||||||
data={donutDataKelompokUmur}
|
data={donutDataKelompokUmur}
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -494,6 +498,8 @@ function Kepuasan() {
|
|||||||
<PieChart
|
<PieChart
|
||||||
withLabels
|
withLabels
|
||||||
withTooltip
|
withTooltip
|
||||||
|
labelsPosition="inside"
|
||||||
|
|
||||||
labelsType="percent"
|
labelsType="percent"
|
||||||
size={200}
|
size={200}
|
||||||
data={donutDataJenisKelamin}
|
data={donutDataJenisKelamin}
|
||||||
@@ -531,7 +537,8 @@ function Kepuasan() {
|
|||||||
withTooltip
|
withTooltip
|
||||||
tooltipAnimationDuration={200}
|
tooltipAnimationDuration={200}
|
||||||
withLabels
|
withLabels
|
||||||
labelsPosition="outside"
|
|
||||||
|
labelsPosition="inside"
|
||||||
labelsType="percent"
|
labelsType="percent"
|
||||||
withLabelsLine
|
withLabelsLine
|
||||||
size={200}
|
size={200}
|
||||||
@@ -574,7 +581,8 @@ function Kepuasan() {
|
|||||||
withTooltip
|
withTooltip
|
||||||
tooltipAnimationDuration={200}
|
tooltipAnimationDuration={200}
|
||||||
withLabels
|
withLabels
|
||||||
labelsPosition="outside"
|
|
||||||
|
labelsPosition="inside"
|
||||||
labelsType="percent"
|
labelsType="percent"
|
||||||
withLabelsLine
|
withLabelsLine
|
||||||
size={190}
|
size={190}
|
||||||
@@ -610,7 +618,7 @@ function Kepuasan() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Nama"
|
label="Nama"
|
||||||
type='text'
|
type='text'
|
||||||
placeholder="masukkan nama"
|
placeholder="Masukkan nama"
|
||||||
value={state.create.form.name}
|
value={state.create.form.name}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
state.create.form.name = val.currentTarget.value;
|
state.create.form.name = val.currentTarget.value;
|
||||||
@@ -619,7 +627,7 @@ function Kepuasan() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Tanggal Pengisian"
|
label="Tanggal Pengisian"
|
||||||
type="date"
|
type="date"
|
||||||
placeholder="masukkan tanggal"
|
placeholder="Masukkan tanggal"
|
||||||
value={state.create.form.tanggal}
|
value={state.create.form.tanggal}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
state.create.form.tanggal = val.currentTarget.value;
|
state.create.form.tanggal = val.currentTarget.value;
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function LandingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack bg={colors.Bg} p="md" gap="lg">
|
<Stack bg={colors.Bg} p="md" gap="lg">
|
||||||
<Flex gap="lg" wrap={{ base: "wrap", md: "nowrap" }}>
|
<Flex gap="lg" wrap={{ base: "wrap", md: "nowrap" }} pb={30}>
|
||||||
<Stack w={{ base: "100%", md: "65%" }} gap="lg">
|
<Stack w={{ base: "100%", md: "65%" }} gap="lg">
|
||||||
<Card radius="xl" bg={colors.grey[1]} p="lg" mt={10} shadow="xl">
|
<Card radius="xl" bg={colors.grey[1]} p="lg" mt={10} shadow="xl">
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
'use client';
|
'use client';
|
||||||
import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan";
|
import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan";
|
||||||
import { Stack, Box, Container, Button, Text, Loader, Paper } from "@mantine/core";
|
import { Stack, Box, Container, Button, Text, Loader, Paper, Center, ActionIcon } from "@mantine/core";
|
||||||
import { IconAward, IconArrowRight } from "@tabler/icons-react";
|
import { IconAward, IconArrowRight, IconPlayerPlay } from "@tabler/icons-react";
|
||||||
import { useTransitionRouter } from 'next-view-transitions';
|
import { useTransitionRouter } from 'next-view-transitions';
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useProxy } from "valtio/utils";
|
import { useProxy } from "valtio/utils";
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
|
|
||||||
@@ -13,6 +13,37 @@ function Penghargaan() {
|
|||||||
const state = useProxy(penghargaanState);
|
const state = useProxy(penghargaanState);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
const [showVideo, setShowVideo] = useState(true);
|
||||||
|
const [videoError, setVideoError] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
// Deteksi iOS dengan lebih akurat
|
||||||
|
const isIOS = typeof window !== 'undefined' && (
|
||||||
|
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) // iPad dengan iPadOS 13+
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Di iOS, coba autoplay dulu, kalau gagal tampilkan fallback
|
||||||
|
if (isIOS && videoRef.current) {
|
||||||
|
const playPromise = videoRef.current.play();
|
||||||
|
|
||||||
|
if (playPromise !== undefined) {
|
||||||
|
playPromise
|
||||||
|
.then(() => {
|
||||||
|
// Autoplay berhasil
|
||||||
|
setShowVideo(true);
|
||||||
|
setIsVideoLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Autoplay gagal, tampilkan fallback
|
||||||
|
setShowVideo(false);
|
||||||
|
setVideoError(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isIOS]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -26,28 +57,99 @@ function Penghargaan() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handlePlayVideo = () => {
|
||||||
|
setShowVideo(true);
|
||||||
|
setVideoError(false);
|
||||||
|
|
||||||
|
// Paksa play video setelah user interaction
|
||||||
|
setTimeout(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.play().catch(err => {
|
||||||
|
console.error("Video play error:", err);
|
||||||
|
setVideoError(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
// kalau mobile ambil 1 data aja, kalau desktop ambil 3
|
// kalau mobile ambil 1 data aja, kalau desktop ambil 3
|
||||||
const data = state.findMany.data?.slice(0, isMobile ? 1 : 3);
|
const data = state.findMany.data?.slice(0, isMobile ? 1 : 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack pos="relative" h="auto" mih={{ base: 500, md: 720 }}>
|
<Stack pos="relative" h="auto" mih={{ base: 500, md: 720 }} style={{ overflow: 'hidden' }}>
|
||||||
<video
|
{/* Video Layer */}
|
||||||
loop
|
{showVideo && !videoError && (
|
||||||
autoPlay
|
<video
|
||||||
muted
|
ref={videoRef}
|
||||||
style={{
|
autoPlay
|
||||||
width: "100%",
|
muted
|
||||||
height: "100%",
|
loop
|
||||||
objectFit: "cover",
|
playsInline
|
||||||
position: "absolute",
|
preload="auto"
|
||||||
top: 0,
|
onLoadedData={() => setIsVideoLoaded(true)}
|
||||||
left: 0,
|
onError={() => {
|
||||||
zIndex: 0,
|
console.error("Video load error");
|
||||||
}}
|
setVideoError(true);
|
||||||
>
|
setShowVideo(false);
|
||||||
<source src="/assets/videos/award.mp4" type="video/mp4" />
|
}}
|
||||||
</video>
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
opacity: isVideoLoaded ? 1 : 0,
|
||||||
|
transition: 'opacity 0.5s ease',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<source src="/assets/videos/award.mp4" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fallback Image + Play Button */}
|
||||||
|
{(!showVideo || videoError) && (
|
||||||
|
<Box
|
||||||
|
onClick={handlePlayVideo}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundImage: "url('/mangupuraaward.jpeg')",
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
cursor: 'pointer',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: 'rgba(0,0,0,0.3)', // overlay gelap agar icon terlihat
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
size={80}
|
||||||
|
radius="xl"
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={40} color="var(--mantine-color-blue-6)" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay Gradient + Content */}
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -126,4 +228,4 @@ function Penghargaan() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Penghargaan;
|
export default Penghargaan;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
|
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
|
||||||
import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan";
|
import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan";
|
||||||
import LandingPage from "@/app/darmasaba/_com/main-page/landing-page";
|
import LandingPage from "@/app/darmasaba/_com/main-page/landing-page";
|
||||||
@@ -14,23 +15,41 @@ import Apbdes from "./_com/main-page/apbdes";
|
|||||||
import Prestasi from "./_com/main-page/prestasi";
|
import Prestasi from "./_com/main-page/prestasi";
|
||||||
import ScrollToTopButton from "./_com/scrollToTopButton";
|
import ScrollToTopButton from "./_com/scrollToTopButton";
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useSnapshot } from "valtio";
|
import { useSnapshot } from "valtio";
|
||||||
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
|
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
|
||||||
import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman";
|
import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman";
|
||||||
import ModernNewsNotification from "./_com/ModernNeewsNotification";
|
|
||||||
import NewsReaderLanding from "./_com/NewsReaderalanding";
|
|
||||||
|
|
||||||
|
import NewsReaderLanding from "./_com/NewsReaderalanding";
|
||||||
|
import ModernNewsNotification from "./_com/ModernNewsNotification";
|
||||||
|
import type { NewsItem } from "./_com/ModernNewsNotification"; // pastikan tipe ini diekspor
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
// Tetap gunakan Valtio untuk card utama (NewsReaderLanding)
|
||||||
const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst);
|
const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst);
|
||||||
const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst);
|
const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst);
|
||||||
|
|
||||||
const featured = snap1;
|
const featured = snap1;
|
||||||
const pengumuman = snap2;
|
const pengumuman = snap2;
|
||||||
const loadingFeatured = featured.loading;
|
const loadingFeatured = featured.loading;
|
||||||
const loadingPengumuman = pengumuman.loading;
|
const loadingPengumuman = pengumuman.loading;
|
||||||
|
|
||||||
|
// State untuk notifikasi
|
||||||
|
const [notificationNews, setNotificationNews] = useState<NewsItem[]>([]);
|
||||||
|
const [hasNewContent, setHasNewContent] = useState(false);
|
||||||
|
const [newItemCount, setNewItemCount] = useState(0);
|
||||||
|
|
||||||
|
const lastBeritaTimestamp = useRef<string | null>(null);
|
||||||
|
const lastPengumumanTimestamp = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Inisialisasi dari localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const savedBeritaTs = localStorage.getItem("lastSeenBeritaTs");
|
||||||
|
const savedPengumumanTs = localStorage.getItem("lastSeenPengumumanTs");
|
||||||
|
if (savedBeritaTs) lastBeritaTimestamp.current = savedBeritaTs;
|
||||||
|
if (savedPengumumanTs) lastPengumumanTimestamp.current = savedPengumumanTs;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load data utama (untuk card)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!featured.data && !loadingFeatured) {
|
if (!featured.data && !loadingFeatured) {
|
||||||
stateDashboardBerita.berita.findFirst.load();
|
stateDashboardBerita.berita.findFirst.load();
|
||||||
@@ -43,49 +62,93 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 🔁 Fetch berita & pengumuman lengkap untuk notifikasi
|
||||||
|
const fetchNotificationData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/news/latest");
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.success && Array.isArray(result.news)) {
|
||||||
|
const news = result.news as NewsItem[];
|
||||||
|
|
||||||
const newsData = useMemo(() => {
|
const latestBerita = news.find((n) => n.type === "berita");
|
||||||
const items = [];
|
const latestPengumuman = news.find((n) => n.type === "pengumuman");
|
||||||
|
|
||||||
if (featured.data) {
|
const latestBeritaTs = latestBerita?.timestamp
|
||||||
items.push({
|
? new Date(latestBerita.timestamp).toISOString()
|
||||||
id: String(featured.data.id || "berita-1"),
|
: null;
|
||||||
type: "berita" as const,
|
const latestPengumumanTs = latestPengumuman?.timestamp
|
||||||
title: String(featured.data.judul || "Berita Terbaru"),
|
? new Date(latestPengumuman.timestamp).toISOString()
|
||||||
content: String(featured.data.content || ""),
|
: null;
|
||||||
timestamp: featured.data.createdAt
|
|
||||||
? (typeof featured.data.createdAt === 'string'
|
// Inisialisasi flag
|
||||||
? featured.data.createdAt
|
let isNewBerita = false;
|
||||||
: new Date(featured.data.createdAt).toISOString())
|
let isNewPengumuman = false;
|
||||||
: new Date().toISOString(),
|
|
||||||
});
|
// Deteksi berita baru
|
||||||
|
if (latestBeritaTs) {
|
||||||
|
if (lastBeritaTimestamp.current === null) {
|
||||||
|
// Pertama kali: simpan tanpa notifikasi
|
||||||
|
lastBeritaTimestamp.current = latestBeritaTs;
|
||||||
|
localStorage.setItem("lastSeenBeritaTs", latestBeritaTs);
|
||||||
|
} else if (latestBeritaTs > lastBeritaTimestamp.current) {
|
||||||
|
isNewBerita = true;
|
||||||
|
lastBeritaTimestamp.current = latestBeritaTs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deteksi pengumuman baru
|
||||||
|
if (latestPengumumanTs) {
|
||||||
|
if (lastPengumumanTimestamp.current === null) {
|
||||||
|
// Pertama kali: simpan tanpa notifikasi
|
||||||
|
lastPengumumanTimestamp.current = latestPengumumanTs;
|
||||||
|
localStorage.setItem("lastSeenPengumumanTs", latestPengumumanTs);
|
||||||
|
} else if (latestPengumumanTs > lastPengumumanTimestamp.current) {
|
||||||
|
isNewPengumuman = true;
|
||||||
|
lastPengumumanTimestamp.current = latestPengumumanTs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔔 Trigger notifikasi hanya jika ada yang benar-benar BARU
|
||||||
|
if (isNewBerita || isNewPengumuman) {
|
||||||
|
const count = (isNewBerita ? 1 : 0) + (isNewPengumuman ? 1 : 0);
|
||||||
|
setNewItemCount(count);
|
||||||
|
setHasNewContent(true); // ✅ INI YANG KAMU LUPA!
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotificationNews(news);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch data notifikasi:", err);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (pengumuman.data) {
|
// Load data notifikasi pertama kali
|
||||||
items.push({
|
useEffect(() => {
|
||||||
id: String(pengumuman.data.id || "pengumuman-1"),
|
fetchNotificationData();
|
||||||
type: "pengumuman" as const,
|
}, []);
|
||||||
title: String(pengumuman.data.judul || "Pengumuman Penting"),
|
|
||||||
content: String(pengumuman.data.content || ""),
|
// Polling setiap 30 detik
|
||||||
timestamp: pengumuman.data.createdAt
|
useEffect(() => {
|
||||||
? (typeof pengumuman.data.createdAt === 'string'
|
const interval = setInterval(fetchNotificationData, 30_000);
|
||||||
? pengumuman.data.createdAt
|
return () => clearInterval(interval);
|
||||||
: new Date(pengumuman.data.createdAt).toISOString())
|
}, []);
|
||||||
: new Date().toISOString(),
|
|
||||||
});
|
const handleSeen = () => {
|
||||||
|
setHasNewContent(false);
|
||||||
|
setNewItemCount(0);
|
||||||
|
const latestBerita = notificationNews.find(n => n.type === "berita");
|
||||||
|
const latestPengumuman = notificationNews.find(n => n.type === "pengumuman");
|
||||||
|
if (latestBerita) {
|
||||||
|
localStorage.setItem("lastSeenBeritaTs", new Date(latestBerita.timestamp!).toISOString());
|
||||||
}
|
}
|
||||||
|
if (latestPengumuman) {
|
||||||
return items;
|
localStorage.setItem("lastSeenPengumumanTs", new Date(latestPengumuman.timestamp!).toISOString());
|
||||||
}, [featured.data, pengumuman.data]);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box id="page-root">
|
<Box id="page-root">
|
||||||
<Stack
|
<Stack bg={colors.grey[1]} gap={0}>
|
||||||
bg={colors.grey[1]}
|
|
||||||
gap={0}
|
|
||||||
>
|
|
||||||
{/* HAPUS RUNNING TEXT, GANTI DENGAN MODERN NOTIFICATION */}
|
|
||||||
<LandingPage />
|
<LandingPage />
|
||||||
<Penghargaan />
|
<Penghargaan />
|
||||||
<Layanan />
|
<Layanan />
|
||||||
@@ -97,13 +160,15 @@ export default function Page() {
|
|||||||
<Prestasi />
|
<Prestasi />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Tombol Scroll ke Atas */}
|
|
||||||
<ScrollToTopButton />
|
<ScrollToTopButton />
|
||||||
|
|
||||||
<NewsReaderLanding />
|
<NewsReaderLanding />
|
||||||
|
|
||||||
<ModernNewsNotification
|
<ModernNewsNotification
|
||||||
news={newsData}
|
news={notificationNews}
|
||||||
autoShowDelay={2000} // Muncul 2 detik setelah load
|
hasNewContent={hasNewContent}
|
||||||
|
newItemCount={newItemCount}
|
||||||
|
onSeen={handleSeen}
|
||||||
|
autoShowDelay={2000}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user