feat: refactor file URL resolution and enhance component imports

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-18 00:54:36 +07:00
parent 62aaf2bcba
commit e278db1988
19 changed files with 100 additions and 114 deletions
+2 -1
View File
@@ -2,7 +2,8 @@ import { Bell, Menu } from "lucide-react"
import { useEffect, useState } from "react";
import humiclogo from "../assets/logo.png";
import { useNavigate } from "react-router-dom";
import { useCurrentUser, resolveFileUrl, getInitials } from "../hooks/useUser";
import { useCurrentUser, getInitials } from "../hooks/useUser";
import { resolveFileUrl } from "../Pages/utils/resolveFileFromUrl";
import { useNotifications } from "../hooks/useNotification";
export default function Header({ toggleSidebar }: { toggleSidebar: () => void }) {
+2
View File
@@ -85,6 +85,7 @@ export default function InternifyLogin() {
type="email"
placeholder="Email Account"
value={email}
autoComplete="username"
onChange={(e) => setEmail(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
/>
@@ -101,6 +102,7 @@ export default function InternifyLogin() {
type={showPassword ? "text" : "password"}
placeholder="Account Password"
value={password}
autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-10 pr-10 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
/>
-23
View File
@@ -1,23 +0,0 @@
interface ProgressBarProps {
value: number;
total: number;
}
export default function ProgressBar({ value, total }: ProgressBarProps) {
const pct = total === 0 ? 0 : Math.round((value / total) * 100);
const isComplete = value === total;
return (
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
{value}/{total}
</span>
</div>
);
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from "react";
import { useCurrentUser, useUpdateProfile, resolveFileUrl, getInitials } from "../../hooks/useUser";
import { useCurrentUser, useUpdateProfile, getInitials } from "../../hooks/useUser";
import { customToast } from "../utils/showToast";
import { Pen } from "lucide-react";
import UpdateProfileDialog from "./UpdateProfileDialogue";
import { resolveFileUrl } from "../utils/resolveFileFromUrl";
export default function SettingsPage() {
const { user, loading: userLoading } = useCurrentUser();
@@ -1,7 +1,8 @@
import { Users } from 'lucide-react';
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
import { useState } from 'react';
import { resolveFileUrl, getInitials } from '../../../hooks/useUser';
import { getInitials } from '../../../hooks/useUser';
import { resolveFileUrl } from '../../utils/resolveFileFromUrl';
export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
const [ImageError, setImageError] = useState(false);
@@ -4,35 +4,35 @@ import { Download, Link, Printer } from "lucide-react";
import type { ProjectDetail } from "../../../../types/project.types";
import type { Certificate } from "../../../../types/certificate.types";
import { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates";
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
import { generateCertificate } from "../../../utils/SertificateGenerator";
import { resolveFileUrl } from "../../../utils/resolveFileFromUrl";
import { customToast } from "../../../utils/showToast";
interface CertificateAvailableProps {
project: ProjectDetail | null;
project: ProjectDetail;
certificate: Certificate;
templateUrl: string;
}
function formatDateIndo(date: string): string {
return new Intl.DateTimeFormat("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(date));
}
function formatDateRange(startDate: string, endDate: string): string {
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
}
export default function CertificateAvailable({ project, certificate, templateUrl }: CertificateAvailableProps) {
// const [copied, setCopied] = useState(false);
const [downloadingImg, setDownloadingImg] = useState(false);
const [downloadingPdf, setDownloadingPdf] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loadingPreview, setLoadingPreview] = useState(true);
const duration = project
? (() => {
const start = new Date(project.start_date);
const end = new Date(project.end_date);
const diffMs = end.getTime() - start.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(diffDays / 30);
const days = diffDays % 30;
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
if (months > 0) return `${months} Bulan`;
return `${diffDays} Hari`;
})()
: "-";
const duration = formatDateRange(project?.start_date, project?.end_date)
useEffect(() => {
@@ -40,12 +40,13 @@ export default function CertificateAvailable({ project, certificate, templateUrl
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
generateCertificate(
resolveImageUrl(templateUrl),
resolveFileUrl(templateUrl),
certificate.user.full_name,
certificate.project.project_name,
certificate.certificate_no,
duration,
verifyUrl,
duration)
)
.then((blob) => {
const url = URL.createObjectURL(blob);
setPreviewUrl(url);
@@ -82,7 +83,6 @@ export default function CertificateAvailable({ project, certificate, templateUrl
const handleShareLinkedIn = () => {
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
// Gunakan "Add Certification to Profile" LinkedIn — tidak butuh API key/approval
const params = new URLSearchParams({
startTask: "CERTIFICATION_NAME",
name: certificate.project.project_name,
@@ -109,7 +109,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
<img
src={previewUrl}
alt="Certificate"
className="rounded-xl w-[550px] h-[350px] object-contain"
className="rounded-xl object-contain border-2"
/>
) : (
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
@@ -1,7 +1,7 @@
import { useNavigate } from 'react-router-dom';
import { getProjectIcon } from '../../../../lib/ProjectIcons';
import type { Project } from '../../../../types/project.types';
import { resolveFileUrl } from '../../../../hooks/useUser';
import { resolveFileUrl } from '../../../utils/resolveFileFromUrl';
const ProjectCard = (project: Project) => {
const navigate = useNavigate();
@@ -1,6 +1,6 @@
import { useState } from "react";
import { FileText, FileUp, Link } from "lucide-react";
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
import { resolveFileUrl } from "../../../utils/resolveFileFromUrl";
interface SubmitStatusTableProps {
type: "file" | "link";
@@ -128,7 +128,7 @@ export default function SubmitStatusTable({
files?.map((f) => (
<a
key={f.id}
href={resolveImageUrl(f.file_path)}
href={resolveFileUrl(f.file_path) ?? undefined}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
@@ -159,8 +159,8 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
<div className="flex flex-col items-center justify-center h-full space-y-2 text-center py-6">
<div className="space-y-0.5">
{submission.files.map((f) => (
<div className="flex items-center gap-2 text-[11px]">
<FileText className="w-4 h-4 text-red-700 stroke-[2.5]" />
<div key={f.id} className="flex items-center gap-2 text-[11px]">
<FileText className="w-4 h-4 text-red-700" />
{f.original_name}
</div>
))}
@@ -5,7 +5,7 @@ import { useUploadCertificateTemplate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects";
import EligibleInternTable from "./components/EligibleInternTable";
import { useProjectInternProgress } from "../../../hooks/useInternProgress";
import { resolveImageUrl } from "../../utils/SertificateGenerator";
import { resolveFileUrl } from "../../utils/resolveFileFromUrl";
import { customToast } from "../../utils/showToast";
export default function CertificateDetail() {
@@ -140,7 +140,7 @@ export default function CertificateDetail() {
{previewUrl || project.certificate_template ? (
<>
<img
src={previewUrl || resolveImageUrl(project.certificate_template)}
src={previewUrl || resolveFileUrl(project.certificate_template)}
alt="Preview template"
className="w-full h-32 object-contain rounded-lg mb-2"
/>
@@ -4,7 +4,8 @@ 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';
import { getInitials } from '../../../hooks/useUser';
import { resolveFileUrl } from '../../utils/resolveFileFromUrl';
export default function InternsTab({ project }: { project: ProjectDetail }) {
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -39,7 +40,7 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
</h3>
<div className="border-b border-gray-300 pb-4">
<div className="flex items-center gap-3 ">
{adminHasPhoto ? (
{adminHasPhoto && !adminImageError ? (
<img
src={adminAvatar}
alt={project.admin.full_name}
@@ -13,10 +13,10 @@ import {
} from "lucide-react";
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
import JSZip from "jszip";
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
import { generateCertificate } from "../../../utils/SertificateGenerator";
import { resolveFileUrl } from "../../../utils/resolveFileFromUrl";
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
import Avatar from "../../Avatar";
import ProgressBar from "../../ProgressBar";
import { customToast } from "../../../utils/showToast";
import type { Project } from "../../../../types/project.types";
import { previewCertificate } from "../../../utils/Certificates";
@@ -43,6 +43,25 @@ function formatDateRange(startDate: string, endDate: string): string {
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
}
function ProgressBar({ value, total }: {value: number, total: number}) {
const pct = total === 0 ? 0 : Math.round((value / total) * 100);
const isComplete = value === total;
return (
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
{value}/{total}
</span>
</div>
);
}
function StatusBadge({ status }: { status: boolean }) {
return (
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${status ? "text-green-600" : "text-orange-500"}`}>
@@ -131,13 +150,15 @@ export default function EligibleInternTable({
setGenerating(true);
setGenerateError(null);
const ok = await customToast.promise(
const res = await customToast.promise(
(async () => {
const result = await SubmitCertificates(project.id, Array.from(selected));
if (!result.data) throw new Error("Tidak ada data sertifikat yang dikembalikan.");
const zip = new JSZip();
const templateUrl = resolveImageUrl(project.certificate_template!);
const templateUrl = resolveFileUrl(project.certificate_template!);
if (!templateUrl) throw new Error("Template sertifikat tidak ditemukan.");
const duration = formatDateRange(project.start_date, project.end_date);
await Promise.all(
@@ -168,7 +189,7 @@ export default function EligibleInternTable({
).then(() => true).catch(() => false);
setGenerating(false);
if (ok) navigate(`/mentor/certificates/${project.slug}/result`);
if (res) navigate(`/mentor/certificates/${project.slug}/result`);
};
if (loading) {
@@ -185,7 +206,7 @@ export default function EligibleInternTable({
return (
<div className="lg:col-span-5 flex flex-col gap-4">
{/* Roster Card */}
<div className="border border-box-border rounded-2xl overflow-hidden bg-white shadow-sm">
<div className="border border-box-border overflow-x-auto rounded-2xl bg-white shadow-sm">
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-100">
<div>
<h3 className="text-base font-bold text-gray-900">Intern Roster</h3>
@@ -253,7 +274,7 @@ export default function EligibleInternTable({
{/* Name + role */}
<div className="flex items-center gap-3">
{intern.avatar ? (
<img src={intern.avatar} className="w-10 h-10 rounded-full object-cover" />
<img src={intern.avatar} className="w-8 h-8 rounded-full object-cover" />
) : (
<Avatar initials={initials} />
)}
@@ -2,7 +2,7 @@
import { FileText, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react";
import type { TaskSubmissionData, DisplayStatus } from "../../../../types/task.types";
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
import { resolveFileUrl } from "../../../utils/resolveFileFromUrl";
// StatusBadge
@@ -66,7 +66,7 @@ export function StatusTable({ status, submission }: StatusTableProps) {
{submission.files.map((f) => (
<a
key={f.id}
href={resolveImageUrl(f.file_path)}
href={resolveFileUrl(f.file_path) ?? undefined}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
+10 -9
View File
@@ -1,7 +1,8 @@
import jsPDF from "jspdf";
import JSZip from "jszip";
import type { Certificate } from "../../types/certificate.types";
import { generateCertificate, resolveImageUrl } from "./SertificateGenerator";
import { generateCertificate } from "./SertificateGenerator";
import { resolveFileUrl } from "./resolveFileFromUrl";
function formatDateIndo(date: string): string {
return new Intl.DateTimeFormat("id-ID", {
@@ -14,7 +15,7 @@ function formatDateIndo(date: string): string {
function formatDateRange(startDate: string, endDate: string): string {
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
}
// 1. Preview di tab baru
// Preview di tab baru
export const previewCertificate = async (
cert: Certificate,
templateUrl: string,
@@ -22,7 +23,7 @@ export const previewCertificate = async (
const tab = window.open("", "_blank");
const blob = await generateCertificate(
resolveImageUrl(templateUrl),
resolveFileUrl(templateUrl),
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
@@ -79,14 +80,14 @@ export const previewCertificate = async (
tab.document.close();
};
// ── 2. Download PDF ───────────────────────────────────────────────────────────
// Download PDF
export const downloadCertificatePdf = async (
cert: Certificate,
templateUrl: string,
): Promise<void> => {
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
const blob = await generateCertificate(
resolveImageUrl(templateUrl),
resolveFileUrl(templateUrl),
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
@@ -118,7 +119,7 @@ export const downloadCertificatePdf = async (
pdf.save(`${fileName}.pdf`);
};
// ── 3. Download PNG atau JPG ──────────────────────────────────────────────────
// Download PNG atau JPG
export const downloadCertificateImage = async (
cert: Certificate,
templateUrl: string,
@@ -126,7 +127,7 @@ export const downloadCertificateImage = async (
): Promise<void> => {
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
const blob = await generateCertificate(
resolveImageUrl(templateUrl),
resolveFileUrl(templateUrl),
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
@@ -176,7 +177,7 @@ export const downloadCertificateImage = async (
URL.revokeObjectURL(url);
};
// ── 4. Download All ZIP ───────────────────────────────────────────────────────
// Download All ZIP
export const downloadAllCertificatesZip = async (
certificates: Certificate[],
templateUrl: string,
@@ -189,7 +190,7 @@ export const downloadAllCertificatesZip = async (
await Promise.all(
certificates.map(async (cert, i) => {
const blob = await generateCertificate(
resolveImageUrl(templateUrl),
resolveFileUrl(templateUrl),
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
+7 -19
View File
@@ -1,24 +1,13 @@
import api from "../../lib/api";
import { resolveFileUrl } from "./resolveFileFromUrl";
import QRCode from "qrcode";
export function resolveImageUrl(path: string): string {
if (!path) return "";
if (path.startsWith("data:")) return path;
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
const apiBaseURL = api.defaults.baseURL ?? "";
const origin = new URL(apiBaseURL).origin;
return `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
}
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`));
img.src = resolveImageUrl(src);
img.src = resolveFileUrl(src);
});
}
@@ -30,14 +19,13 @@ export async function ensureFontsLoaded(): Promise<void> {
await document.fonts.ready;
}
const LAYOUT = {
internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" },
internNameSmall: { x: 0.82, y: 0.889, size: 33, font: "Great Vibes", color: "#800000" },
internNameSmall: { x: 0.82, y: 0.889, size: 47, font: "Great Vibes", color: "#090909" },
projectName: { x: 0.815, y: 0.598, size: 46, font: "Grenze", color: "#800000" },
duration: { x: 0.692, y: 0.645, size: 48, font: "Grenze", color: "#090909" },
certNo: { x: 0.6, y: 0.28, size: 50, font: "Grenze", color: "#090909" },
certNoSmall: { x: 0.825, y: 0.91, size: 22, font: "Grenze", color: "#800000" },
duration: { x: 0.7, y: 0.645, size: 48, font: "Grenze", color: "#090909" },
certNo: { x: 0.597, y: 0.305, size: 50, font: "Grenze", color: "#090909" },
certNoSmall: { x: 0.827, y: 0.92, size: 32, font: "Grenze", color: "#090909"},
qr: { x: 0.77, y: 0.715, size: 0.103 },
};
@@ -74,7 +62,7 @@ export async function generateCertificate(
text(LAYOUT.projectName, projectName);
text(LAYOUT.duration, ProjectDuration);
text(LAYOUT.certNo, certificateNo);
text(LAYOUT.certNoSmall, certificateNo);
text(LAYOUT.certNoSmall, "No.2026/SPI3/" + certificateNo);
// QR code — nanti akan diganti ke alamat domain
const qrSize = Math.round(canvas.width * LAYOUT.qr.size);
+8
View File
@@ -0,0 +1,8 @@
import {API_BASE_URL} from "../../lib/api";
export function resolveFileUrl(path?: string | null): string {
if (!path) return "";
if (/^(https?:|data:|blob:)/i.test(path)) return path;
return `${API_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
-12
View File
@@ -1,12 +0,0 @@
// Ubah message backend (string ATAU object field-errors) jadi satu string aman
export function resolveErrorMessage(message: unknown, fallback = "Silakan coba lagi."): string {
if (typeof message === "string") return message;
if (message && typeof message === "object") {
// ambil semua value error, gabung: { end_date: "...", start_date: "..." } -> "... \n ..."
return Object.values(message as Record<string, unknown>)
.flat()
.filter((v): v is string => typeof v === "string")
.join(" ") || fallback;
}
return fallback;
}
+1 -4
View File
@@ -12,9 +12,7 @@
<script>
const TEMPLATE_PATH = "../../assets/template.png";
// ====== KOORDINAT (rasio terhadap ukuran template) ======
// fillText -> titik TENGAH teks (textAlign=center, textBaseline=middle)
// drawImage -> pojok KIRI-ATAS gambar
// LAYOUT
const LAYOUT = {
internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" },
internNameSmall: { x: 0.82, y: 0.889, size: 33, font: "Great Vibes", color: "#800000" },
@@ -25,7 +23,6 @@
qr: { x: 0.77, y: 0.715, size: 0.103 },
};
// ====== DATA DUMMY ======
const DATA = {
internName: "Jonathan Kristina",
projectName: "Internify Proyek Humic",
+6 -6
View File
@@ -109,12 +109,12 @@ export const getInitials = (fullName?: string | null): string => {
};
// Untuk resolve gambar (duplikat dari utils/...)
export const resolveFileUrl = (path?: string | null): string | null => {
if (!path) return null;
if (/^(https?:|data:|blob:)/i.test(path)) return path;
const base = import.meta.env.VITE_API_BASE_URL as string;
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
};
// export const resolveFileUrl = (path?: string | null): string | null => {
// if (!path) return null;
// if (/^(https?:|data:|blob:)/i.test(path)) return path;
// const base = import.meta.env.VITE_API_BASE_URL as string;
// return `${base}${path.startsWith("/") ? path : `/${path}`}`;
// };
// PATCH /auth-api/update-profile
export const useUpdateProfile = () => {