feat: update tenant detail, update landing page

This commit is contained in:
hanif salafi 2025-10-12 21:33:30 +07:00
parent e8f49bdad4
commit 95a24644e8
21 changed files with 704 additions and 140 deletions

View File

@ -26,6 +26,7 @@ import { ApprovalWorkflowForm } from "@/components/form/ApprovalWorkflowForm";
import { UserLevelsForm } from "@/components/form/UserLevelsForm";
import { useWorkflowModal } from "@/components/modals/WorkflowModalProvider";
import { useWorkflowStatusCheck } from "@/hooks/useWorkflowStatusCheck";
import { useLocalStorage } from "@/hooks/use-local-storage";
import {
CreateApprovalWorkflowWithClientSettingsRequest,
UserLevelsCreateRequest,
@ -60,7 +61,7 @@ import { close, loading } from "@/config/swal";
import DetailTenant from "@/components/form/tenant/tenant-detail-update-form";
function TenantSettingsContentTable() {
const [activeTab, setActiveTab] = useState("workflows");
const [activeTab, setActiveTab] = useLocalStorage('tenant-settings-active-tab', 'profile');
const [isUserLevelDialogOpen, setIsUserLevelDialogOpen] = useState(false);
const [workflow, setWorkflow] =
useState<ComprehensiveWorkflowResponse | null>(null);
@ -220,7 +221,7 @@ function TenantSettingsContentTable() {
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger
value="tenant"
value="profile"
className="flex items-center gap-2 border rounded-lg"
>
<WorkflowIcon className="h-4 w-4" />
@ -243,7 +244,7 @@ function TenantSettingsContentTable() {
</TabsList>
{/* Approval Workflows Tab */}
<TabsContent value="tenant" className="space-y-6 border rounded-lg">
<TabsContent value="profile" className="space-y-6 border rounded-lg">
<DetailTenant id={10} />
</TabsContent>
<TabsContent value="workflows" className="space-y-6 border rounded-lg">

View File

@ -687,6 +687,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
<Button
type="submit"
variant="outline"
disabled={isSubmitting || isLoading || isLoadingData}
className="flex items-center gap-2"
>

View File

@ -21,6 +21,8 @@ import withReactContent from "sweetalert2-react-content";
import { errorAutoClose, loading, successAutoClose } from "@/lib/swal";
import { close } from "@/config/swal";
import Image from "next/image";
import { getClientProfile, updateClientProfile, uploadClientLogo, ClientProfile } from "@/service/client/client-profile";
import { SaveIcon } from "@/components/icons";
// ✅ Zod Schema Validasi
const companySchema = z.object({
@ -58,6 +60,8 @@ export default function TenantCompanyUpdateForm({
const MySwal = withReactContent(Swal);
const [previewLogo, setPreviewLogo] = useState<string | null>(null);
const [loadingData, setLoadingData] = useState(false);
const [clientProfile, setClientProfile] = useState<ClientProfile | null>(null);
const [selectedLogoFile, setSelectedLogoFile] = useState<File | null>(null);
const {
control,
@ -82,26 +86,30 @@ export default function TenantCompanyUpdateForm({
async function fetchCompanyData() {
setLoadingData(true);
try {
// TODO: ganti dengan service API kamu (misalnya getTenantCompanyDetail)
const response = initialData; // simulasi
if (response) {
setValue("companyName", response.companyName || "");
setValue("address", response.address || "");
setValue("phone", response.phone || "");
setValue("website", response.website || "");
setValue("description", response.description || "");
setValue("isActive", response.isActive ?? true);
setPreviewLogo(response.logoUrl || null);
const response = await getClientProfile();
if (response?.data?.success && response.data.data) {
const data = response.data.data;
setClientProfile(data);
// Set form values
setValue("companyName", data.name || "");
setValue("address", data.address || "");
setValue("phone", data.phoneNumber || "");
setValue("website", data.website || "");
setValue("description", data.description || "");
setValue("isActive", data.isActive ?? true);
setPreviewLogo(data.logoUrl || null);
}
} catch (err) {
} catch (err: any) {
console.error("❌ Gagal memuat data perusahaan:", err);
errorAutoClose(err?.message || "Gagal memuat data perusahaan");
} finally {
setLoadingData(false);
}
}
fetchCompanyData();
}, [initialData, setValue]);
}, [setValue]);
// ✅ Fungsi Upload Logo
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -117,40 +125,78 @@ export default function TenantCompanyUpdateForm({
return;
}
// Validasi ukuran file (max 5MB)
if (file.size > 5 * 1024 * 1024) {
MySwal.fire({
icon: "error",
title: "Ukuran file terlalu besar",
text: "Ukuran file maksimal 5MB",
});
return;
}
setValue("logo", file);
setSelectedLogoFile(file);
setPreviewLogo(URL.createObjectURL(file));
};
// ✅ Fungsi Remove Logo
const handleRemoveLogo = () => {
setValue("logo", undefined);
setSelectedLogoFile(null);
setPreviewLogo(clientProfile?.logoUrl || null);
};
// ✅ Submit Form
const onSubmit = async (data: CompanySchema) => {
try {
loading();
const formData = new FormData();
formData.append("companyName", data.companyName);
formData.append("address", data.address);
formData.append("phone", data.phone);
formData.append("website", data.website || "");
formData.append("description", data.description || "");
formData.append("isActive", data.isActive ? "true" : "false");
if (data.logo) formData.append("logo", data.logo);
// Update profile data terlebih dahulu
const updateData = {
name: data.companyName,
address: data.address,
phoneNumber: data.phone,
website: data.website || undefined,
description: data.description || undefined,
};
console.log("📦 Payload:", Object.fromEntries(formData.entries()));
console.log("📦 Payload:", updateData);
// TODO: Ganti dengan service API kamu → misalnya updateTenantCompany(formData)
// const response = await updateTenantCompany(id, formData);
await new Promise((resolve) => setTimeout(resolve, 1000)); // simulate
close();
const response = await updateClientProfile(updateData);
if (response?.data?.success) {
// Jika ada logo yang dipilih, upload logo
if (selectedLogoFile) {
try {
console.log("📤 Uploading logo...");
const logoResponse = await uploadClientLogo(selectedLogoFile);
if (logoResponse?.data?.success) {
console.log("✅ Logo uploaded successfully");
} else {
console.warn("⚠️ Logo upload failed, but profile updated");
}
} catch (logoError: any) {
console.error("❌ Error uploading logo:", logoError);
// Tidak menghentikan proses jika logo gagal diupload
}
}
successAutoClose("Data perusahaan berhasil diperbarui.");
setTimeout(() => {
if (onSuccess) onSuccess();
}, 2000);
} catch (err) {
close();
successAutoClose("Data perusahaan berhasil diperbarui.");
setTimeout(() => {
if (onSuccess) onSuccess();
}, 2000);
} else {
close();
errorAutoClose("Gagal memperbarui data perusahaan.");
}
} catch (err: any) {
close();
console.error("❌ Gagal update perusahaan:", err);
errorAutoClose("Terjadi kesalahan saat memperbarui data perusahaan.");
errorAutoClose(err?.message || "Terjadi kesalahan saat memperbarui data perusahaan.");
}
};
@ -188,16 +234,32 @@ export default function TenantCompanyUpdateForm({
className="max-w-xs"
/>
{previewLogo && (
<div className="relative w-20 h-20 rounded-lg overflow-hidden border">
<Image
src={previewLogo}
alt="Company Logo"
fill
className="object-cover"
/>
<div className="flex items-center gap-2">
<div className="relative w-20 h-20 rounded-lg overflow-hidden border">
<Image
src={previewLogo}
alt="Company Logo"
fill
className="object-cover"
/>
</div>
{selectedLogoFile && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveLogo}
className="text-red-600 hover:text-red-700"
>
Remove
</Button>
)}
</div>
)}
</div>
<p className="text-sm text-gray-500">
Format yang didukung: JPG, PNG, WebP. Maksimal 5MB.
</p>
</div>
{/* Nama Perusahaan */}
@ -278,7 +340,7 @@ export default function TenantCompanyUpdateForm({
</div>
{/* Status Aktif */}
<div className="flex items-center space-x-2">
{/* <div className="flex items-center space-x-2">
<Controller
control={control}
name="isActive"
@ -290,14 +352,21 @@ export default function TenantCompanyUpdateForm({
)}
/>
<Label>Aktif</Label>
</div>
</div> */}
</CardContent>
<CardFooter className="flex justify-end gap-3">
<Button type="submit">Update</Button>
<Button type="button" variant="outline" onClick={() => onCancel?.()}>
Cancel
</Button>
<Button
type="submit"
variant="outline"
className="flex items-center gap-2"
>
<SaveIcon className="h-4 w-4" />
Update
</Button>
</CardFooter>
</Card>
</form>

View File

@ -1,7 +1,38 @@
"use client";
import { useState, useEffect } from "react";
import { getArticleCategories, ArticleCategory } from "@/service/categories/article-categories";
export default function Category() {
const categories = [
const [categories, setCategories] = useState<ArticleCategory[]>([]);
const [loading, setLoading] = useState(true);
// Fetch article categories
useEffect(() => {
async function fetchCategories() {
try {
const response = await getArticleCategories();
if (response?.data?.success && response.data.data) {
// Filter hanya kategori yang aktif dan published
const activeCategories = response.data.data.filter(
(category: ArticleCategory) => category.isActive && category.isPublish
);
setCategories(activeCategories);
}
} catch (error) {
console.error("Error fetching article categories:", error);
// Fallback to static categories if API fails
setCategories([]);
} finally {
setLoading(false);
}
}
fetchCategories();
}, []);
// Fallback categories jika API gagal atau tidak ada data
const fallbackCategories = [
"PON XXI",
"OPERASI KETUPAT 2025",
"HUT HUMAS KE-74",
@ -14,22 +45,50 @@ export default function Category() {
"SEPUTAR PRESTASI",
];
const displayCategories = categories.length > 0 ? categories : fallbackCategories;
return (
<section className="px-4 py-10">
<div className="max-w-[1350px] mx-auto bg-white rounded-xl shadow-md p-6">
<h2 className="text-xl font-semibold mb-5">
10 Kategori Paling Populer
{loading ? "Memuat Kategori..." : `${displayCategories.length} Kategori Paling Populer`}
</h2>
<div className="flex flex-wrap gap-3">
{categories.map((category, index) => (
<button
key={index}
className="px-4 py-2 rounded border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-100 transition"
>
{category}
</button>
))}
</div>
{loading ? (
// Loading skeleton
<div className="flex flex-wrap gap-3">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index}
className="px-4 py-2 rounded border border-gray-200 bg-gray-100 animate-pulse"
>
<div className="h-4 w-20 bg-gray-300 rounded"></div>
</div>
))}
</div>
) : (
<div className="flex flex-wrap gap-3">
{displayCategories.map((category, index) => {
// Handle both API data and fallback data
const categoryTitle = typeof category === 'string' ? category : category.title;
const categorySlug = typeof category === 'string' ? category.toLowerCase().replace(/\s+/g, '-') : category.slug;
return (
<button
key={index}
className="px-4 py-2 rounded border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-100 hover:border-gray-400 transition-all duration-200"
onClick={() => {
// Navigate to category page or search by category
console.log(`Category clicked: ${categoryTitle} (${categorySlug})`);
// TODO: Implement navigation to category page
}}
>
{categoryTitle}
</button>
);
})}
</div>
)}
</div>
</section>
);

View File

@ -1,8 +1,52 @@
"use client";
import { Instagram, ChevronLeft, ChevronRight } from "lucide-react";
import { Instagram } from "lucide-react";
import Image from "next/image";
import { useRef } from "react";
import { useState, useEffect } from "react";
import { getPublicClients, PublicClient } from "@/service/client/public-clients";
import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation, Autoplay } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
// Custom styles for Swiper
const swiperStyles = `
.client-swiper .swiper-button-next,
.client-swiper .swiper-button-prev {
background: white;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 32px;
height: 32px;
margin-top: 0;
}
.client-swiper .swiper-button-next:after,
.client-swiper .swiper-button-prev:after {
font-size: 14px;
font-weight: bold;
}
.client-swiper .swiper-button-disabled {
opacity: 0.3;
}
.client-swiper.swiper-centered .swiper-button-next,
.client-swiper.swiper-centered .swiper-button-prev {
display: none;
}
.client-swiper.swiper-centered .swiper-wrapper {
justify-content: center;
}
@media (max-width: 768px) {
.client-swiper .swiper-button-next,
.client-swiper .swiper-button-prev {
display: none;
}
}
`;
// const logos = [
// { src: "/mabes.png", href: "/in/public/publication/kl" },
@ -27,63 +71,120 @@ const logos = [
];
export default function Footer() {
const scrollRef = useRef<HTMLDivElement>(null);
const [clients, setClients] = useState<PublicClient[]>([]);
const [loading, setLoading] = useState(true);
const scroll = (direction: "left" | "right") => {
if (scrollRef.current) {
scrollRef.current.scrollBy({
left: direction === "left" ? -200 : 200,
behavior: "smooth",
});
// Fetch public clients
useEffect(() => {
async function fetchClients() {
try {
const response = await getPublicClients();
if (response?.data?.success && response.data.data) {
setClients(response.data.data);
}
} catch (error) {
console.error("Error fetching public clients:", error);
// Fallback to static logos if API fails
setClients([]);
} finally {
setLoading(false);
}
}
};
fetchClients();
}, []);
return (
<footer className="border-t bg-white text-center">
<style jsx>{swiperStyles}</style>
<div className="max-w-[1350px] mx-auto">
<div className="py-6">
<h2 className="text-2xl font-semibold mb-4 px-4 md:px-0">
Publikasi
</h2>
<div className="relative flex items-center justify-center">
{/* Left Scroll Button */}
<button
onClick={() => scroll("left")}
className="absolute left-2 z-10 bg-white p-1 md:p-2 shadow rounded-full hidden md:inline-flex"
<div className="px-4 md:px-12">
<Swiper
modules={[Navigation, Autoplay]}
spaceBetween={24}
slidesPerView="auto"
centeredSlides={clients.length <= 4}
navigation={{
nextEl: ".swiper-button-next",
prevEl: ".swiper-button-prev",
}}
autoplay={{
delay: 3000,
disableOnInteraction: false,
}}
loop={clients.length > 4}
className={`client-swiper ${clients.length <= 4 ? 'swiper-centered' : ''}`}
>
<ChevronLeft className="w-4 h-4 md:w-5 md:h-5" />
</button>
{loading ? (
// Loading skeleton
Array.from({ length: 8 }).map((_, idx) => (
<SwiperSlide key={idx} className="!w-auto">
<div className="w-[80px] h-[80px] md:w-[100px] md:h-[100px] bg-gray-200 rounded animate-pulse" />
</SwiperSlide>
))
) : clients.length > 0 ? (
// Dynamic clients from API
clients.map((client, idx) => (
<SwiperSlide key={idx} className="!w-auto">
<a
href={`/in/tenant/${client.slug}`}
target="_blank"
rel="noopener noreferrer"
className="group block"
>
{client.logoUrl ? (
<Image
src={client.logoUrl}
alt={client.name}
width={100}
height={100}
className="md:w-[100px] md:h-[100px] object-contain hover:opacity-80 transition"
/>
) : (
// Fallback when no logo - menggunakan placeholder image
<div className="w-[80px] h-[80px] md:w-[100px] md:h-[100px] rounded flex items-center justify-center hover:from-blue-200 hover:to-blue-300 transition-all duration-200">
<Image
src="/logo-netidhub.png"
alt={`${client.name} placeholder`}
width={100}
height={100}
className="md:w-[100px] md:h-[100px] object-contain opacity-70"
/>
</div>
)}
</a>
</SwiperSlide>
))
) : (
// Fallback to static logos if API fails or no data
logos.map((logo, idx) => (
<SwiperSlide key={idx} className="!w-auto">
<a
href={`/in/tenant/${logo.slug}`}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Image
src={logo.src}
alt={`logo-${idx}`}
width={80}
height={80}
className="md:w-[100px] md:h-[100px] object-contain hover:opacity-80 transition"
/>
</a>
</SwiperSlide>
))
)}
</Swiper>
{/* Scrollable Container */}
<div
ref={scrollRef}
className="flex gap-6 md:gap-14 overflow-x-auto no-scrollbar px-4 md:px-12"
>
{logos.map((logo, idx) => (
<a
key={idx}
href={`/in/tenant/${logo.slug}`}
target="_blank"
rel="noopener noreferrer"
>
<Image
src={logo.src}
alt={`logo-${idx}`}
width={60}
height={60}
className="md:w-[80px] md:h-[80px] flex-shrink-0 object-contain hover:opacity-80 transition"
/>
</a>
))}
</div>
{/* Right Scroll Button */}
<button
onClick={() => scroll("right")}
className="absolute right-2 z-10 bg-white p-1 md:p-2 shadow rounded-full hidden md:inline-flex"
>
<ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
</button>
{/* Navigation Buttons */}
<div className="swiper-button-prev !text-gray-600 !w-8 !h-8 !mt-0 !top-1/2 !left-0 !-translate-y-1/2"></div>
<div className="swiper-button-next !text-gray-600 !w-8 !h-8 !mt-0 !top-1/2 !right-0 !-translate-y-1/2"></div>
</div>
</div>

View File

@ -59,7 +59,7 @@ export default function Header() {
createdAt: article.createdAt,
smallThumbnailLink: article.thumbnailUrl,
fileTypeId: article.typeId,
tenantName: article.tenantName,
clientName: article.clientName,
categories: article.categories,
label:
article.typeId === 1
@ -284,7 +284,7 @@ function Card({
<div className="p-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold flex-wrap">
<span className="bg-emerald-600 text-white px-2 py-0.5 rounded">
{item.tenantName}
{item.clientName}
</span>
<span className="text-orange-600">
{item.categories?.map((cat: any) => cat.title).join(", ")}

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { ThumbsUp, ThumbsDown } from "lucide-react";
import { Card } from "../ui/card";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { listData, listArticles } from "@/service/landing/landing";
import { toggleBookmark, getBookmarkSummaryForUser } from "@/service/content";
import { getCookiesDecrypt } from "@/lib/utils";
@ -36,24 +37,107 @@ function formatTanggal(dateString: string) {
export default function MediaUpdate() {
const [tab, setTab] = useState<"latest" | "popular">("latest");
const [contentType, setContentType] = useState<"all" | "audiovisual" | "audio" | "foto" | "text">("all");
const [dataToRender, setDataToRender] = useState<any[]>([]);
const [filteredData, setFilteredData] = useState<any[]>([]);
const [bookmarkedIds, setBookmarkedIds] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [currentTypeId, setCurrentTypeId] = useState<string>("1");
const router = useRouter();
const MySwal = withReactContent(Swal);
useEffect(() => {
fetchData(tab);
}, [tab]);
useEffect(() => {
if (contentType !== "all") {
fetchData(tab);
} else {
filterDataByContentType();
}
}, [contentType]);
useEffect(() => {
filterDataByContentType();
}, [dataToRender]);
// Function to get typeId based on content type
function getTypeIdByContentType(contentType: string): string {
switch (contentType) {
case "audiovisual":
return "2"; // Video
case "foto":
return "1"; // Image
case "audio":
return "4"; // Audio
case "text":
return "3"; // Text
default:
return "1"; // Default to Image
}
}
// Function to get link based on typeId (same as header.tsx)
function getLink(item: any) {
switch (item?.typeId) {
case 1:
return `/content/image/detail/${item?.id}`;
case 2:
return `/content/video/detail/${item?.id}`;
case 3:
return `/content/text/detail/${item?.id}`;
case 4:
return `/content/audio/detail/${item?.id}`;
default:
return "#";
}
}
// Function to filter data by content type
function filterDataByContentType() {
if (contentType === "all") {
setFilteredData(dataToRender);
return;
}
const filtered = dataToRender.filter((item) => {
// Determine content type based on item properties
const hasVideo = item.videoUrl || item.videoPath;
const hasAudio = item.audioUrl || item.audioPath;
const hasImage = item.smallThumbnailLink || item.thumbnailUrl || item.imageUrl;
const hasText = item.content || item.description;
switch (contentType) {
case "audiovisual":
return hasVideo && (hasAudio || hasImage);
case "audio":
return hasAudio && !hasVideo;
case "foto":
return hasImage && !hasVideo && !hasAudio;
case "text":
return hasText && !hasVideo && !hasAudio && !hasImage;
default:
return true;
}
});
setFilteredData(filtered);
}
async function fetchData(section: "latest" | "popular") {
try {
setLoading(true);
// Determine typeId based on contentType
const typeId = contentType === "all" ? 1 : parseInt(getTypeIdByContentType(contentType));
setCurrentTypeId(typeId.toString());
// 🔹 Ambil data artikel
const response = await listArticles(
1,
20,
1, // typeId = image
typeId, // Dynamic typeId based on content type
undefined,
undefined,
section === "latest" ? "createdAt" : "viewCount"
@ -64,7 +148,7 @@ export default function MediaUpdate() {
if (response?.error) {
console.error("Articles API failed, fallback ke old API");
const fallbackRes = await listData(
"1",
typeId.toString(),
"",
"",
20,
@ -89,16 +173,7 @@ export default function MediaUpdate() {
"Tanpa Kategori",
createdAt: article.createdAt,
smallThumbnailLink: article.thumbnailUrl,
label:
article.typeId === 1
? "Image"
: article.typeId === 2
? "Video"
: article.typeId === 3
? "Text"
: article.typeId === 4
? "Audio"
: "",
label: article.categoryName,
...article,
}));
@ -207,21 +282,25 @@ export default function MediaUpdate() {
Media Update
</h2>
{/* Tab */}
<div className="flex justify-center mb-8 bg-white">
<Card className="bg-[#FFFFFF] rounded-xl flex flex-row p-3 gap-2">
{/* Main Tab */}
<div className="flex justify-center mb-6 bg-white">
<Card className="bg-[#FFFFFF] rounded-xl flex flex-row p-3 gap-2 shadow-md border border-gray-200">
<button
onClick={() => setTab("latest")}
className={`px-5 py-2 rounded-lg text-sm font-medium ${
tab === "latest" ? "bg-[#C6A455] text-white" : "text-[#C6A455]"
className={`px-6 py-3 rounded-lg text-sm font-semibold transition-all duration-200 ${
tab === "latest"
? "bg-[#C6A455] text-white shadow-sm"
: "text-[#C6A455] hover:bg-[#C6A455]/10"
}`}
>
Konten Terbaru
</button>
<button
onClick={() => setTab("popular")}
className={`px-5 py-2 rounded-lg text-sm font-medium ${
tab === "popular" ? "bg-[#C6A455] text-white" : "text-[#C6A455]"
className={`px-6 py-3 rounded-lg text-sm font-semibold transition-all duration-200 ${
tab === "popular"
? "bg-[#C6A455] text-white shadow-sm"
: "text-[#C6A455] hover:bg-[#C6A455]/10"
}`}
>
Konten Terpopuler
@ -229,6 +308,64 @@ export default function MediaUpdate() {
</Card>
</div>
{/* Content Type Filter */}
<div className="flex justify-center mb-8">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-2xl p-4 border-2 border-blue-100 shadow-lg">
<div className="flex flex-wrap justify-center gap-2">
<button
onClick={() => setContentType("all")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "all"
? "bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg ring-2 ring-blue-300"
: "bg-white text-blue-600 border-2 border-blue-200 hover:border-blue-400 hover:shadow-md"
}`}
>
📋 Semua
</button>
<button
onClick={() => setContentType("audiovisual")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "audiovisual"
? "bg-gradient-to-r from-purple-500 to-pink-600 text-white shadow-lg ring-2 ring-purple-300"
: "bg-white text-purple-600 border-2 border-purple-200 hover:border-purple-400 hover:shadow-md"
}`}
>
🎬 Audio Visual
</button>
<button
onClick={() => setContentType("audio")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "audio"
? "bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg ring-2 ring-green-300"
: "bg-white text-green-600 border-2 border-green-200 hover:border-green-400 hover:shadow-md"
}`}
>
🎵 Audio
</button>
<button
onClick={() => setContentType("foto")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "foto"
? "bg-gradient-to-r from-orange-500 to-red-600 text-white shadow-lg ring-2 ring-orange-300"
: "bg-white text-orange-600 border-2 border-orange-200 hover:border-orange-400 hover:shadow-md"
}`}
>
📸 Foto
</button>
<button
onClick={() => setContentType("text")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "text"
? "bg-gradient-to-r from-gray-500 to-slate-600 text-white shadow-lg ring-2 ring-gray-300"
: "bg-white text-gray-600 border-2 border-gray-200 hover:border-gray-400 hover:shadow-md"
}`}
>
📝 Text
</button>
</div>
</div>
</div>
{/* Slider */}
{loading ? (
<p className="text-center">Loading...</p>
@ -243,21 +380,23 @@ export default function MediaUpdate() {
1024: { slidesPerView: 4 },
}}
>
{dataToRender.map((item) => (
{filteredData.map((item) => (
<SwiperSlide key={item.id}>
<div className="rounded-xl shadow-md overflow-hidden bg-white">
<div className="w-full h-[204px] relative">
<Image
src={item.smallThumbnailLink || "/placeholder.png"}
alt={item.title || "No Title"}
fill
className="object-cover"
/>
<Link href={getLink(item)}>
<Image
src={item.smallThumbnailLink || "/placeholder.png"}
alt={item.title || "No Title"}
fill
className="object-cover cursor-pointer hover:opacity-90 transition-opacity"
/>
</Link>
</div>
<div className="p-3">
<div className="flex gap-2 mb-1">
<span className="text-xs text-white px-2 py-0.5 rounded bg-blue-600">
{item.category || "Tanpa Kategori"}
{item.clientName || "Tanpa Kategori"}
</span>
<span className="text-xs font-medium text-[#b3882e]">
{item.label || ""}
@ -266,9 +405,11 @@ export default function MediaUpdate() {
<p className="text-xs text-gray-500 mb-1">
{formatTanggal(item.createdAt)}
</p>
<p className="text-sm font-semibold mb-3 line-clamp-2">
{item.title}
</p>
<Link href={getLink(item)}>
<p className="text-sm font-semibold mb-3 line-clamp-2 cursor-pointer hover:text-blue-600 transition-colors">
{item.title}
</p>
</Link>
<div className="flex items-center justify-between">
<div className="flex gap-2 text-gray-600">

View File

@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
/**
* Custom hook untuk mengelola state dengan localStorage
* @param key - Key untuk localStorage
* @param initialValue - Nilai awal jika tidak ada data di localStorage
* @returns [value, setValue] - Tuple berisi nilai dan fungsi setter
*/
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
// Fungsi untuk mendapatkan nilai dari localStorage
const getStoredValue = (): T => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
};
// State dengan lazy initialization
const [storedValue, setStoredValue] = useState<T>(getStoredValue);
// Fungsi setter yang juga menyimpan ke localStorage
const setValue = (value: T) => {
try {
setStoredValue(value);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(value));
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
};
// Update state jika localStorage berubah dari tab lain
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.warn(`Error parsing localStorage value for key "${key}":`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setValue];
}

7
package-lock.json generated
View File

@ -127,7 +127,7 @@
"sonner": "^2.0.7",
"sweetalert2": "^11.10.5",
"sweetalert2-react-content": "^5.1.0",
"swiper": "^11.1.15",
"swiper": "^11.2.10",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
@ -21233,7 +21233,9 @@
}
},
"node_modules/swiper": {
"version": "11.1.15",
"version": "11.2.10",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-11.2.10.tgz",
"integrity": "sha512-RMeVUUjTQH+6N3ckimK93oxz6Sn5la4aDlgPzB+rBrG/smPdCTicXyhxa+woIpopz+jewEloiEE3lKo1h9w2YQ==",
"funding": [
{
"type": "patreon",
@ -21244,7 +21246,6 @@
"url": "http://opencollective.com/swiper"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}

View File

@ -128,7 +128,7 @@
"sonner": "^2.0.7",
"sweetalert2": "^11.10.5",
"sweetalert2-react-content": "^5.1.0",
"swiper": "^11.1.15",
"swiper": "^11.2.10",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",

View File

@ -0,0 +1,38 @@
import { getCookiesDecrypt } from "@/lib/utils";
import {
httpGetInterceptor,
} from "../http-config/http-interceptor-service";
// Types untuk Article Category
export interface ArticleCategory {
id: number;
title: string;
description: string;
thumbnailUrl: string;
slug: string;
tags: string[];
thumbnailPath: string | null;
parentId: number | null;
oldCategoryId: number | null;
createdById: number;
statusId: number;
isPublish: boolean;
publishedAt: string | null;
isEnabled: boolean | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface ArticleCategoriesResponse {
success: boolean;
code: number;
messages: string[];
data: ArticleCategory[];
}
// Service function
export async function getArticleCategories() {
const url = "/article-categories";
return httpGetInterceptor(url);
}

View File

@ -0,0 +1,61 @@
import { getCookiesDecrypt } from "@/lib/utils";
import {
httpGetInterceptor,
httpPutInterceptor,
httpPostInterceptor,
} from "../http-config/http-interceptor-service";
// Types untuk Client Profile
export interface ClientProfile {
id: string;
name: string;
description: string | null;
clientType: string;
parentClientId: string | null;
logoUrl: string | null;
logoImagePath: string | null;
address: string | null;
phoneNumber: string | null;
website: string | null;
subClientCount: number;
maxUsers: number | null;
maxStorage: number | null;
currentUsers: number;
currentStorage: number | null;
settings: any | null;
createdById: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface UpdateClientProfileRequest {
name: string;
description?: string;
address?: string;
phoneNumber?: string;
website?: string;
}
// Service functions
export async function getClientProfile() {
const url = "/clients/profile";
return httpGetInterceptor(url);
}
export async function updateClientProfile(data: UpdateClientProfileRequest) {
const url = "/clients/update";
return httpPutInterceptor(url, data);
}
export async function uploadClientLogo(logoFile: File) {
const url = "/clients/logo";
const formData = new FormData();
formData.append("logo", logoFile);
const headers = {
"Content-Type": "multipart/form-data",
};
return httpPostInterceptor(url, formData, headers);
}

View File

@ -0,0 +1,31 @@
import { getCookiesDecrypt } from "@/lib/utils";
import {
httpGetInterceptor,
} from "../http-config/http-interceptor-service";
// Types untuk Public Client
export interface PublicClient {
name: string;
slug: string;
description: string | null;
clientType: string;
logoUrl: string | null;
address: string | null;
phoneNumber: string | null;
website: string | null;
isActive: boolean;
createdAt: string;
}
export interface PublicClientsResponse {
success: boolean;
code: number;
messages: string[];
data: PublicClient[];
}
// Service function
export async function getPublicClients() {
const url = "/clients/public";
return httpGetInterceptor(url);
}