feat: refactor file URL resolution and enhance component imports
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user