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