feat: Refactor Mentor Settings Page and improve user profile handling

- Removed MentorSettingsPage component to streamline user profile management.
- Updated ProjectForumTab and ProjectsInternTab for better UI/UX.
- Enhanced ProjectsPage loading state with a spinner.
- Improved TaskTab to handle loading states and error messages more effectively.
- Added new utility functions for user initials and file URL resolution.
- Updated UserService to normalize user data and handle profile updates more efficiently.
- Enhanced CSS styles for better visual consistency across components.
- Added new properties to CurrentUser type for better user data management.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-14 17:20:15 +07:00
parent a124a2b96e
commit c2aacb505f
37 changed files with 613 additions and 668 deletions
+14 -9
View File
@@ -1,15 +1,15 @@
import { Briefcase, Globe, Users } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { FaInstagram, FaLinkedin, FaRegEnvelope } from "react-icons/fa";
import logoHumic from "../assets/logo.png"
const footerLinks = {
Internship: [
{ label: "Beranda", path: "/" },
{ label: "Tentang", path: "/tentang" },
{ label: "Life At Humic", path: "/life" },
{ label: "Tentang", path: "/about-us" },
{ label: "Life At Humic", path: "/our-developer" },
],
"Internify Logo": [
{ label: "Humic Website", path: "/humic" },
External: [
{ label: "Humic Website", path: "https://humic.telkomuniversity.ac.id/" },
{ label: "Our Activity", path: "/activity" },
{ label: "Life At Humic", path: "/life-humic" },
{ label: "Our Developer", path: "/developer" },
@@ -17,9 +17,9 @@ const footerLinks = {
};
const socials = [
{ label: "Instagram", icon: FaInstagram, href: "https://placeholder.com" },
{ label: "LinkedIn", icon: FaLinkedin, href: "https://placeholder.com" },
{ label: "Email", icon: FaRegEnvelope, href: "https://placeholder.com" },
{ label: "Instagram", icon: FaInstagram, href: "https://www.instagram.com/humicengineering/" },
{ label: "LinkedIn", icon: FaLinkedin, href: "https://www.linkedin.com/company/humic-engineering/" },
{ label: "Email", icon: FaRegEnvelope, href: "https://humic.telkomuniversity.ac.id/" },
];
@@ -54,7 +54,7 @@ export function FooterIntern() {
{/* Link columns */}
<div className="flex gap-12 flex-1 justify-end flex-wrap">
{Object.entries(footerLinks).map(([title, links]) => (
<div key={title} className="flex flex-col gap-2.5 min-w-[110px]">
<div key={title} className="flex flex-col gap-2 min-w-[110px]">
<p className="text-[13px] font-bold text-gray-900">{title}</p>
<ul className="flex flex-col gap-0.5">
{links.map(({ label, path }) => (
@@ -71,6 +71,11 @@ export function FooterIntern() {
</div>
))}
</div>
<img
src={logoHumic}
alt="logo humic"
className='w-[110px] h-[68px]'
/>
</footer>
);
}
+9 -14
View File
@@ -1,25 +1,17 @@
import { Bell, Menu } from "lucide-react"
import { useEffect, useState } from "react";
import { useState } from "react";
import humiclogo from "../assets/logo.png";
import { useNavigate } from "react-router-dom";
import { useCurrentUser, resolveFileUrl, DEFAULT_AVATAR } from "../hooks/useUser";
function getInitials(fullName: string | undefined | null): string {
if (!fullName) return "U";
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "U";
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
import { useCurrentUser, resolveFileUrl, getInitials } from "../hooks/useUser";
import { useNotifications } from "../hooks/useNotification";
export default function Header({ toggleSidebar }: { toggleSidebar: () => void }) {
const nav = useNavigate();
const { user } = useCurrentUser();
const [imageError, setImageError] = useState(false);
const displayName = user?.full_name?.trim() || [user?.nama_depan, user?.nama_belakang].filter(Boolean).join(" ") || "";
const { unreadCount } = useNotifications();
const photoUrl = resolveFileUrl(user?.profile_picture);
const displayName = user?.full_name?.trim() || [user?.nama_depan, user?.nama_belakang].filter(Boolean).join(" ") || "";
const initials = getInitials(displayName);
const hasPhoto = !!photoUrl && !imageError;
@@ -40,8 +32,11 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
</div>
<div className="flex flex-row gap-6 items-center">
<button onClick={() => nav("notifications")}>
<button onClick={() => nav("notifications")} className="relative">
<Bell className="w-5 h-5 hover:text-red-800"/>
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-600 rounded-full border-2 border-white" />
)}
</button>
{/* Gambar Profil */}
+12 -7
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { useDeadlines } from '../../hooks/useTasks';
const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
@@ -16,6 +16,9 @@ function getFirstDayOfMonth(year: number, month: number) {
return new Date(year, month, 1).getDay()
}
const formatDate = (dateInput: Date | string) =>
new Date(dateInput).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' });
export default function HomeCalendar() {
const [today, setToday] = useState(new Date());
const { deadlines, loading } = useDeadlines();
@@ -82,22 +85,24 @@ export default function HomeCalendar() {
const totalCells = Math.ceil((firstDay + daysInMonth) / 7) * 7
const leadingDays = Array.from({ length: totalCells - firstDay - daysInMonth }, (_, i) => i + 1)
// Bold dates (weekends or special)
// Tanggal Akhir Pekan
const isBold = (day: number) => {
const d = new Date(currentYear, currentMonth, day).getDay()
return d === 0 || d === 6 // Sun or Sat
return d === 0 || d === 6
}
if (loading) {
return (
<div className="px-13 py-5 border border-card-outline rounded-2xl">
<p className="text-[11px] text-font-shade">Loading calendar...</p>
<div className="px-13 py-5 bg-gray-100 animate-pulse rounded-2xl">
<div className="h-[260px] rounded-xl flex items-center justify-center animate-pulse">
<Loader2 className='w-5 h-5 animate-spin'/>
</div>
</div>
)
}
return (
<div className=" px-13 py-5 border border-card-outline rounded-2xl">
<div className="px-13 py-5 border border-card-outline rounded-2xl">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="font-bold text-font">
@@ -180,7 +185,7 @@ export default function HomeCalendar() {
{upcomingDeadlines.map((d, i) => (
<li key={i} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-600 flex-shrink-0" />
<span className="text-xs text-font-shade">{d.label}</span>
<span className="text-xs text-font-shade">{formatDate(d.date)} - {d.label}</span>
</li>
))}
</ul>
+2 -2
View File
@@ -68,7 +68,7 @@ export default function InternifyLogin() {
</p>
</div>
{/* MAIN CONTENT */}
<div className="max-w-md bg-box-primary rounded-2xl border border-box-border p-8 md:p-12 shadow-sm">
<div className="box max-w-md bg-box-primary border border-box-border p-8 md:p-12 shadow-sm rounded-lg">
<h2 className="text-xl font-bold text-gray-900 mb-1">Welcome back</h2>
<p className="text-sm text-gray-500 mb-6">Access your dashboard with your credentials.</p>
@@ -142,7 +142,7 @@ export default function InternifyLogin() {
</div>
<button className="w-full mt-2 flex items-center justify-center gap-2 border border-card-outline rounded-full py-1.5 text-xs text-font-shade hover:bg-gray-50 transition-colors font-medium">
<CircleQuestionMark className="w-5 h-5"/>
<CircleQuestionMark className="w-5 h-5" />
Frequently Asked Questions
</button>
</div>
+4 -3
View File
@@ -6,6 +6,7 @@ import { io } from "socket.io-client";
import * as NotificationService from "../../services/NotificationService";
import type { UINotification, BackendNotification } from "../../types/notification.types";
import mailRafiki from '../../assets/Mail-rafiki.svg'
import { customToast } from "../utils/showToast";
const getCookieToken = () => {
return document.cookie
@@ -70,9 +71,9 @@ export default function NotificationList() {
try {
await NotificationService.markAllAsRead();
setNotifications([]);
alert("All notifications marked as read!");
} catch (err) {
console.error("Failed to mark all as read:", err);
customToast.success("All notifications marked as read!");
} catch (err:any) {
customToast.error("Failed to mark all as read", err?.message);
}
};
+92 -40
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { useCurrentUser, useUpdateProfile, resolveFileUrl, DEFAULT_AVATAR } from "../../hooks/useUser";
import { useCurrentUser, useUpdateProfile, resolveFileUrl, getInitials } from "../../hooks/useUser";
import { customToast } from "../utils/showToast";
import { Pen } from "lucide-react";
import UpdateProfileDialog from "./UpdateProfileDialogue";
@@ -8,7 +8,9 @@ export default function SettingsPage() {
const { user, loading: userLoading } = useCurrentUser();
const { save, loading: saving, msgRef } = useUpdateProfile();
const [confirmOpen, setConfirmOpen] = useState(false);
const isIntern = user?.role === "intern";
const hasPendingChanges = isIntern && (user?.changes ?? 0) > 0;
const [formData, setFormData] = useState({
fullName: "",
@@ -16,9 +18,13 @@ export default function SettingsPage() {
bio: "",
});
const [formErrors, setFormErrors] = useState({
fullName: "",
email: "",
});
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [removePhoto, setRemovePhoto] = useState(false);
const [imageError, setImageError] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -26,13 +32,13 @@ export default function SettingsPage() {
if (!user) return;
setFormData({
fullName: user.full_name || `${user.nama_depan} ${user.nama_belakang}`.trim(),
fullName: user.full_name || "",
email: user.email || "",
bio: user.professional_bio || "",
});
setPhotoPreview(resolveFileUrl(user.profile_picture) ?? DEFAULT_AVATAR);
setRemovePhoto(false);
setPhotoFile(null);
setPhotoPreview(resolveFileUrl(user.profile_picture));
setImageError(false);
}, [user]);
@@ -46,7 +52,6 @@ export default function SettingsPage() {
if (!file) return;
setPhotoFile(file);
setRemovePhoto(false);
setImageError(false);
const reader = new FileReader();
@@ -54,29 +59,45 @@ export default function SettingsPage() {
reader.readAsDataURL(file);
};
const handleRemovePhoto = () => {
const handleCancelPhoto = () => {
setPhotoFile(null);
setRemovePhoto(true);
setPhotoPreview(DEFAULT_AVATAR);
setPhotoPreview(resolveFileUrl(user?.profile_picture));
setImageError(false);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const validateForm = () => {
const errors = { fullName: "", email: "" };
if (!formData.fullName.trim()) errors.fullName = "Full name is required.";
if (!formData.email.trim()) errors.email = "Email address is required.";
setFormErrors(errors);
return !errors.fullName && !errors.email;
};
const handleSaveChanges = async () => {
setConfirmOpen(false);
if (!validateForm()) return;
const emailChanged =
formData.email.trim().toLowerCase() !== (user?.email ?? "").trim().toLowerCase();
try {
await customToast.promise(
save({
full_name: formData.fullName,
email: formData.email,
professional_bio: formData.bio,
profile_picture: photoFile ?? (removePhoto ? DEFAULT_AVATAR : undefined),
profile_picture: !isIntern && photoFile ? photoFile : undefined,
}),
{
loading: "Menyimpan perubahan...",
success: () => ({
title: "Perubahan berhasil disimpan!",
description: msgRef.current.success ?? "",
description: emailChanged
? "Email berubah, silakan login ulang..."
: msgRef.current.success ?? "",
}),
error: () => ({
title: "Gagal menyimpan perubahan!",
@@ -84,22 +105,36 @@ export default function SettingsPage() {
}),
}
);
// email berubah -> sesi lama tidak valid lagi, paksa login ulang
if (emailChanged) {
document.cookie = "token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
setTimeout(() => {
window.location.href = "/login-internify";
}, 1200);
return;
}
setPhotoFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
} catch { }
};
if (userLoading) {
return (
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
Loading profile...
</div>
);
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setConfirmOpen(true);
};
if (userLoading) {
return (
<div className="space-y-4 mt-10">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-32 bg-box-secondary rounded-2xl animate-pulse" />
))}
</div>
);
}
return (
<div>
{/* Header Title */}
@@ -123,35 +158,32 @@ export default function SettingsPage() {
{/* Card Body & Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Profile Photo Row */}
<div className="flex items-center gap-5 py-2">
{/* Avatar dengan badge edit */}
<div className="relative flex-shrink-0">
<div className="w-16 h-16 rounded-xl border border-card-outline overflow-hidden bg-gray-100 flex items-center justify-center shadow-sm">
{photoPreview && !imageError && (
<div className="w-16 h-16 rounded-xl border border-card-outline overflow-hidden bg-red flex items-center justify-center shadow-sm">
{photoPreview && !imageError ? (
<img
src={photoPreview}
alt="Profile"
className="w-full h-full object-cover"
onError={() => setImageError(true)}
/>
) : (
<p className="text-lg font-bold text-white">{getInitials(user?.full_name) || "U"}</p>
)}
</div>
{/* Badge edit icon */}
{!isIntern &&
{!isIntern && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute -bottom-1.5 -right-1.5 w-6 h-6 bg-[#B30000] rounded-full flex items-center justify-center shadow border-2 border-white hover:bg-[#990000] transition-colors"
className="absolute -bottom-1.5 -right-1.5 w-6 h-6 bg-red rounded-full flex items-center justify-center shadow border-2 border-white hover:bg-red-800 transition-colors"
title="Edit photo"
>
<Pen className="text-white w-2.5 h-2.5" />
</button>
}
)}
</div>
{/* Info & Action Buttons */}
{!isIntern ? (
<div>
<h3 className="text-sm font-bold text-gray-900 mb-0.5">Profile Photo</h3>
@@ -159,31 +191,35 @@ export default function SettingsPage() {
JPG, GIF or PNG. Recommended size 800x800px. Max size of 800K.
</p>
{photoFile && (
<p className="text-xs text-blue-500 mb-2 font-medium">
{photoFile.name}
</p>
<p className="text-xs text-blue-500 mb-2 font-medium">{photoFile.name}</p>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
className="px-4 py-1.5 bg-red hover:bg-red-600 text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
>
Upload New
</button>
<button
type="button"
onClick={handleRemovePhoto}
className="px-4 py-1.5 bg-white border border-box-border text-gray-600 hover:bg-gray-50 text-xs font-bold rounded-lg transition-colors"
onClick={handleCancelPhoto}
disabled={!photoFile}
className="px-4 py-1.5 bg-white border border-box-border text-gray-600 hover:bg-gray-50 text-xs font-bold rounded-lg transition-colors disabled:opacity-40"
>
Remove
Cancel
</button>
</div>
</div>
) : (
<div>
<h3 className="text-sm font-bold text-gray-900 mb-0.5">Nama</h3>
<p className="text-base text-gray-700 font-medium">{user?.full_name || formData.fullName || 'User'}</p>
<p className="text-base text-font-shade font-medium">
{user?.full_name || formData.fullName || "User"}
</p>
<p className="text-[13px] text-font font-medium">
{user.kelompok_peminatan}
</p>
</div>
)}
@@ -210,8 +246,17 @@ export default function SettingsPage() {
name="fullName"
value={formData.fullName}
onChange={handleChange}
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
disabled={hasPendingChanges}
className={`w-full px-3 py-2 border rounded-lg text-sm text-gray-800 focus:outline-none transition-colors bg-white font-medium disabled:bg-gray-100 disabled:text-gray-500 ${formErrors.fullName ? "border-red-600 focus:border-red-600 focus:ring-1 focus:ring-red-600" : "border-box-border focus:border-red-600 focus:ring-1 focus:ring-red-600"}`}
/>
{formErrors.fullName && (
<p className="text-[11px] text-red-600">{formErrors.fullName}</p>
)}
{hasPendingChanges && (
<p className="text-[11px] text-gray-500">
Nama hanya bisa diubah satu kali dan sudah pernah diubah.
</p>
)}
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
@@ -222,8 +267,14 @@ export default function SettingsPage() {
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
className={`w-full px-3 py-2 border rounded-lg text-sm text-gray-800 focus:outline-none transition-colors bg-white font-medium ${formErrors.email ? "border-red-600 focus:border-red-600 focus:ring-1 focus:ring-red-600" : "border-box-border focus:border-red-600 focus:ring-1 focus:ring-red-600"}`}
/>
{formErrors.email && (
<p className="text-[11px] text-red-600">{formErrors.email}</p>
)}
<p className="text-[11px] text-gray-500 font-normal pt-0.5">
Mengubah email akan mengakhiri sesi — kamu harus login ulang.
</p>
</div>
</div>
@@ -249,7 +300,7 @@ export default function SettingsPage() {
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-xs rounded-lg shadow-sm transition-colors uppercase tracking-wider disabled:opacity-60"
className="px-6 py-2 bg-red hover:bg-red-700 text-white font-bold text-xs rounded-lg shadow-sm transition-colors uppercase tracking-wider disabled:bg-gray-400"
>
{saving ? "Saving..." : "Save Changes"}
</button>
@@ -257,6 +308,7 @@ export default function SettingsPage() {
</form>
</div>
<UpdateProfileDialog
isOpen={confirmOpen}
isIntern={isIntern}
@@ -26,9 +26,6 @@ export default function UpdateProfileDialog({
if (!isOpen && dialog.open) dialog.close();
}, [isOpen]);
// Event "cancel" dipicu saat user menekan Esc.
// preventDefault() tanpa onClose() = Esc benar-benar dimatikan,
// dialog hanya bisa ditutup lewat tombol Batal / X.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
@@ -42,12 +39,8 @@ export default function UpdateProfileDialog({
return (
<dialog
ref={dialogRef}
// fixed inset-0 + m-auto + h-fit -> dialog center horizontal & vertical.
// hidden + open:flex -> hanya tampil kalau attribute [open] ada,
// supaya lifecycle showModal()/close() tetap dihormati.
className="hidden open:flex fixed inset-0 m-auto h-fit max-h-[90vh] w-[90vw] max-w-md flex-col overflow-hidden rounded-xl border border-box-border p-0 shadow-xl backdrop:bg-black/40 backdrop:backdrop-blur-[2px]"
onClick={(e) => {
// klik di area backdrop (target === dialog itu sendiri) -> tutup
if (e.target === dialogRef.current && !loading) onClose();
}}
>
+20 -11
View File
@@ -1,5 +1,6 @@
import { useParams } from "react-router-dom";
import { useCertificateVerification } from "../../hooks/useCertificates";
import logoHumic from "../../assets/logo.png"
export default function VerificationCert() {
const { uuid } = useParams<{ uuid: string }>();
@@ -7,7 +8,7 @@ export default function VerificationCert() {
if (loading) {
return (
<div className="container flex items-center justify-center min-h-screen">
<div className="flex items-center justify-center min-h-screen">
<p className="text-gray-500 animate-pulse">Memverifikasi sertifikat...</p>
</div>
);
@@ -15,9 +16,14 @@ export default function VerificationCert() {
if (error || !verification) {
return (
<div className="container flex flex-col items-center justify-center min-h-screen text-center px-4">
<h1 className="text-2xl font-bold text-red-600 mb-2">Sertifikat Tidak Valid</h1>
<p className="text-gray-500">
<div className="min-h-screen bg-gradient-to-br from-slate-100 via-slate-300 to-gray-50 flex items-center justify-center flex-col px-6 relative overflow-hidden">
<img
src={logoHumic}
alt="Humic Internify"
className="w-[240px] h-[240x] object-cover mb-10"
/>
<h1 className="text-2xl font-bold text-red mb-2 animate-pulse">Maaf, ID Sertifikat anda masukkan tidak valid</h1>
<p className="text-gray-500 text-[16px]">
Sertifikat dengan UUID ini tidak dapat ditemukan atau sudah tidak berlaku.
</p>
</div>
@@ -25,22 +31,26 @@ export default function VerificationCert() {
}
return (
<div className="container flex items-center justify-center min-h-screen px-4">
<div className="bg-white shadow-md rounded-lg p-8 max-w-md w-full">
<div className="min-h-screen bg-gradient-to-br from-slate-150 via-slate-300 to-gray-50 flex items-center justify-center flex-col px-6 relative overflow-hidden">
<img
src={logoHumic}
alt="Humic Internify"
className="[240px] h-[240x] object-cover mb-14"
/>
<h1 className="text-2xl font-bold text-green-600 mb-6 text-center">
Sertifikat Valid
</h1>
<div className="space-y-3 text-sm">
<div>
<span className="text-gray-500">Nama Intern</span>
<span className="text-font">Nama Intern</span>
<p className="font-semibold">{verification.intern_name}</p>
</div>
<div>
<span className="text-gray-500">Proyek</span>
<span className="text-font">Proyek</span>
<p className="font-semibold">{verification.project_name}</p>
</div>
<div>
<span className="text-gray-500">Tanggal Terbit</span>
<span className="text-font">Tanggal Terbit</span>
<p className="font-semibold">
{new Date(verification.created_at).toLocaleDateString("id-ID", {
day: "numeric",
@@ -50,11 +60,10 @@ export default function VerificationCert() {
</p>
</div>
<div>
<span className="text-gray-500">UUID</span>
<span className="text-font">UUID</span>
<p className="font-mono text-xs break-all">{verification.uuid}</p>
</div>
</div>
</div>
</div>
);
}
@@ -8,7 +8,7 @@ export default function HomeInternPage() {
<div className="flex-1">
<HomeHeader />
<div className="flex flex-col gap-5">
<div className="grid grid-cols-2 gap-5">
<div className="flex flex-col gap-5 md:grid md:grid-cols-2">
<div className="bg-box-primary border border-box-border rounded-xl p-6">
<HomeProjectList />
</div>
@@ -9,7 +9,7 @@ import { useProjectDetail } from "../../../hooks/useProjects";
import { useMyCertificates } from "../../../hooks/useCertificates";
import type { Project } from "../../../types/project.types";
export default function InternCertificateDetail () {
export default function InternCertificateDetail() {
const { slug } = useParams<{ slug: string }>();
const location = useLocation();
const projectFromList = location.state?.project as Project | undefined;
@@ -43,8 +43,19 @@ export default function InternCertificateDetail () {
if (isLoading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
Memuat Data...
<div>
<div className="mb-4 flex flex-col gap-1">
<h1 className="page-title">Certificates</h1>
<p className="page-title-desc">View and download internship certificates</p>
</div>
<div className="flex flex-col lg:flex-row gap-6 items-stretch animate-pulse">
{/* Kolom kiri: certificate box */}
<div className="w-full lg:w-5/8 h-[420px] bg-gray-200 rounded-xl" />
{/* Kolom kanan: history box */}
<div className="w-full lg:w-3/8 flex-shrink-0 h-[420px] bg-gray-200 rounded-xl" />
</div>
</div>
);
}
@@ -61,7 +72,7 @@ export default function InternCertificateDetail () {
<CertificateAvailable
project={project}
certificate={myCertificateForProject}
templateUrl = {project?.certificate_template ?? ""}
templateUrl={project?.certificate_template ?? ""}
/>
) : (
<CertificateNotAvailable
+2 -14
View File
@@ -6,7 +6,6 @@ import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import emptyimage from '../../../assets/Learning-amico.svg'
// Skeleton card that mirrors the real certificate card layout
function CertCardSkeleton() {
return (
<div className="bg-white border border-card-outline rounded-xl overflow-hidden shadow-sm animate-pulse">
@@ -17,10 +16,7 @@ function CertCardSkeleton() {
</div>
</div>
{/* Info */}
<div className="px-4 py-3 space-y-2">
<div className="h-3 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
<div className="px-4 py-3 space-y-2 bg-gray-100">
</div>
</div>
);
@@ -37,22 +33,17 @@ export default function SertificateList() {
};
return (
<div>
{/* Header */}
<div className="mb-6 flex flex-col gap-1">
<h1 className="page-title">Certificate History</h1>
<p className="page-title-desc">All your earned internship certificates</p>
</div>
{/* Loading Skeleton */}
{loading && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{[...Array(6)].map((_, i) => (
{[...Array(projects.length)].map((_, i) => (
<CertCardSkeleton key={i} />
))}
</div>
)}
{/* Error */}
{error && !loading && (
<div className="flex items-center justify-center py-20 text-red-500 gap-2">
<AlertCircle className="w-6 h-6" />
@@ -60,15 +51,12 @@ export default function SertificateList() {
</div>
)}
{/* Empty */}
{!loading && !error && projects.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 gap-5 text-font-shade">
<img src={emptyimage} alt="empty image" className="w-64 h-64" />
<p className="text-[18px]">Tidak ada projek yang berlangsung.</p>
</div>
)}
{/* Grid */}
{!loading && !error && projects.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{projects.map((item: Project) => (
+14 -4
View File
@@ -1,7 +1,12 @@
import { Users } from 'lucide-react';
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
import { useState } from 'react';
import { resolveFileUrl, getInitials } from '../../../hooks/useUser';
export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
const [ImageError, setImageError] = useState(false);
const Avatar = resolveFileUrl(project.admin.profile_picture);
const hasAvatar = !!Avatar && !ImageError;
return (
<div>
{/* Mentor */}
@@ -9,14 +14,19 @@ export default function ParticipantsTab({ project }: { project: ProjectDetail })
<h3 className="font-bold text-font-shade mb-3.5">Mentor</h3>
<div className="border-b border-box-border pb-4">
<div className="flex items-center gap-3">
{project.admin.profile_picture ? (
{hasAvatar ? (
<img
src={project.admin.profile_picture}
src={Avatar}
alt={project.admin.full_name}
className="w-8 h-8 rounded-full object-cover"
className="w-[40px] h-[40px] object-cover rounded-full"
onError={() => setImageError(true)}
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-200" />
<div className="w-full h-full bg-[#B30000] flex items-center justify-center">
<span className="text-white text-[10px] font-bold tracking-wide">
{getInitials(project.admin.full_name)}
</span>
</div>
)}
<span className="text-md text-font">{project.admin.full_name}</span>
</div>
+30 -21
View File
@@ -2,18 +2,37 @@ import ProjectCard from './components/ProjectsCard'
import { useMyProjects } from '../../../hooks/useProjects';
import TeamPana from '../../../assets/Team-pana.svg'
function ProjectCardSkeleton() {
return (
<div className="w-[180px] min-h-[270px] bg-gray-300 rounded-xl overflow-hidden shadow-sm animate-pulse" />
)
}
export default function ProjectsPage() {
const { projects, loading, error } = useMyProjects();
if (loading) return (
<div className="flex flex-col gap-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
);
const renderContent = () => {
if (loading) {
return [...Array(5)].map((_, i) => <ProjectCardSkeleton key={i} />);
}
if (error) return <p className="text-danger text-md">{error}</p>;
if (error) {
return <p className="text-danger text-md col-span-full">{error}</p>;
}
if (projects.length === 0) {
return (
<div className="col-span-full flex flex-col items-center justify-center py-2 gap-3 mt-19">
<img className="w-[440px]" src={TeamPana} alt="" />
<p className="text-[18px] text-font-shade">Kamu belum tergabung ke projek apapun</p>
</div>
);
}
return projects.map(project => (
<ProjectCard key={project.id} {...project} />
));
};
return (
<div>
@@ -22,19 +41,9 @@ export default function ProjectsPage() {
<h1 className="page-title-desc">View assigned projects and track their progress.</h1>
</div>
{projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-2 gap-3 mt-19">
<img className='w-[440px]' src={TeamPana} alt="" />
<p className="text-[18px] text-font-shade">Kamu belum tergabung ke projek apapun</p>
</div>
) : (
// Grid card
<div className="grid grid-cols-1 gap-4 md:gap-3 md:grid-cols-3 lg:grid-cols-5">
{projects.map(project => (
<ProjectCard key={project.id} {...project} />
))}
</div>
)}
<div className="flex flex-wrap gap-4 items-stretch">
{renderContent()}
</div>
</div>
)
}
@@ -20,7 +20,7 @@ const HomeProjectList = () => {
if (error) return <p className="text-red-500 text-sm">{error}</p>;
return (
<div>
<div className=''>
<h2 className="text-lg font-semibold text-font-shade mb-5">Projects</h2>
{projects.length === 0 ? (
@@ -34,6 +34,12 @@ export default function TaskCard({ task }: { task: InternTaskItem }) {
onClick={() => navigate(`/intern/projects/${task.project_slug}/${task.slug}?type=${task.submission_type}`)}
>
<div className="flex-1 min-w-0">
<div className="md:hidden mb-[8px]">
<span
className={`font-semibold text-xs px-2.5 py-1 rounded-md whitespace-nowrap border ${statusStyles[displayStatus]}`}>
{statusLabels[displayStatus]}
</span>
</div>
<div className="text-[11px] text-font mb-1">
Deadline : {formatDate(task.deadline_at)} at {formatTime(task.deadline_at)}
</div>
@@ -48,7 +54,6 @@ export default function TaskCard({ task }: { task: InternTaskItem }) {
</div>
</div>
<div className="block md:hidden h-[10px] bg-gray-600" />
<div className="shrink-0 hidden md:flex items-center border border-card-outline">
<span
@@ -7,8 +7,8 @@ export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] })
const today = new Date();
today.setHours(0, 0, 0, 0);
return (
<div className="">
<div className="flex items-center gap-1.5 mb-1 md:mb-4 justify-between">
<div>
<div className="flex items-center gap-1.5 mb-0 md:mb-4 justify-between">
<span className="font-semibold text-md text-font-shade">Timeline</span>
</div>
<ul className="relative pl-4">
@@ -19,13 +19,13 @@ export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] })
const isPast = d < today;
const active = isToday || isPast;
return (
<li key={task.id} className="relative mb-6 list-none">
<li key={task.id} className="relative mb-2 list-none">
{index < tasks.length - 1 && (
<div className="absolute -left-[1.0625rem] top-3 w-px h-full bg-[#ddd]" />
)}
{/* Dot */}
<div
className={`absolute -left-5 top-0.5 w-3 h-3 rounded-full border-2 border-white ${
className={`absolute -left-[1.45rem] top-0.5 w-3 h-3 rounded-full border-2 border-white ${
active ? 'bg-red' : 'bg-gray-400'
} ${isToday ? 'shadow-md' : ''}`}
/>
@@ -1,39 +1,40 @@
import { useNavigate } from 'react-router-dom';
import { getProjectIcon } from '../../../../lib/ProjectIcons';
import type { Project } from '../../../../types/project.types';
import { resolveFileUrl } from '../../../../hooks/useUser';
const ProjectCard = (project: Project) => {
const navigate = useNavigate();
const Icon = getProjectIcon(project.project_icon);
const Avatar = resolveFileUrl(project.admin.profile_picture);
const tasksDone = project.task_done;
const tasksTotal = project.total_tasks;
return (
<div
onClick={() => navigate(`/intern/projects/${project.slug}`)}
className=" rounded-xl overflow-hidden shadow-sm border border-gray-100 w-full flex flex-col cursor-pointer hover:shadow-md transition-shadow duration-200"
className="w-[220px] min-h-[320px] flex flex-col bg-white rounded-xl overflow-hidden shadow-sm border border-gray-100 cursor-pointer hover:shadow-md transition-shadow duration-200"
>
{/* Thumbnail */}
<div style={{ backgroundColor: project.background_color ?? '#dc2626' }} className="h-[150px] overflow-hidden flex items-center justify-center">
<div
style={{ backgroundColor: project.background_color ?? '#dc2626' }}
className="h-[150px] flex items-center justify-center flex-shrink-0"
>
<Icon className="w-12 h-12 text-white opacity-80" />
</div>
{/* Content */}
<div className="min-h-[180px] max-h-[200px] flex-1 flex flex-col justify-between py-3">
<div className="flex flex-col flex-1 justify-between py-3">
<div className="px-3 flex flex-col gap-2">
{/* Project Name */}
<p className="text-xs lg:text-sm font-semibold text-gray-800 leading-tight">
<p className="text-sm font-semibold text-gray-800 leading-tight">
{project.project_name}
</p>
{/* Mentor */}
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-gray-200 overflow-hidden flex-shrink-0">
{project.admin.profile_picture ? (
{Avatar ? (
<img
src={project.admin.profile_picture}
src={Avatar}
alt={project.admin.full_name}
className="w-full h-full object-cover"
/>
@@ -44,7 +45,6 @@ const ProjectCard = (project: Project) => {
<span className="text-xs text-gray-500 truncate">{project.admin.email}</span>
</div>
{/* Progress bar */}
<div className="flex flex-col gap-1">
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
@@ -58,11 +58,8 @@ const ProjectCard = (project: Project) => {
</div>
</div>
{/* Button */}
<div className="flex items-end justify-end px-3 pb-1">
<button
className="bg-red-600 hover:bg-red-700 text-white text-xs font-medium py-1.5 px-3 rounded-xl transition-colors"
>
<div className="flex items-end justify-end px-3">
<button className="bg-red-600 hover:bg-red-700 text-white text-xs font-medium py-1.5 px-3 rounded-xl transition-colors">
View Project
</button>
</div>
@@ -8,6 +8,9 @@ const TaskTimeline = () => {
const isOverdue = (dateStr: string) => new Date(dateStr) < today;
const formatDate = (dateInput: Date | string) =>
new Date(dateInput).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
if (loading) return (
<div className="flex flex-col gap-5">
{[...Array(3)].map((_, i) => (
@@ -19,7 +22,7 @@ const TaskTimeline = () => {
if (error) return <p className="text-red-500 text-sm">{error}</p>;
return (
<div>
<div className=''>
<h2 className="font-semibold text-font-shade mb-5">Timeline Tugas</h2>
{tasks.length === 0 ? (
@@ -50,7 +53,7 @@ const TaskTimeline = () => {
<div className="flex flex-col gap-0.5 min-w-0">
<p className="text-[10px] font-semibold text-font-shade uppercase tracking-widest">
{deadlineDate.toLocaleDateString("id-ID")} AT{" "}
{formatDate(deadlineDate)} AT{" "}
{deadlineDate.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
+2 -2
View File
@@ -21,7 +21,7 @@ export const InternLayout = () => {
onClick={closeSidebar}
/>
)}
<main className="container">
<main className="main-content">
<Breadcrumbs/>
<Outlet />
</main>
@@ -46,7 +46,7 @@ export const MentorLayout = () => {
onClick={closeSidebar}
/>
)}
<main className="container">
<main className="main-content">
<Breadcrumbs/>
<Outlet />
</main>
+55 -44
View File
@@ -5,67 +5,78 @@ import CreateProjectModal from './components/CreateProjectDialog';
import { useProjectsByRole } from '../../../hooks/useListProjects';
import TeamPana from '../../../assets/Team-pana.svg';
function MentorProjectCardSkeleton() {
return (
<div className="flex flex-row bg-gray-300 rounded-xl overflow-hidden shadow-sm w-[180px] min-h-[270px] animate-pulse">
</div>
)
}
export default function MentorProjectsPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const { projects, loading, error, refetch } = useProjectsByRole();
const openDialog = () => {
setIsModalOpen(true);
};
const closeDialog = () => {
setIsModalOpen(false);
};
const openDialog = () => setIsModalOpen(true);
const closeDialog = () => setIsModalOpen(false);
const handleRefetch = () => {
closeDialog();
refetch();
}
};
if (loading) return (
<div className="flex flex-col gap-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
);
if (error) return <p className="text-danger text-md">{error}</p>;
const renderContent = () => {
if (loading) {
return (
<div className="flex flex-row gap-3 w-full">
{[...Array(4)].map((_, i) => (
<MentorProjectCardSkeleton />
))}
</div>
);
}
if (error) return <p className="text-danger text-md">{error}</p>;
if (projects.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-2 gap-3 mt-19 w-full">
<img className="w-[440px]" src={TeamPana} alt="" />
<p className="text-[18px] text-font-shade">
Belum ada project, buat project dengan klik 'Add'
</p>
</div>
);
}
return projects.map(project => (
<MentorProjectCard
key={project.id}
{...project}
onArchived={refetch}
onCompleted={refetch}
/>
));
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<div className="flex flex-col">
<h1 className="page-title">List Projects</h1>
<p className="page-title-desc">Create, manage, and monitor ongoing projects and assignments.</p>
</div>
<div>
<button
onClick={openDialog}
className="flex items-center gap-2 px-4 py-1 bg-[#C0392B] text-white rounded-lg hover:bg-[#A93226] transition-colors duration-150"
>
<Plus className="w-4 h-4" />
<span>Add</span>
</button>
<p className="page-title-desc">
Create, manage, and monitor ongoing projects and assignments.
</p>
</div>
<button
onClick={openDialog}
className="flex items-center gap-2 px-4 py-1 bg-[#C0392B] text-white rounded-lg hover:bg-[#A93226] transition-colors duration-150"
>
<Plus className="w-4 h-4" />
<span>Add</span>
</button>
</div>
<div className="flex flex-wrap gap-4 items-stretch">
{projects.length === 0 && (
<div className="flex flex-col items-center justify-center py-2 gap-3 mt-19 w-full">
<img className='w-[440px]' src={TeamPana} alt="" />
<p className="text-[18px] text-font-shade">Belum ada project, Buat project dengan kik 'add'</p>
</div>
)}
{projects.map(project => (
<MentorProjectCard
key={project.id}
{...project}
onArchived={refetch}
onCompleted={refetch}
/>
))}
</div>
<div className="flex flex-wrap gap-4 items-stretch">{renderContent()}</div>
{isModalOpen && (
<CreateProjectModal
@@ -76,4 +87,4 @@ export default function MentorProjectsPage() {
)}
</div>
);
};
}
@@ -192,7 +192,7 @@ export default function CertificateDetail() {
>
{uploading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<Loader2 className="w-5 h-5 animate-spin" />
Mengupload...
</>
) : (
+25 -26
View File
@@ -66,18 +66,10 @@ export default function InternsManagement() {
}
};
if (loading) return (
<div className="flex flex-col gap-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
);
if (error) return <p className="text-red-500 text-sm">{error}</p>;
return (
<div className="">
<div>
{/* Header Title */}
<div className="mb-6">
<h1 className="page-title">Interns Management</h1>
@@ -130,11 +122,10 @@ export default function InternsManagement() {
<button
onClick={handleToggleSort}
title={sortOrder === "asc" ? "Sort Z–A" : sortOrder === "desc" ? "Clear sort" : "Sort A–Z"}
className={`p-2 shadow-sm rounded-lg transition-colors hidden sm:flex items-center gap-1 text-xs font-semibold ${
sortOrder
? "bg-[#B30000] text-white hover:bg-[#990000]"
: "bg-card text-gray-600 hover:bg-gray-200"
}`}
className={`p-2 shadow-sm rounded-lg transition-colors hidden sm:flex items-center gap-1 text-xs font-semibold ${sortOrder
? "bg-[#B30000] text-white hover:bg-[#990000]"
: "bg-card text-gray-600 hover:bg-gray-200"
}`}
>
{sortOrder === "desc" ? (
<SortDesc className="w-4 h-4" />
@@ -177,18 +168,26 @@ export default function InternsManagement() {
</button>
</div>
<InternsTable
key={`${searchEmail}-${selectedProject}`}
interns={filteredInterns}
totalInterns={totalInterns}
removing={removing}
sortOrder={sortOrder}
onSort={handleToggleSort}
onAssign={(userId) => openAssignFor(userId)}
onDelete={handleDeleteIntern}
/>
{loading ? (
<div className={`w-full bg-gray-400/20 h-[300px] rounded-xl flex items-center justify-center animate-pulse`}>
<span className="text-sm text-font-shade">Memuat Data...</span>
</div>
) : (
<InternsTable
key={`${searchEmail}-${selectedProject}`}
interns={filteredInterns}
totalInterns={totalInterns}
removing={removing}
sortOrder={sortOrder}
onSort={handleToggleSort}
onAssign={(userId) => openAssignFor(userId)}
onDelete={handleDeleteIntern}
/>
)
}
{isModalOpen &&
{
isModalOpen &&
<AddInternsModal
isOpen={isModalOpen}
userId={selectedUserId}
@@ -198,6 +197,6 @@ export default function InternsManagement() {
}}
/>
}
</div>
</div >
);
}
@@ -25,7 +25,7 @@ export default function MentorProjectsDetailPage() {
};
if (loading) return (
<div className="space-y-4">
<div className="space-y-4 mt-10">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-32 bg-box-secondary rounded-2xl animate-pulse" />
))}
@@ -47,7 +47,7 @@ export default function MentorProjectsDetailPage() {
return (
<div>
{/* Header Utama */}
<div className="flex justify-between items-center mb-6">
<div className="flex justify-between items-center mb-6 ">
<h1 className="text-3xl font-bold text-[#1a1a1a]">{task.title}</h1>
<button
className="flex items-center gap-2 border border-gray-400 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@@ -1,262 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { useCurrentUser, useUpdateProfile } from "../../../hooks/useUser";
import { customToast } from "../../utils/showToast";
export default function MentorSettingsPage() {
const { user, loading: userLoading } = useCurrentUser();
const { save, loading: saving, error: errorMsg, successMsg } = useUpdateProfile();
const [formData, setFormData] = useState({
fullName: "",
email: "",
bio: "",
});
// File state untuk profile_picture
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync form data ketika user sudah di-fetch
useEffect(() => {
if (user) {
setFormData({
fullName:
user.full_name ||
`${user.nama_depan ?? ""} ${user.nama_belakang ?? ""}`.trim() ||
"",
email: user.email || "",
bio: user.professional_bio || "",
});
if (user.profile_picture) {
setPhotoPreview(user.profile_picture);
} else {
setPhotoPreview(null);
}
setImageError(false);
}
}, [user]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setPhotoFile(file);
const reader = new FileReader();
reader.onloadend = () => setPhotoPreview(reader.result as string);
reader.readAsDataURL(file);
}
};
const handleRemovePhoto = () => {
setPhotoFile(null);
setPhotoPreview(null);
setImageError(false);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSaveChanges = async (e: React.FormEvent) => {
e.preventDefault();
await customToast.promise(
save({
full_name: formData.fullName,
email: formData.email,
professional_bio: formData.bio,
}),
{
loading: "Menyimpan perubahan...",
success: () => ({
title: "Perubahan berhasil disimpan!",
description: successMsg || "",
}),
error: () => ({
title: "Gagal menyimpan perubahan!",
description: errorMsg || "",
}),
}
);
};
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
if (userLoading) {
return (
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
Loading profile...
</div>
);
}
return (
<div>
{/* Header Title */}
<div className="mb-6">
<h1 className="page-title">Settings</h1>
<p className="page-title-desc">
Manage and edit your profile and account information.
</p>
</div>
{/* White Card Container */}
<div className="w-full bg-white rounded-xl border border-box-border shadow-lg overflow-hidden">
{/* Card Header */}
<div className="p-6 border-b border-box-border">
<h2 className="text-[20px] font-bold text-foreground">Profile Information</h2>
<p className="text-xs text-foreground mt-0.5">
Update your photo and personal details.
</p>
</div>
{/* Card Body & Form */}
<form onSubmit={handleSaveChanges} className="p-6 space-y-6">
{/* Profile Photo Row */}
<div className="flex items-center gap-5 py-2">
{/* Avatar dengan badge edit */}
<div className="relative flex-shrink-0">
<div className="w-20 h-20 rounded-xl border-2 border-red-100 overflow-hidden bg-gray-100 flex items-center justify-center shadow-sm">
{photoPreview && !imageError ? (
<img
src={photoPreview}
alt="Profile"
className="w-full h-full object-cover"
onError={() => setImageError(true)}
/>
) : (
<span className="text-3xl font-black text-gray-900">{initialLetter}</span>
)}
</div>
{/* Badge edit icon */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute -bottom-1.5 -right-1.5 w-6 h-6 bg-[#B30000] rounded-full flex items-center justify-center shadow border-2 border-white hover:bg-[#990000] transition-colors"
title="Edit photo"
>
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828a2 2 0 01-1.414.586H9v-2a2 2 0 01.586-1.414z" />
</svg>
</button>
</div>
{/* Info & Action Buttons */}
<div>
<h3 className="text-sm font-bold text-gray-900 mb-0.5">Profile Photo</h3>
<p className="text-xs text-gray-400 mb-3">
JPG, GIF or PNG. Recommended size 800x800px. Max size of 800K.
</p>
{photoFile && (
<p className="text-xs text-blue-500 mb-2 font-medium">
📎 {photoFile.name}
</p>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
>
Upload New
</button>
<button
type="button"
onClick={handleRemovePhoto}
className="px-4 py-1.5 bg-white border border-box-border text-gray-600 hover:bg-gray-50 text-xs font-bold rounded-lg transition-colors"
>
Remove
</button>
</div>
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/png, image/jpeg, image/gif"
className="hidden"
onChange={handlePhotoUpload}
/>
</div>
<hr className="border-t border-dashed border-box-border" />
{/* Grid Inputs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
Full Name
</label>
<input
type="text"
name="fullName"
value={formData.fullName}
onChange={handleChange}
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
Email Address
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
/>
</div>
</div>
{/* Professional Bio */}
<div className="space-y-1">
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
Professional Bio
</label>
<textarea
name="bio"
rows={4}
value={formData.bio}
onChange={handleChange}
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium resize-none"
/>
<p className="text-[11px] text-gray-500 font-normal pt-0.5">
Brief description for your profile. URLs and @mentions are allowed.
</p>
</div>
{/* Feedback Messages
{successMsg && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded-md font-medium">
{successMsg}
</p>
)}
{errorMsg && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-md font-medium">
{errorMsg}
</p>
)} */}
{/* Footer Action */}
<div className="flex justify-end pt-2">
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-xs rounded-lg shadow-sm transition-colors uppercase tracking-wider disabled:opacity-60"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -22,9 +22,9 @@ export default function MentorForumTab({ project }: { project: Project }) {
)}
{!loading && tasks.length === 0 ? (
<div className='flex justify-center items-center flex-col mt-5'>
<FileText className='text-font mb-3' size={48} />
<p className="text-md text-font-shade">Belum ada tugas yang dapat dikerjakan.</p>
<div className='flex justify-center items-center flex-col mt-12'>
<FileText className='text-font mb-6' size={48} />
<p className="text-md text-font">Belum ada tugas yang dapat dikerjakan.</p>
</div>
) : (
!loading && (
@@ -4,12 +4,16 @@ import ManageInternsModal from './components/AddInternsDialog';
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
import { useRemoveMember } from '../../../hooks/useProjects';
import { customToast } from '../../utils/showToast';
import { resolveFileUrl, getInitials } from '../../../hooks/useUser';
export default function InternsTab({ project }: { project: ProjectDetail }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [members, setMembers] = useState<ProjectMember[]>(project.members);
const { remove, loading } = useRemoveMember();
const isProjectActive = project?.status === "active"
const [adminImageError, setAdminImageError] = useState(false);
const adminAvatar = resolveFileUrl(project.admin.profile_picture);
const adminHasPhoto = !!adminAvatar && !adminImageError;
const handleModalClose = (finalMembers: ProjectMember[]) => {
setMembers(finalMembers);
@@ -34,15 +38,20 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
Mentor
</h3>
<div className="border-b border-gray-300 pb-4">
<div className="flex items-center gap-3">
{project.admin.profile_picture ? (
<div className="flex items-center gap-3 ">
{adminHasPhoto ? (
<img
src={project.admin.profile_picture}
src={adminAvatar}
alt={project.admin.full_name}
className="w-8 h-8 rounded-full object-cover"
className="w-[35px] h-[35px] object-cover rounded-full"
onError={() => setAdminImageError(true)}
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-200" />
<div className="w-full h-full bg-[#B30000] flex items-center justify-center">
<span className="text-white text-[10px] font-bold tracking-wide">
{getInitials(project.admin.full_name)}
</span>
</div>
)}
<span className=" text-sm text-[#333]">
{project.admin.full_name}
@@ -64,41 +73,41 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
}`}
>
<span className='flex items-center gap-3 text-[11px]'>
<Plus className='w-3 h-3'/>Add
<Plus className='w-3 h-3' />Add
</span>
</button>
</div>
{members.length === 0 ? (
<div className='flex flex-col items-center justify-center py-16'>
<Users className='w-12 h-12 text-font-shade mx-auto'/>
<p className="text-sm text-font-shade mt-3">Belum ada anggota.</p>
<Users className='w-12 h-12 text-gray-300 mx-auto' />
<p className="text-sm text-font mt-3">Belum ada anggota.</p>
</div>
) : (
members.map((member: ProjectMember, idx) => (
<div
key={idx}
className={`flex items-center justify-between py-3 ${idx < members.length - 1 ? 'border-b border-gray-300' : ''
}`}
>
<div className="flex items-center gap-3">
<span className=" text-sm text-font">
{member.email}
<div
key={idx}
className={`flex items-center justify-between py-3 ${idx < members.length - 1 ? 'border-b border-gray-300' : ''
}`}
>
<div className="flex items-center gap-3">
<span className=" text-sm text-font">
{member.email}
</span>
</div>
<span className=" text-[13px] text-font font-semibold">
{member.position}
</span>
<div>
<button
onClick={() => handleRemove(member.id)}
disabled={loading || !isProjectActive}
className='flex items-center gap-1 text-[11px] bg-red-600 text-white py-1 px-2 rounded-md disabled:opacity-50 hover:bg-red-700 transition-colors disabled:cursor-not-allowed'
>
<Plus className='w-4 h-4' />Delete
</button>
</div>
</div>
<span className=" text-[13px] text-font font-semibold">
{member.position}
</span>
<div>
<button
onClick={() => handleRemove(member.id)}
disabled={loading || !isProjectActive}
className='flex items-center gap-1 text-[11px] bg-red-600 text-white py-1 px-2 rounded-md disabled:opacity-50 hover:bg-red-700 transition-colors disabled:cursor-not-allowed'
>
<Plus className='w-4 h-4'/>Delete
</button>
</div>
</div>
)))}
)))}
</div>
{isModalOpen && <ManageInternsModal
isOpen={isModalOpen}
+11 -5
View File
@@ -4,15 +4,21 @@ import { useProjectDetail } from '../../../hooks/useProjects';
import MentorForumTab from './ProjectForumTab';
import InternsTab from './ProjectsInternTab';
import TaskTab from './TaskTab';
import { Loader2 } from 'lucide-react';
export default function ProjectDetailPage() {
const { slug } = useParams<{ slug: string }>();
const [activeTab, setActiveTab] = useState<'forum' | 'participants' | 'Task'>('forum');
const { project, loading, error } = useProjectDetail(slug ?? "");
if (loading) return <p className="p-10 text-gray-400">Memuat project...</p>;
if (error) return <p className="p-10 text-red-500">{error}</p>;
if (!project) return <p className="p-10 text-gray-400">Project tidak ditemukan.</p>
if (loading) return (
<p className="p-10 flex items-center gap-2 text-[18px] text-gray-400">
<Loader2 className="w-4 h-4 animate-spin" />
Memuat project...
</p>
);
if (error) return <p className="p-10 text-[18px] text-red-700">{error}</p>;
if (!project) {
return (
@@ -30,7 +36,7 @@ export default function ProjectDetailPage() {
return (
<div className="bg-gray-50 min-h-screen">
<div className="">
<div>
{/* Tabs */}
<div className="flex mb-4 border-b-1 border-gray-300">
@@ -57,7 +63,7 @@ export default function ProjectDetailPage() {
default:
return null;
}
})()}
})()}
</div>
</div>
);
+57 -11
View File
@@ -30,13 +30,14 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
}
};
if (loading) return (
<div className="flex flex-col gap-3 p-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
);
// if (loading) return (
// <div className="flex flex-col gap-3 p-6">
// {[...Array(3)].map((_, i) => (
// <div key={i} className="h-12 bg-gray-300 rounded-lg animate-pulse" />
// ))}
// </div>
// );
if (error) return <p className="text-red-500 text-sm p-6">{error}</p>;
return (
@@ -54,7 +55,52 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
<hr className="border-gray-200/80 mb-4" />
<div className="flex flex-col">
{tasks.length > 0 ? (
{loading ? (
<div className="flex flex-col gap-3 py-4">
{[...Array(tasks.length)].map((_, i) => (
<div key={i} className="h-14 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
) : error ? (
<p className="text-red-500 text-sm py-4">{error}</p>
) : tasks.length > 0 ? (
tasks.map((task, idx) => (
<div
key={task.id}
className={`flex items-center justify-between py-4 group ${idx < tasks.length - 1 ? 'border-b border-gray-100' : ''
}`}
>
<div className="flex items-center gap-3 min-w-0">
<ClipboardList size={22} className="text-slate-600 shrink-0" />
<span className="text-base text-slate-700 font-medium truncate">{task.title}</span>
</div>
<div className="flex items-center gap-4 text-slate-600 shrink-0">
<button
onClick={() => { setSelectedTask(task); setEditModalOpen(true); }}
disabled={!isProjectActive}
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Pencil size={20} />
</button>
<button
onClick={() => handleDeleteTask(task.id, task.title)}
disabled={!isProjectActive}
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={20} />
</button>
</div>
</div>
))
) : (
<div className='flex flex-col items-center justify-center py-16'>
<ClipboardList className='w-12 h-12 text-font-shade mx-auto' />
<p className="text-sm text-font-shade mt-3">Belum ada tugas yang dibuat untuk proyek ini.</p>
</div>
)
}
{/* {tasks.length > 0 ? (
tasks.map((task, idx) => (
<div
key={task.id}
@@ -70,14 +116,14 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
<button
onClick={() => { setSelectedTask(task); setEditModalOpen(true); }}
disabled={!isProjectActive}
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Pencil size={20} />
</button>
<button
onClick={() => handleDeleteTask(task.id, task.title)}
disabled={!isProjectActive}
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={20} />
</button>
@@ -89,7 +135,7 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
<ClipboardList className='w-12 h-12 text-font-shade mx-auto'/>
<p className="text-sm text-font-shade mt-3">Belum ada tugas yang dibuat untuk proyek ini.</p>
</div>
)}
)} */}
</div>
{isModalOpen && (
@@ -17,7 +17,7 @@ export default function ViewInternSubmission() {
if (loading)
return (
<div className="space-y-4">
<div className="space-y-4 mt-10">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-32 bg-box-secondary rounded-2xl animate-pulse" />
))}
@@ -72,7 +72,7 @@ export default function CreateTaskModal({ isOpen, onClose, projectId, onSuccess
}
};
return (
<dialog ref={dialogRef} className="custom-dialog p-[1.5rem]">
<dialog ref={dialogRef} className="custom-dialog">
<div className="dialog-header">
<div>
<h2 className="text-[26px] font-bold text-[#B30000] tracking-tight">Create New Task</h2>
@@ -82,7 +82,7 @@ export default function CreateTaskModal({ isOpen, onClose, projectId, onSuccess
<X className="w-5 h-5 stroke-[2.5]" />
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 space-y-2 overflow-y-auto flex-1">
<form onSubmit={handleSubmit} className="p-6 space-y-2 overflow-y-auto flex-1 pt-7">
<div>
<label className="text-xs font-bold text-gray-700 tracking-wide">Task Title</label>
<input
@@ -8,12 +8,8 @@ import { RiFileList2Fill } from 'react-icons/ri';
function ProjectCardSkeleton() {
return (
<div className="flex items-center gap-4 border border-gray-200 rounded-lg px-4 py-3 bg-gray-50 animate-pulse">
<div className="flex items-center gap-4 border border-gray-200 rounded-lg px-4 py-3 bg-gray-100 animate-pulse">
<div className="w-10 h-10 rounded-lg bg-gray-300 flex-shrink-0" />
<div className="space-y-2">
<div className="h-4 bg-gray-300 rounded w-32" />
<div className="h-3 bg-gray-300 rounded w-40" />
</div>
</div>
);
}
@@ -29,13 +25,17 @@ const HomeMentorProject = () => {
closeDialog();
refetch();
}
return (
<div className='flex flex-col'>
<div className='flex flex-row justify-between mb-3 items-center'>
<h2 className="text-lg font-semibold text-gray-800">Projects</h2>
<button
className='bg-card p-1.5 rounded-lg font-bold hover:bg-card-hover border border-card-outline'
onClick={openDialog} >
onClick={openDialog}
disabled={loading}
>
<span className='text-sm flex flex-row px-1 justify-between items-center gap-3.5'>
<Plus className='w-4 h-4' />
Add
@@ -44,11 +44,7 @@ const HomeMentorProject = () => {
</div>
<div className="flex flex-col max-h-[160px] overflow-y-auto gap-3 pr-1">
{loading && (
<>
{[...Array(2)].map((_, i) => (
<ProjectCardSkeleton key={i} />
))}
</>
<ProjectCardSkeleton/>
)}
{error && <p className="text-sm text-red-500 px-5">{error}</p>}
@@ -27,7 +27,6 @@ export default function InternsTable({
const totalPages = Math.max(1, Math.ceil(interns.length / PAGE_SIZE));
// Clamp page bila filter mengubah jumlah hasil
const safePage = Math.min(currentPage, totalPages);
const startIndex = (safePage - 1) * PAGE_SIZE;
const paginatedInterns = interns.slice(startIndex, startIndex + PAGE_SIZE);
@@ -27,7 +27,7 @@ export default function MentorProjectCard({ onArchived, onCompleted, ...project
const Icon = getProjectIcon(project.project_icon);
/* ── Archive ─────────────────────────────────────────────── */
/* Archive */
const handleArchiveButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setShowArchiveDialog(true);
@@ -65,24 +65,6 @@ export default function MentorProjectCard({ onArchived, onCompleted, ...project
return (
<>
{/* Archive Confirmation Dialog */}
<ArchiveProjectDialog
isOpen={showArchiveDialog}
projectName={project.project_name}
loading={archiveLoading}
onConfirm={handleArchiveConfirm}
onClose={() => setShowArchiveDialog(false)}
/>
{/* Complete Confirmation Dialog */}
<CompleteProjectDialog
isOpen={showCompleteDialog}
projectName={project.project_name}
loading={completeLoading}
onConfirm={handleCompleteConfirm}
onClose={() => setShowCompleteDialog(false)}
/>
{/* Card */}
<div
onClick={handleCardClick}
@@ -151,6 +133,24 @@ export default function MentorProjectCard({ onArchived, onCompleted, ...project
</button>
</div>
</div>
{/* Archive Confirmation Dialog */}
<ArchiveProjectDialog
isOpen={showArchiveDialog}
projectName={project.project_name}
loading={archiveLoading}
onConfirm={handleArchiveConfirm}
onClose={() => setShowArchiveDialog(false)}
/>
{/* Complete Confirmation Dialog */}
<CompleteProjectDialog
isOpen={showCompleteDialog}
projectName={project.project_name}
loading={completeLoading}
onConfirm={handleCompleteConfirm}
onClose={() => setShowCompleteDialog(false)}
/>
</>
);
}
+82 -22
View File
@@ -1,31 +1,80 @@
// hooks/useUser.ts
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from "react";
import { loginUser, getProfile, updateProfile } from "../services/UserService";
import type { Mahasiswa } from "../types/project.types";
import { getMahasiswa } from "../services/MahasiswaService";
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
import { useRef } from "react";
// GET /auth-api/me — ambil data user yang sedang login
type UserStore = {
user: CurrentUser | null;
loading: boolean;
error: string | null;
};
let store: UserStore = { user: null, loading: true, error: null };
const listeners = new Set<() => void>();
const setStore = (patch: Partial<UserStore>) => {
store = { ...store, ...patch }; // objek baru -> getSnapshot berubah referensinya
listeners.forEach((l) => l());
};
const subscribe = (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
};
const getSnapshot = () => store;
// useUpdateProfile setelah PATCH sukses -> broadcast ke semua konsumen
export const setCurrentUser = (user: CurrentUser | null) => {
setStore({ user, loading: false, error: null });
};
// dipakai saat logout
export const clearCurrentUser = () => {
fetched = false;
inflight = null;
setStore({ user: null, loading: false, error: null });
};
let fetched = false;
let inflight: Promise<void> | null = null;
const fetchUser = (): Promise<void> => {
if (inflight) return inflight;
setStore({ loading: true, error: null });
inflight = getProfile()
.then((u) => {
fetched = true;
setStore({ user: u, loading: false, error: null });
})
.catch(() => {
setStore({ loading: false, error: "Gagal memuat data user." });
})
.finally(() => {
inflight = null;
});
return inflight;
};
export const useCurrentUser = () => {
const [user, setUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refetch = useCallback(() => {
setLoading(true);
setError(null);
getProfile()
.then(setUser)
.catch(() => setError("Gagal memuat data user."))
.finally(() => setLoading(false));
}, []);
const snap = useSyncExternalStore(subscribe, getSnapshot);
useEffect(() => {
refetch();
}, [refetch]);
if (!fetched && !inflight) fetchUser();
}, []);
return { user, loading, error, refetch };
const refetch = useCallback(() => {
fetched = false;
return fetchUser();
}, []);
return { user: snap.user, loading: snap.loading, error: snap.error, refetch };
};
// POST /auth-api/login
@@ -38,6 +87,7 @@ export const useLogin = () => {
setError(null);
try {
const data = await loginUser({ email, password });
clearCurrentUser(); // buang user lama agar tidak bocor antar-sesi
return data; // { token }
} catch (err: any) {
const status = err?.response?.status;
@@ -55,7 +105,14 @@ export const useLogin = () => {
return { login, loading, error, setError };
};
export const DEFAULT_AVATAR = "https://placehold.co/150";
export const getInitials = (fullName?: string | null): string => {
if (!fullName) return "U";
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "U";
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
};
export const resolveFileUrl = (path?: string | null): string | null => {
if (!path) return null;
@@ -84,6 +141,8 @@ export const useUpdateProfile = () => {
try {
const updated = await updateProfile(payload);
setCurrentUser(updated);
const msg = "Profile updated successfully!";
msgRef.current.success = msg;
setSuccessMsg(msg);
@@ -103,7 +162,10 @@ export const useUpdateProfile = () => {
message = "Sesi Anda telah berakhir. Silakan login kembali.";
break;
case 403:
message = "Anda tidak memiliki izin untuk mengubah profil ini.";
message = apiMessage || "Anda tidak memiliki izin untuk mengubah data ini.";
break;
case 409:
message = apiMessage || "Email sudah digunakan, atau nama sudah pernah diubah.";
break;
case 413:
message = "Ukuran file terlalu besar. Maksimal 2MB.";
@@ -120,8 +182,6 @@ export const useUpdateProfile = () => {
msgRef.current.error = message;
setError(message);
// WAJIB throw — kalau `return null`, toast selalu masuk cabang success.
throw new Error(message);
} finally {
setLoading(false);
+24 -26
View File
@@ -24,7 +24,7 @@
--color-card-hover: #d5d5d5;
--color-box-primary: #FFFFFF;
--color-box-secondary: #f3f4f6 ;
--color-box-secondary: #f3f4f6;
--color-box-border: #a9afbb;
--color-primary: #ad2b19;
@@ -42,26 +42,29 @@
--color-danger: #c62611;
}
.container {
.main-content {
flex: 1 1 0%;
min-width: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
padding-inline: calc(var(--spacing) * 14);
padding-block: calc(var(--spacing) * 12);
background-color: var(--color-background);
flex-direction: column;
min-height: 100vh;
}
.box {
background-color: #F9F9F9;
border-radius: 12px;
padding: 10px;
background-color: var(--color-box-primary);
border-radius: 20px;
border: 1px solid var(--color-border);
padding: 32px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.page-title {
font-size: var(--text-3xl) /* 1.875rem = 30px */;
line-height: var(--tw-leading, var(--text-3xl--line-height) /* calc(2.25 / 1.875) ≈ 1.2 */);
font-size: var(--text-3xl) ;
line-height: var(--tw-leading, var(--text-3xl--line-height));
font-weight: bold;
color: var(--color-gray-900);
--tw-tracking: var(--tracking-tight);
@@ -74,7 +77,6 @@
margin-top: calc(var(--spacing) * 1)
}
/* Selector utama untuk elemen dialog */
dialog.custom-dialog {
position: fixed;
top: 0;
@@ -82,7 +84,7 @@ dialog.custom-dialog {
bottom: 0;
left: 0;
margin: auto;
display: none; /* default tertutup: sembunyikan */
display: none;
flex-direction: column;
width: calc(100% - 2rem);
max-width: 540px;
@@ -94,22 +96,21 @@ dialog.custom-dialog {
}
dialog.custom-dialog[open] {
display: flex; /* hanya tampil saat terbuka */
display: flex;
animation: fadeInZoom 200ms ease-out forwards;
}
/* Pseudo-element untuk Backdrop (Latar belakang gelap) */
dialog.custom-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.4); /* bg-black/40 */
backdrop-filter: blur(4px); /* backdrop-blur-sm */
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
}
/* Keyframes untuk animasi Tailwind (animate-in fade-in zoom-in-95) */
@keyframes fadeInZoom {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
@@ -123,21 +124,18 @@ dialog.custom-dialog::backdrop {
padding-left: 1rem;
padding-right: 1.5rem;
padding-top: 1rem;
border-bottom: 1px solid #b3b8c0; /* border-gray-100 */
border-bottom: 1px solid #b3b8c0;
}
/* Breakpoint Medium (md:) - Aktif saat lebar layar >= 768px */
@media (min-width: 1024px) {
.dialog-header {
/* Perubahan padding untuk layar desktop (md:px-8 md:py-5) */
padding-left: 2rem; /* 32px */
padding-right: 2rem; /* 32px */
padding-top: 1.25rem; /* 20px */
padding-bottom: 1.25rem;/* 20px */
padding-left: 2rem;
padding-right: 2rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
}
.container {
padding-inline: calc(var(--spacing) * 14);
.main-content {
padding-block: calc(var(--spacing) * 10);
}
}
+27 -31
View File
@@ -15,49 +15,45 @@ export const loginUser = async (payload: {
// GET /auth-api/me
export const getProfile = async (): Promise<CurrentUser> => {
const res = await api.get("/auth-api/me");
return res.data.data;
return normalizeUser(res.data.data);
};
export const normalizeUser = (raw: any): CurrentUser => ({
...raw,
full_name:
raw.full_name ??
`${raw.nama_depan ?? ""} ${raw.nama_belakang ?? ""}`.trim(),
});
// Mengembalikan inisial nama dari pengguna (Huruf pertama Nama depan dan huruf kedua Nama belakang)
export function getInitials(fullName: string | undefined | null): string {
if (!fullName) return "U";
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "U";
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
// PATCH /auth-api/update-profile
export const updateProfile = async (
payload: UpdateProfilePayload,
): Promise<CurrentUser> => {
const parts = (payload.full_name ?? "").trim().split(/\s+/).filter(Boolean);
const namaDepan = parts[0] ?? "";
const namaBelakang = parts.slice(1).join(" ");
const formData = new FormData();
if (payload.profile_picture instanceof File) {
const formData = new FormData();
if (payload.full_name !== undefined) {
formData.append("nama_depan", namaDepan);
formData.append("nama_belakang", namaBelakang);
}
if (payload.email !== undefined) formData.append("email", payload.email);
if (payload.professional_bio !== undefined)
formData.append("professional_bio", payload.professional_bio);
formData.append("profile_picture", payload.profile_picture);
const res = await api.patch("/auth-api/update-profile", formData);
return res.data.data;
}
const body: Record<string, unknown> = {};
if (payload.full_name !== undefined) {
body.nama_depan = namaDepan;
body.nama_belakang = namaBelakang;
formData.append("full_name", payload.full_name.trim());
}
if (payload.email !== undefined) body.email = payload.email;
if (payload.email !== undefined) formData.append("email", payload.email.trim());
if (payload.professional_bio !== undefined) {
body.professional_bio = payload.professional_bio;
formData.append("professional_bio", payload.professional_bio);
}
if (payload.profile_picture instanceof File) {
formData.append("profile_picture", payload.profile_picture);
}
if (typeof payload.profile_picture === "string") {
body.profile_picture = payload.profile_picture;
}
const res = await api.patch("/auth-api/update-profile", body);
return res.data.data;
const res = await api.patch("/auth-api/update-profile", formData);
return normalizeUser(res.data.data);
};
export const getMahasiswa = async (): Promise<Mahasiswa[]> => {
+5 -1
View File
@@ -6,7 +6,11 @@ export interface CurrentUser {
email: string;
role: "mentor" | "intern" | "admin";
professional_bio?: string;
profile_picture?: string | null;
profile_picture?: string | null;
changes?: number;
kelompok_peminatan: string;
signature?: string;
}
export interface UpdateProfilePayload {