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
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"