Fix menu admin landing page, submenu sosial media

This commit is contained in:
2025-12-02 16:06:14 +08:00
parent dcf195f54f
commit ffe5e6dd9f
13 changed files with 730 additions and 373 deletions

View File

@@ -136,6 +136,7 @@ model MediaSosial {
name String name String
image FileStorage? @relation(fields: [imageId], references: [id]) image FileStorage? @relation(fields: [imageId], references: [id])
imageId String? imageId String?
icon String?
iconUrl String? @db.VarChar(255) iconUrl String? @db.VarChar(255)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -0,0 +1,76 @@
'use client';
import { Box, Image, Select, rem } from '@mantine/core';
const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
type SosmedKey = keyof typeof sosmedMap;
const sosmedList = Object.entries(sosmedMap).map(([value, item]) => ({
value,
label: item.label,
}));
export default function SelectSosialMedia({
value,
onChange,
}: {
value: SosmedKey;
onChange: (value: SosmedKey) => void;
}) {
const selected = value;
const selectedImage = sosmedMap[selected]?.src;
return (
<Box maw={300}>
<Select
placeholder="Pilih sosial media"
value={selected}
data={sosmedList}
searchable={false}
withCheckIcon={false}
onChange={(val) => val && onChange(val as SosmedKey)}
styles={{
input: {
textAlign: 'left',
fontSize: rem(16),
paddingLeft: 36,
},
section: {
left: 10,
right: 'auto',
},
}}
/>
{/* 🔥 PREVIEW DIPISAH DI LUAR SELECT */}
{selectedImage && (
<Box mt="md">
<Image
alt=""
src={selectedImage}
radius="md"
style={{
width: 120,
height: 120,
objectFit: 'contain',
border: '1px solid #eee',
padding: 8,
}}
/>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { Box, Select } from '@mantine/core';
import { useEffect, useState } from 'react';
export const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
type SosmedKey = keyof typeof sosmedMap;
const sosmedList = Object.entries(sosmedMap).map(([value, item]) => ({
value,
label: item.label,
}));
export default function SelectSocialMediaEdit({
value,
onChange,
}: {
value: string;
onChange: (val: SosmedKey) => void;
}) {
const [selected, setSelected] = useState<SosmedKey>('facebook');
useEffect(() => {
if (value && sosmedMap[value as SosmedKey]) {
setSelected(value as SosmedKey);
}
}, [value]);
return (
<Box>
<Select
label="Jenis Media Sosial"
value={selected}
data={sosmedList}
searchable={false}
onChange={(val) => {
if (!val) return;
setSelected(val as SosmedKey);
onChange(val as SosmedKey);
}}
/>
</Box>
);
}

View File

@@ -27,7 +27,7 @@ const programInovasi = proxy({
name: "", name: "",
description: "", description: "",
imageId: "", imageId: "",
link: "" link: "",
} as ProgramInovasiForm, } as ProgramInovasiForm,
loading: false, loading: false,
async create() { async create() {
@@ -71,20 +71,21 @@ const programInovasi = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function load: async (page = 1, limit = 10, search = "") => {
programInovasi.findMany.loading = true; // Use the full path to access the property // Change to arrow function
programInovasi.findMany.loading = true; // Use the full path to access the property
programInovasi.findMany.page = page; programInovasi.findMany.page = page;
programInovasi.findMany.search = search; programInovasi.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.programinovasi[ const res = await ApiFetch.api.landingpage.programinovasi[
"findMany" "findMany"
].get({ ].get({
query query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
programInovasi.findMany.data = res.data.data || []; programInovasi.findMany.data = res.data.data || [];
programInovasi.findMany.total = res.data.total || 0; programInovasi.findMany.total = res.data.total || 0;
@@ -389,7 +390,10 @@ const pejabatDesa = proxy({
try { try {
// Ensure ID is properly encoded in the URL // Ensure ID is properly encoded in the URL
const url = new URL(`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`, window.location.origin); const url = new URL(
`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`,
window.location.origin
);
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -438,16 +442,19 @@ const pejabatDesa = proxy({
const templateMediaSosial = z.object({ const templateMediaSosial = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"), imageId: z.string().nullable().optional(),
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"), iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"),
icon: z.string().nullable().optional(),
}); });
type MediaSosialForm = { type MediaSosialForm = {
name: string; name: string;
imageId: string; imageId: string | null; // boleh null
iconUrl: string; iconUrl: string;
icon: string | null; // boleh null
}; };
const mediaSosial = proxy({ const mediaSosial = proxy({
create: { create: {
form: {} as MediaSosialForm, form: {} as MediaSosialForm,
@@ -455,9 +462,10 @@ const mediaSosial = proxy({
async create() { async create() {
// Ensure all required fields are non-null // Ensure all required fields are non-null
const formData = { const formData = {
name: mediaSosial.create.form.name || "", name: mediaSosial.create.form.name ?? "",
imageId: mediaSosial.create.form.imageId || "", imageId: mediaSosial.create.form.imageId ?? null, // FIXED
iconUrl: mediaSosial.create.form.iconUrl || "", iconUrl: mediaSosial.create.form.iconUrl ?? "",
icon: mediaSosial.create.form.icon ?? null, // FIXED
}; };
const cek = templateMediaSosial.safeParse(formData); const cek = templateMediaSosial.safeParse(formData);
@@ -492,20 +500,19 @@ const mediaSosial = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function load: async (page = 1, limit = 10, search = "") => {
mediaSosial.findMany.loading = true; // Use the full path to access the property // Change to arrow function
mediaSosial.findMany.loading = true; // Use the full path to access the property
mediaSosial.findMany.page = page; mediaSosial.findMany.page = page;
mediaSosial.findMany.search = search; mediaSosial.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.mediasosial[ const res = await ApiFetch.api.landingpage.mediasosial["findMany"].get({
"findMany"
].get({
query, query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
mediaSosial.findMany.data = res.data.data || []; mediaSosial.findMany.data = res.data.data || [];
mediaSosial.findMany.total = res.data.total || 0; mediaSosial.findMany.total = res.data.total || 0;
@@ -537,7 +544,7 @@ const mediaSosial = proxy({
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
} }
mediaSosial.update.loading = true; mediaSosial.update.loading = true;
try { try {
const res = await fetch(`/api/landingpage/mediasosial/${id}`); const res = await fetch(`/api/landingpage/mediasosial/${id}`);
@@ -586,66 +593,72 @@ const mediaSosial = proxy({
}, },
}, },
update: { update: {
id: "", id: "",
form: {} as MediaSosialForm, form: {} as MediaSosialForm,
loading: false, loading: false,
async load(id: string) { async load(id: string) {
if (!id) { if (!id) {
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
}
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
try {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
} }
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal const result = await response.json();
try { if (result?.success) {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, { const data = result.data;
method: "GET", this.id = data.id;
headers: { this.form = {
"Content-Type": "application/json", name: data.name || "",
}, imageId: data.imageId || null,
}); iconUrl: data.iconUrl || "",
icon: data.icon || null,
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); };
} return data;
} else {
const result = await response.json(); throw new Error(
result?.message || "Gagal mengambil data media sosial"
if (result?.success) { );
const data = result.data;
this.id = data.id;
this.form = {
name: data.name || "",
imageId: data.imageId || "",
iconUrl: data.iconUrl || "",
};
return data;
} else {
throw new Error(result?.message || "Gagal mengambil data media sosial");
}
} catch (error) {
console.error((error as Error).message);
toast.error("Terjadi kesalahan saat mengambil data media sosial");
} finally {
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
} }
}, } catch (error) {
console.error((error as Error).message);
async update() { toast.error("Terjadi kesalahan saat mengambil data media sosial");
const cek = templateMediaSosial.safeParse(mediaSosial.update.form); } finally {
if (!cek.success) { mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
const err = `[${cek.error.issues }
.map((v) => `${v.path.join(".")}`) },
.join("\n")}] required`;
toast.error(err); async update() {
return false; const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
} if (!cek.success) {
const err = `[${cek.error.issues
try { .map((v) => `${v.path.join(".")}`)
mediaSosial.update.loading = true; .join("\n")}] required`;
toast.error(err);
const response = await fetch(`/api/landingpage/mediasosial/${this.id}`, { return false;
}
try {
mediaSosial.update.loading = true;
const response = await fetch(
`/api/landingpage/mediasosial/${this.id}`,
{
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -654,38 +667,40 @@ const mediaSosial = proxy({
name: this.form.name, name: this.form.name,
imageId: this.form.imageId, imageId: this.form.imageId,
iconUrl: this.form.iconUrl, iconUrl: this.form.iconUrl,
icon: this.form.icon,
}), }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
} }
);
const result = await response.json();
if (!response.ok) {
if (result.success) { const errorData = await response.json().catch(() => ({}));
toast.success("Berhasil update media sosial"); throw new Error(
await mediaSosial.findMany.load(); // refresh list errorData.message || `HTTP error! status: ${response.status}`
return true;
} else {
throw new Error(result.message || "Gagal update media sosial");
}
} catch (error) {
console.error("Error updating media sosial:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update media sosial"
); );
return false;
} finally {
mediaSosial.update.loading = false;
} }
},
const result = await response.json();
if (result.success) {
toast.success("Berhasil update media sosial");
await mediaSosial.findMany.load(); // refresh list
return true;
} else {
throw new Error(result.message || "Gagal update media sosial");
}
} catch (error) {
console.error("Error updating media sosial:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update media sosial"
);
return false;
} finally {
mediaSosial.update.loading = false;
}
}, },
},
}); });
const profileLandingPageState = proxy({ const profileLandingPageState = proxy({

View File

@@ -0,0 +1,12 @@
export const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};

View File

@@ -1,5 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import SelectSocialMediaEdit from '@/app/admin/(dashboard)/_com/selectSocialMediaEdit';
import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile'; import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
@@ -14,7 +16,7 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader Loader,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -23,15 +25,45 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
type SosmedKey =
| 'none'
| 'facebook'
| 'instagram'
| 'tiktok'
| 'youtube'
| 'whatsapp'
| 'gmail'
| 'telegram'
| 'x'
| 'telephone'
| 'custom';
const sosmedMap: Record<SosmedKey, { label: string; src: string | null }> = {
none: { label: "None", src: '/no-image.jpg' },
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
function EditMediaSosial() { function EditMediaSosial() {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
icon: '',
iconUrl: '', iconUrl: '',
imageId: '', imageId: '',
}); });
@@ -39,13 +71,14 @@ function EditMediaSosial() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ const [originalData, setOriginalData] = useState({
name: "", name: '',
iconUrl: "", icon: '',
imageId: "", iconUrl: '',
imageUrl: "", imageId: '',
imageUrl: '',
}); });
// Load data by ID // Load Data by ID
useEffect(() => { useEffect(() => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
@@ -54,81 +87,97 @@ function EditMediaSosial() {
try { try {
const data = await stateMediaSosial.update.load(id); const data = await stateMediaSosial.update.load(id);
if (data) { if (!data) return;
// isi form awal
const newForm = {
name: data.name || "",
iconUrl: data.iconUrl || "",
imageId: data.imageId || "",
};
setFormData(newForm);
// simpan juga versi original // Tentukan default/custom icon
setOriginalData({ // Tentukan default/custom icon
...newForm, if (data.imageId) {
imageUrl: data.image?.link || "", setSelectedSosmed('custom');
}); } else {
// ✅ Gunakan langsung data.icon jika ada dan valid
setPreviewImage(data.image?.link || null); if (data.icon && sosmedMap[data.icon as SosmedKey]) {
setSelectedSosmed(data.icon as SosmedKey);
} else {
setSelectedSosmed('none'); // fallback
}
} }
} catch (error) {
console.error('Error loading media sosial:', error); const newForm = {
toast.error( name: data.name || '',
error instanceof Error ? error.message : 'Gagal mengambil data media sosial' icon: data.icon || '',
); iconUrl: data.iconUrl || '',
imageId: data.imageId || '',
};
setFormData(newForm);
setOriginalData({
...newForm,
imageUrl: data.image?.link || '',
});
setPreviewImage(data.image?.link || null);
} catch {
toast.error('Gagal mengambil data media sosial');
} }
}; };
loadData(); loadData();
}, [params?.id]); }, [params?.id]);
const handleChange = (field: string, value: string) => { const handleChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// update global state hanya saat submit
stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData }; stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData };
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar'); if (!uploaded?.id) {
toast.error('Gagal upload gambar');
return;
}
stateMediaSosial.update.form.imageId = uploaded.id; stateMediaSosial.update.form.imageId = uploaded.id;
} }
// 🚨 Tambahkan ini untuk debugging
console.log("Data yang akan dikirim ke backend:", stateMediaSosial.update.form);
await stateMediaSosial.update.update(); await stateMediaSosial.update.update();
toast.success('Media sosial berhasil diperbarui!'); toast.success('Media sosial berhasil diperbarui!');
router.push('/admin/landing-page/profil/media-sosial'); router.push('/admin/landing-page/profil/media-sosial');
} catch (error) { } catch (error) {
console.error('Error updating media sosial:', error); console.error("Error di handleSubmit:", error); // 🚨 Tambahkan ini juga
toast.error('Terjadi kesalahan saat memperbarui media sosial'); toast.error('Terjadi kesalahan saat memperbarui media sosial');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
// ✅ Tombol Batal → balikin ke data original
const handleResetForm = () => { const handleResetForm = () => {
setFormData({ setFormData({
name: originalData.name, name: originalData.name,
icon: originalData.icon,
iconUrl: originalData.iconUrl, iconUrl: originalData.iconUrl,
imageId: originalData.imageId, imageId: originalData.imageId,
}); });
setPreviewImage(originalData.imageUrl || null); setPreviewImage(originalData.imageUrl || null);
setFile(null); setFile(null);
toast.info("Form dikembalikan ke data awal"); toast.info('Form dikembalikan ke data awal');
}; };
return ( return (
<Box <Box px={{ base: 'sm', md: 'lg' }} py="md">
px={{ base: 'sm', md: 'lg' }}
py="md"
>
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
@@ -147,80 +196,119 @@ function EditMediaSosial() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Upload Gambar */} {/* Upload / Icon */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi Icon / Gambar Media Sosial
</Text> </Text>
<Dropzone
onDrop={(files) => { {/* Custom Upload */}
const selectedFile = files[0]; {/* PILIH ICON */}
if (selectedFile) { <SelectSocialMediaEdit
setFile(selectedFile); value={selectedSosmed}
setPreviewImage(URL.createObjectURL(selectedFile)); onChange={(key) => {
setSelectedSosmed(key);
if (key === 'custom') {
// custom → gunakan Dropzone
setFormData((prev) => ({
...prev,
icon: '',
imageId: '',
}));
return;
} }
// default → pakai icon bawaan
setFormData((prev) => ({
...prev,
icon: key, // <-- simpan 'facebook', bukan path
imageId: '',
}));
}} }}
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={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>
{/* ✅ Preview gambar + tombol X */} {selectedSosmed === 'custom' ? (
{previewImage && ( <>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Dropzone
<Image onDrop={(files) => {
src={previewImage} const selectedFile = files[0];
alt="Preview Gambar" if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
handleChange('imageId', '');
}
}}
onReject={() => toast.error('File tidak valid')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md" radius="md"
style={{ p="xl"
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} /> <Group justify="center" gap="xl" mih={180}>
</ActionIcon> <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 align="center" gap="xs">
<Text fw={500}>Seret gambar atau klik untuk pilih</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format: .png, .jpg, .jpeg, .webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setFile(null);
setPreviewImage(null);
handleChange('imageId', '');
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</>
) : (
// Default icon
<Box mt="xs">
<Image
src={sosmedMap[selectedSosmed].src || ''}
alt="Icon bawaan"
width={40}
height={40}
radius="md"
style={{ border: '1px solid #ddd', padding: 4, background: '#fff' }}
/>
</Box> </Box>
)} )}
</Box> </Box>
@@ -237,25 +325,17 @@ function EditMediaSosial() {
{/* Link Media Sosial */} {/* Link Media Sosial */}
<TextInput <TextInput
label="Link Media Sosial / Nomor Telepon" label="Link Media Sosial / Nomor Telepon"
placeholder="Masukkan link media sosial atau nomor telepon" placeholder="Masukkan link atau nomor telepon"
value={formData.iconUrl} value={formData.iconUrl}
onChange={(e) => handleChange('iconUrl', e.target.value)} onChange={(e) => handleChange('iconUrl', e.target.value)}
required required
/> />
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */} <Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile'; import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile';
@@ -8,6 +9,7 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { sosmedMap } from '../../_lib/sosmed';
function DetailMediaSosial() { function DetailMediaSosial() {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
@@ -16,6 +18,14 @@ function DetailMediaSosial() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const getIconSource = (item: any) => {
if (item.image?.link) return item.image.link;
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) {
return sosmedMap[item.icon as keyof typeof sosmedMap].src;
}
return null;
};
useShallowEffect(() => { useShallowEffect(() => {
stateMediaSosial.findUnique.load(params?.id as string); stateMediaSosial.findUnique.load(params?.id as string);
}, []); }, []);
@@ -77,46 +87,47 @@ function DetailMediaSosial() {
<Box> <Box>
<Text fz="lg" fw="bold">Gambar</Text> <Text fz="lg" fw="bold">Gambar</Text>
{data.image?.link ? ( {(() => {
<Image const src = getIconSource(data);
src={data.image.link}
alt={data.name || 'Gambar Media Sosial'}
w="100%"
maw={120} // max width biar tidak keluar layar
h="auto"
radius="md"
fit="cover"
loading="lazy"
/>
) : ( if (src) {
<Text fz="sm" c="dimmed">Tidak ada gambar</Text> return (
)} <Image
loading="lazy"
src={src}
alt={data.name}
fit={data.image?.link ? "cover" : "contain"}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
setSelectedId(data.id); setSelectedId(data.id);
setModalHapus(true); setModalHapus(true);
}} }}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${data.id}/edit`)} onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${data.id}/edit`)}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,5 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
@@ -22,10 +23,41 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import profileLandingPageState from '../../../../_state/landing-page/profile'; import profileLandingPageState from '../../../../_state/landing-page/profile';
import SelectSosialMedia from '@/app/admin/(dashboard)/_com/selectSocialMedia';
// ⭐ Tambah type SosmedKey
type SosmedKey =
| 'facebook'
| 'instagram'
| 'tiktok'
| 'youtube'
| 'whatsapp'
| 'gmail'
| 'telegram'
| 'x'
| 'telephone'
| 'custom';
// ⭐ mapping icon sosmed bawaan
const sosmedMap: Record<SosmedKey, { label: string; src: string | null }> = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
export default function CreateMediaSosial() { export default function CreateMediaSosial() {
const router = useRouter(); const router = useRouter();
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -39,16 +71,34 @@ export default function CreateMediaSosial() {
name: '', name: '',
imageId: '', imageId: '',
iconUrl: '', iconUrl: '',
icon: ''
}; };
setPreviewImage(null);
setFile(null); setFile(null);
setPreviewImage(null);
setSelectedSosmed('facebook');
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// ──────────────── ⭐ CASE 1: PAKAI ICON DEFAULT ────────────────
if (selectedSosmed !== 'custom') {
stateMediaSosial.create.form.imageId = null;
stateMediaSosial.create.form.icon = sosmedMap[selectedSosmed].src!;
await stateMediaSosial.create.create();
resetForm();
router.push('/admin/landing-page/profil/media-sosial');
return;
}
// ──────────────── ⭐ CASE 2: CUSTOM ICON → WAJIB UPLOAD ────────────────
if (!file) { if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu'); toast.warn('Silakan upload icon custom terlebih dahulu');
return;
} }
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
@@ -59,10 +109,12 @@ export default function CreateMediaSosial() {
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi'); toast.error('Gagal mengunggah icon custom');
return;
} }
stateMediaSosial.create.form.imageId = uploaded.id; stateMediaSosial.create.form.imageId = uploaded.id;
stateMediaSosial.create.form.icon = null;
await stateMediaSosial.create.create(); await stateMediaSosial.create.create();
@@ -78,6 +130,7 @@ export default function CreateMediaSosial() {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
@@ -96,112 +149,110 @@ export default function CreateMediaSosial() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
<Box> {/* Select Sosmed */}
<Text fw="bold" fz="sm" mb={6}> <SelectSosialMedia value={selectedSosmed} onChange={setSelectedSosmed} />
Gambar Program Inovasi
</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={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>
{/* ✅ Preview gambar + tombol X */} {/* Custom icon uploader */}
{previewImage && ( {selectedSosmed === 'custom' && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Box>
<Image <Text fw="bold" fz="sm" mb={6}>
src={previewImage} Upload Custom Icon
alt="Preview Gambar" </Text>
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */} <Dropzone
<ActionIcon onDrop={(files) => {
variant="filled" const selectedFile = files[0];
color="red" if (selectedFile) {
radius="xl" setFile(selectedFile);
size="sm" setPreviewImage(URL.createObjectURL(selectedFile));
pos="absolute" }
top={5} }}
right={5} onReject={() => toast.error('File tidak valid')}
onClick={() => { maxSize={5 * 1024 ** 2}
setPreviewImage(null); accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
setFile(null); radius="md"
}} p="xl"
style={{ >
boxShadow: '0 2px 6px rgba(0,0,0,0.15)', <Group justify="center" gap="xl" mih={180}>
}} <Dropzone.Accept>
> <IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
<IconX size={14} /> </Dropzone.Accept>
</ActionIcon> <Dropzone.Reject>
</Box> <IconX size={48} color="red" stroke={1.5} />
)} </Dropzone.Reject>
</Box> <Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack align="center" gap="xs">
<Text fw={500}>Seret gambar atau klik untuk pilih</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setFile(null);
setPreviewImage(null);
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
)}
{/* Input name */}
<TextInput <TextInput
label="Nama Media Sosial / Kontak" label="Nama Media Sosial"
placeholder="Masukkan nama media sosial atau kontak" placeholder="Masukkan nama media sosial"
value={stateMediaSosial.create.form.name || ''} value={stateMediaSosial.create.form.name ?? ''}
onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)} onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)}
required required
/> />
{/* Input link */}
<TextInput <TextInput
label="Link Media Sosial / Nomor Telepon" label="Link / Kontak"
placeholder="Masukkan link media sosial atau nomor telepon" placeholder="Masukkan link atau nomor"
value={stateMediaSosial.create.form.iconUrl || ''} value={stateMediaSosial.create.form.iconUrl ?? ''}
onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)} onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)}
required required
/> />
{/* Actions */}
<Group justify="right"> <Group justify="right">
<Button <Button variant="outline" color="gray" radius="md" onClick={resetForm}>
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset Reset
</Button> </Button>
<Button <Button
onClick={handleSubmit}
radius="md" radius="md"
size="md" onClick={handleSubmit}
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
@@ -8,6 +9,7 @@ import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import profileLandingPageState from '../../../_state/landing-page/profile'; import profileLandingPageState from '../../../_state/landing-page/profile';
import { sosmedMap } from '../_lib/sosmed';
function MediaSosial() { function MediaSosial() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -29,6 +31,14 @@ function ListMediaSosial({ search }: { search: string }) {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial) const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial)
const router = useRouter(); const router = useRouter();
const getIconSource = (item: any) => {
if (item.image?.link) return item.image.link;
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) {
return sosmedMap[item.icon as keyof typeof sosmedMap].src;
}
return null;
};
const { const {
data, data,
page, page,
@@ -56,9 +66,9 @@ function ListMediaSosial({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Media Sosial</Title> <Title order={4}>Daftar Media Sosial</Title>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}> <Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}>
Tambah Baru Tambah Baru
</Button> </Button>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table highlightOnHover> <Table highlightOnHover>
@@ -77,13 +87,26 @@ function ListMediaSosial({ search }: { search: string }) {
<TableTd style={{ width: '25%', }}> <TableTd style={{ width: '25%', }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text> <Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%' }}>
<Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden', }}> <Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden' }}>
{item.image?.link ? (
<Image loading='lazy' src={item.image.link} alt={item.name} fit="cover" /> {(() => {
) : ( const src = getIconSource(item);
<Box bg={colors['blue-button']} w="100%" h="100%" />
)} if (src) {
return (
<Image
loading="lazy"
src={src}
alt={item.name}
fit={item.image?.link ? "cover" : "contain"}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box> </Box>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%', }}>

View File

@@ -5,6 +5,7 @@ type FormCreate = {
name: string; name: string;
imageId: string; imageId: string;
iconUrl: string; iconUrl: string;
icon: string;
}; };
export default async function mediaSosialCreate(context: Context) { export default async function mediaSosialCreate(context: Context) {
@@ -14,8 +15,9 @@ export default async function mediaSosialCreate(context: Context) {
const result = await prisma.mediaSosial.create({ const result = await prisma.mediaSosial.create({
data: { data: {
name: body.name, name: body.name,
imageId: body.imageId, imageId: body.imageId || null,
iconUrl: body.iconUrl, iconUrl: body.iconUrl,
icon: body.icon || null,
}, },
include: { include: {
image: true, image: true,
@@ -29,8 +31,6 @@ export default async function mediaSosialCreate(context: Context) {
}; };
} catch (error) { } catch (error) {
console.error("Error creating media sosial:", error); console.error("Error creating media sosial:", error);
throw new Error( throw new Error("Gagal membuat media sosial: " + (error as Error).message);
"Gagal membuat media sosial: " + (error as Error).message
);
} }
} }

View File

@@ -20,8 +20,9 @@ const MediaSosial = new Elysia({
.post("/create", MediaSosialCreate, { .post("/create", MediaSosialCreate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
imageId: t.String(), imageId: t.Union([t.String(), t.Null()]),
iconUrl: t.String(), iconUrl: t.Union([t.String(), t.Null()]),
icon: t.Union([t.String(), t.Null()]),
}), }),
}) })
@@ -29,8 +30,9 @@ const MediaSosial = new Elysia({
.put("/:id", MediaSosialUpdate, { .put("/:id", MediaSosialUpdate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
imageId: t.Optional(t.String()), imageId: t.Optional(t.Union([t.String(), t.Null()])),
iconUrl: t.Optional(t.String()), iconUrl: t.Optional(t.Union([t.String(), t.Null()])),
icon: t.Optional(t.Union([t.String(), t.Null()])),
}), }),
}) })
// ✅ Delete // ✅ Delete

View File

@@ -6,6 +6,7 @@ type FormUpdateMediaSosial = {
name?: string; name?: string;
imageId?: string; imageId?: string;
iconUrl?: string; iconUrl?: string;
icon?: string;
}; };
export default async function mediaSosialUpdate(context: Context) { export default async function mediaSosialUpdate(context: Context) {
@@ -20,13 +21,29 @@ export default async function mediaSosialUpdate(context: Context) {
}; };
} }
// 🚨 Tambahkan validasi di sini
if (!body.name || body.name.trim().length < 3) {
return {
success: false,
message: "Nama media sosial minimal 3 karakter",
};
}
if (!body.iconUrl || body.iconUrl.trim().length < 3) {
return {
success: false,
message: "Icon URL minimal 3 karakter",
};
}
try { try {
const updated = await prisma.mediaSosial.update({ const updated = await prisma.mediaSosial.update({
where: { id }, where: { id },
data: { data: {
name: body.name, name: body.name,
imageId: body.imageId, imageId: body.imageId || null, // pastikan null jika kosong
iconUrl: body.iconUrl, iconUrl: body.iconUrl,
icon: body.icon || null, // pastikan null jika kosong
}, },
include: { include: {
image: true, image: true,

View File

@@ -5,7 +5,7 @@ import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress' import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
import { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types' import { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types'
import colors from '@/con/colors' import colors from '@/con/colors'
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, Select, SimpleGrid, Stack, Text } from '@mantine/core' import { ActionIcon, BackgroundImage, Box, Button, Center, Group, Loader, Select, SimpleGrid, Stack, Text } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react' import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -133,7 +133,7 @@ function Apbdes() {
style={{ overflow: 'hidden' }} style={{ overflow: 'hidden' }}
> >
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} /> <Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
<Stack justify="space-between" h="100%" p="xl" pos="relative"> <Stack gap={"xs"} justify="space-between" h="100%" p="xl" pos="relative">
<Text <Text
c="white" c="white"
fw={600} fw={600}
@@ -152,7 +152,20 @@ function Apbdes() {
> >
{v.jumlah} {v.jumlah}
</Text> </Text>
<Group justify="center"> <Center>
<ActionIcon
component={Link}
href={v.file?.link || ''}
radius="xl"
size="xl"
variant="gradient"
gradient={{ from: '#1C6EA4', to: '#1C6EA4' }}
>
<IconDownload size={20} color="white" />
</ActionIcon>
</Center>
{/* <Group justify="center">
<ActionIcon <ActionIcon
component={Link} component={Link}
href={v.file?.link || ''} href={v.file?.link || ''}
@@ -161,11 +174,11 @@ function Apbdes() {
variant="gradient" variant="gradient"
gradient={{ from: '#1C6EA4', to: '#1C6EA4' }} gradient={{ from: '#1C6EA4', to: '#1C6EA4' }}
> >
<Flex align="center" gap="xs" px="md" py={6}> <Group align="center" gap="xs" px="md" py={6}>
<IconDownload size={18} color="white" /> <IconDownload size={25} color="white" />
</Flex> </Group>
</ActionIcon> </ActionIcon>
</Group> </Group> */}
</Stack> </Stack>
</BackgroundImage> </BackgroundImage>
)) ))