[TEST DONE] Checking Sertification generation for mentor/admin and view on intern

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-03 20:18:52 +07:00
parent 508ffe5f84
commit 5b7ac30589
14 changed files with 189 additions and 99 deletions
+7 -1
View File
@@ -1,10 +1,16 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/sec2.png" /> <link rel="icon" type="image/svg+xml" href="/sec2.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Internify | Humic Engineering</title> <title>Internify | Humic Engineering</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Great+Vibes&family=Grenze:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2
View File
@@ -19,6 +19,7 @@
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"qrcode": "^1.5.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-froala-wysiwyg": "^4.5.2", "react-froala-wysiwyg": "^4.5.2",
@@ -34,6 +35,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/eslint-plugin": "^8.54.0",
+14 -14
View File
@@ -5,10 +5,10 @@ import CertificateAvailable from "./components/CertificateAvailabe";
import CertificateNotAvailable from "./components/CertificateNotAvailable"; import CertificateNotAvailable from "./components/CertificateNotAvailable";
import SertificateHistory from "./components/CertificateHistory"; import SertificateHistory from "./components/CertificateHistory";
import { useClaimCertificate } from "../../../hooks/useCertificates"; // import { useClaimCertificate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects"; import { useProjectDetail } from "../../../hooks/useProjects";
import { useProjectCertificates } from "../../../hooks/useCertificates"; import { useMyCertificates } from "../../../hooks/useCertificates";
import type { Project } from "../../../types/project.types"; import type { Project } from "../../../types/project.types";
export default function InternCertificateDetail () { export default function InternCertificateDetail () {
@@ -17,8 +17,8 @@ export default function InternCertificateDetail () {
const projectFromList = location.state?.project as Project | undefined; const projectFromList = location.state?.project as Project | undefined;
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? ""); const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
const { certificates, loading: certLoading, refetch: refetchCertificates } = useProjectCertificates(projectDetail?.id ?? 0); const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates();
const { claim, loading: claiming, error: claimError } = useClaimCertificate(); // const { claim, loading: claiming, error: claimError } = useClaimCertificate();
const myCertificateForProject = useMemo(() => { const myCertificateForProject = useMemo(() => {
if (!projectDetail) return null; if (!projectDetail) return null;
@@ -43,13 +43,13 @@ export default function InternCertificateDetail () {
const remainingTasks = project ? project.total_tasks - project.task_done : 0; const remainingTasks = project ? project.total_tasks - project.task_done : 0;
const isLoading = projectLoading || certLoading; const isLoading = projectLoading || certLoading;
const handleClaim = async () => { // const handleClaim = async () => {
if (!project?.id) return; // if (!project?.id) return;
const result = await claim(project.id); // const result = await claim(project.id);
if (result) { // if (result) {
refetchCertificates(); // refetchCertificates();
} // }
}; // };
if (isLoading) { if (isLoading) {
return ( return (
@@ -78,9 +78,9 @@ export default function InternCertificateDetail () {
progress={progress} progress={progress}
remainingTasks={remainingTasks} remainingTasks={remainingTasks}
allTasksDone={allTasksDone} allTasksDone={allTasksDone}
onClaim={handleClaim} // onClaim={handleClaim}
claiming={claiming} // claiming={claiming}
claimError={claimError} // claimError={claimError}
/> />
)} )}
</div> </div>
@@ -22,7 +22,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
useEffect(() => { useEffect(() => {
if (!templateUrl) return; if (!templateUrl) return;
generateCertificate(resolveImageUrl(templateUrl), certificate.user.full_name) generateCertificate(resolveImageUrl(templateUrl), certificate.user.full_name, certificate.project.project_name, certificate.certificate_no, certificate.uuid)
.then((blob) => { .then((blob) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
setPreviewUrl(url); setPreviewUrl(url);
@@ -1,12 +1,10 @@
import { useNavigate } from "react-router-dom";
import { History, Loader2, AlertCircle } from "lucide-react"; import { History, Loader2, AlertCircle } from "lucide-react";
import type { Certificate } from "../../../../types/certificate.types"; import type { Certificate } from "../../../../types/certificate.types";
import { useMyCertificates } from "../../../../hooks/useCertificates"; import { useMyCertificates } from "../../../../hooks/useCertificates";
export default function SertificateHistory() { export default function SertificateHistory() {
const navigate = useNavigate();
const { certificates, loading, error } = useMyCertificates(); const { certificates, loading, error } = useMyCertificates();
const displayed = certificates.slice(0, 2); const displayed = certificates.slice(0, 3);
return ( return (
<div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5"> <div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
@@ -56,15 +54,6 @@ export default function SertificateHistory() {
))} ))}
</div> </div>
)} )}
<div className="w-full my-3 flex justify-end">
<button
onClick={() => navigate('/intern/certificates/all')}
className="text-sm text-red-700 font-semibold hover:underline"
>
See more
</button>
</div>
</div> </div>
); );
} }
@@ -5,18 +5,18 @@ interface CertificateNotAvailableProps {
progress: number; progress: number;
remainingTasks: number; remainingTasks: number;
allTasksDone?: boolean; allTasksDone?: boolean;
onClaim: () => void; // onClaim: () => void;
claiming: boolean; // claiming: boolean;
claimError: string | null; // claimError: string | null;
} }
export default function CertificateNotAvailable({ export default function CertificateNotAvailable({
progress, progress,
remainingTasks, remainingTasks,
allTasksDone = false, allTasksDone = false,
onClaim, // onClaim,
claiming, // claiming,
claimError, // claimError,
}: CertificateNotAvailableProps) { }: CertificateNotAvailableProps) {
return ( return (
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center"> <div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
@@ -54,14 +54,14 @@ export default function CertificateNotAvailable({
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" /> <div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
</div> </div>
</div> </div>
<button {/* <button
onClick={onClaim} onClick={onClaim}
disabled={claiming} disabled={claiming}
className="mt-0 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200" className="mt-0 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200"
> >
{claiming ? "Memproses..." : "Klaim Sertifikat"} {claiming ? "Memproses..." : "Klaim Sertifikat"}
</button> </button>
{claimError && <p className="text-red-500 text-sm mt-2">{claimError}</p>} {claimError && <p className="text-red-500 text-sm mt-2">{claimError}</p>} */}
</> </>
) : ( ) : (
<> <>
@@ -65,7 +65,7 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
{/* Main nav */} {/* Main nav */}
<nav className="flex flex-col gap-0.5"> <nav className="flex flex-col gap-0.5">
{navItems.map(({ label, icon: Icon, path }) => { {navItems.map(({ label, icon: Icon, path }) => {
const isActive = location.pathname === path || location.pathname.startsWith(path + "/"); const isActive = location.pathname === path || location.pathname.startsWith(path + "/intern");
return ( return (
<button <button
key={path} key={path}
@@ -65,7 +65,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
if (ok) { setFiles([]); setIsEditing(false); } if (ok) { setFiles([]); setIsEditing(false); }
}; };
// Sudah ada submission dan tidak sedang edit → tampilkan status // Sudah ada submission dan tidak sedang edit -> tampilkan status
if (submission && !isEditing) { if (submission && !isEditing) {
return ( return (
<SubmitStatusTable <SubmitStatusTable
@@ -66,7 +66,7 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
{/* Main nav */} {/* Main nav */}
<nav className="flex flex-col gap-0.5"> <nav className="flex flex-col gap-0.5">
{navItems.map(({ label, icon: Icon, path }) => { {navItems.map(({ label, icon: Icon, path }) => {
const isActive = location.pathname === path || location.pathname.startsWith(path + "/"); const isActive = location.pathname === path || location.pathname.startsWith(path + "/mentor");
return ( return (
<button <button
key={path} key={path}
@@ -2,8 +2,9 @@
import { FileText, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react"; import { FileText, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react";
import type { TaskSubmissionData, DisplayStatus } from "../../../../types/task.types"; import type { TaskSubmissionData, DisplayStatus } from "../../../../types/task.types";
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
// ─── StatusBadge ──────────────────────────────────────────────────────────── // StatusBadge
export function StatusBadge({ status }: { status: DisplayStatus }) { export function StatusBadge({ status }: { status: DisplayStatus }) {
if (status === "submitted" || status === "done") if (status === "submitted" || status === "done")
@@ -30,7 +31,7 @@ export function StatusBadge({ status }: { status: DisplayStatus }) {
); );
} }
// ─── StatusTable ──────────────────────────────────────────────────────────── // StatusTable
interface StatusTableProps { interface StatusTableProps {
status: DisplayStatus; status: DisplayStatus;
@@ -65,13 +66,18 @@ export function StatusTable({ status, submission }: StatusTableProps) {
label: "File submission", label: "File submission",
value: hasSubmission && submission?.file_path value: hasSubmission && submission?.file_path
? ( ? (
<div className="flex items-center gap-2 text-sm text-gray-800"> <a
<FileText className="w-4 h-4 text-red-700 stroke-[2.5]" /> href={resolveImageUrl(submission.file_path)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
>
<FileText className="w-4 h-4 stroke-[2.5]" />
<span>{submission.file_path.split("/").pop()}</span> <span>{submission.file_path.split("/").pop()}</span>
</div> </a>
) )
: hasSubmission && submission?.url_link : hasSubmission && submission?.url_link
? ( ? (
<a <a
href={submission.url_link} href={submission.url_link}
target="_blank" target="_blank"
@@ -80,8 +86,8 @@ export function StatusTable({ status, submission }: StatusTableProps) {
> >
{submission.url_link} {submission.url_link}
</a> </a>
) )
: <span className="text-sm text-gray-400">-</span>, : <span className="text-sm text-gray-400">-</span>,
}, },
]; ];
+28 -12
View File
@@ -6,14 +6,17 @@ import { generateCertificate, resolveImageUrl } from "./SertificateGenerator";
// ── 1. Preview di tab baru ──────────────────────────────────────────────────── // ── 1. Preview di tab baru ────────────────────────────────────────────────────
export const previewCertificate = async ( export const previewCertificate = async (
cert: Certificate, cert: Certificate,
templateUrl: string templateUrl: string,
): Promise<void> => { ): Promise<void> => {
// Buka tab DULU sebelum await — supaya tidak diblokir popup blocker // Buka tab DULU sebelum await — supaya tidak diblokir popup blocker
const tab = window.open("", "_blank"); const tab = window.open("", "_blank");
const blob = await generateCertificate( const blob = await generateCertificate(
resolveImageUrl(templateUrl), resolveImageUrl(templateUrl),
cert.user.full_name cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
cert.uuid,
); );
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -68,12 +71,15 @@ export const previewCertificate = async (
// ── 2. Download PDF ─────────────────────────────────────────────────────────── // ── 2. Download PDF ───────────────────────────────────────────────────────────
export const downloadCertificatePdf = async ( export const downloadCertificatePdf = async (
cert: Certificate, cert: Certificate,
templateUrl: string templateUrl: string,
): Promise<void> => { ): Promise<void> => {
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`; const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
const blob = await generateCertificate( const blob = await generateCertificate(
resolveImageUrl(templateUrl), resolveImageUrl(templateUrl),
cert.user.full_name cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
cert.uuid,
); );
const dataUrl: string = await new Promise((resolve, reject) => { const dataUrl: string = await new Promise((resolve, reject) => {
@@ -91,7 +97,11 @@ export const downloadCertificatePdf = async (
}); });
const orientation = img.width > img.height ? "landscape" : "portrait"; const orientation = img.width > img.height ? "landscape" : "portrait";
const pdf = new jsPDF({ orientation, unit: "px", format: [img.width, img.height] }); const pdf = new jsPDF({
orientation,
unit: "px",
format: [img.width, img.height],
});
pdf.addImage(dataUrl, "PNG", 0, 0, img.width, img.height); pdf.addImage(dataUrl, "PNG", 0, 0, img.width, img.height);
pdf.save(`${fileName}.pdf`); pdf.save(`${fileName}.pdf`);
}; };
@@ -100,12 +110,15 @@ export const downloadCertificatePdf = async (
export const downloadCertificateImage = async ( export const downloadCertificateImage = async (
cert: Certificate, cert: Certificate,
templateUrl: string, templateUrl: string,
format: "png" | "jpg" = "png" format: "png" | "jpg" = "png",
): Promise<void> => { ): Promise<void> => {
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`; const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
const blob = await generateCertificate( const blob = await generateCertificate(
resolveImageUrl(templateUrl), resolveImageUrl(templateUrl),
cert.user.full_name cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
cert.uuid,
); );
let finalBlob = blob; let finalBlob = blob;
@@ -137,7 +150,7 @@ export const downloadCertificateImage = async (
canvas.toBlob( canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("toBlob gagal"))), (b) => (b ? resolve(b) : reject(new Error("toBlob gagal"))),
"image/jpeg", "image/jpeg",
0.95 0.95,
); );
}); });
} }
@@ -155,7 +168,7 @@ export const downloadAllCertificatesZip = async (
certificates: Certificate[], certificates: Certificate[],
templateUrl: string, templateUrl: string,
projectName: string, projectName: string,
onProgress?: (done: number, total: number) => void onProgress?: (done: number, total: number) => void,
): Promise<void> => { ): Promise<void> => {
const zip = new JSZip(); const zip = new JSZip();
const total = certificates.length; const total = certificates.length;
@@ -164,11 +177,14 @@ export const downloadAllCertificatesZip = async (
certificates.map(async (cert, i) => { certificates.map(async (cert, i) => {
const blob = await generateCertificate( const blob = await generateCertificate(
resolveImageUrl(templateUrl), resolveImageUrl(templateUrl),
cert.user.full_name cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
cert.uuid,
); );
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob); zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
onProgress?.(i + 1, total); onProgress?.(i + 1, total);
}) }),
); );
const zipBlob = await zip.generateAsync({ type: "blob" }); const zipBlob = await zip.generateAsync({ type: "blob" });
@@ -177,4 +193,4 @@ export const downloadAllCertificatesZip = async (
a.download = `Sertifikat - ${projectName}.zip`; a.download = `Sertifikat - ${projectName}.zip`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
}; };
+60 -11
View File
@@ -1,8 +1,12 @@
import api from "../../lib/api"; import api from "../../lib/api";
import QRCode from "qrcode";
// Flag supaya font hanya di-load sekali (tidak berulang tiap generate)
let fontsLoaded = false;
// src/utils/certificateGenerator.ts
export function resolveImageUrl(path: string): string { export function resolveImageUrl(path: string): string {
if (!path) return ""; if (!path) return "";
if (path.startsWith("data:")) return path; // untuk QR data URL
if (path.startsWith("http://") || path.startsWith("https://")) { if (path.startsWith("http://") || path.startsWith("https://")) {
return path; return path;
} }
@@ -11,7 +15,7 @@ export function resolveImageUrl(path: string): string {
return `${origin}${path.startsWith("/") ? "" : "/"}${path}`; return `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
} }
// ── Load Image dari URL ──────────────────────────────────────────────────────── // Load Image dari URL
export function loadImage(src: string): Promise<HTMLImageElement> { export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
@@ -22,29 +26,74 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
}); });
} }
// Load kedua font dari Google Fonts (hanya sekali)
export async function ensureFontsLoaded(): Promise<void> {
// Trigger & tunggu font yang sudah diload via <link> di index.html
await Promise.all([
document.fonts.load('105px "Great Vibes"'),
document.fonts.load('54px "Grenze"'),
]);
await document.fonts.ready;
}
// ── Generate satu sertifikat sebagai Blob ───────────────────────────────────── // ── Generate satu sertifikat sebagai Blob ─────────────────────────────────────
export async function generateCertificate( export async function generateCertificate(
templateUrl: string, templateUrl: string,
internName: string, internName: string,
projectName: string, projectName: string,
uuid: string, certificateNo: string,
barcodeUrl: string verifyUrl: string
): Promise<Blob> { ): Promise<Blob> {
const img = await loadImage(templateUrl); // Pastikan font siap sebelum menggambar teks
await ensureFontsLoaded();
const img = await loadImage(templateUrl);
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth; canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight; canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
// Tulis nama di tengah horizontal, posisi Y dari konstanta
ctx.font = `bold 48px Georgia, serif`;
ctx.fillStyle = "#800000";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.fillText(internName, canvas.width / 2, canvas.height * 0.55);
// Nama intern — besar (Great Vibes)
ctx.font = `105px "Great Vibes", cursive`;
ctx.fillStyle = "#800000";
ctx.fillText(internName, canvas.width * 0.57, canvas.height * 0.48);
// Nama intern — versi kecil di pojok bawah
ctx.font = `30px "Great Vibes", cursive`;
ctx.fillStyle = "#800000";
ctx.fillText(internName, canvas.width * 0.825, canvas.height * 0.895);
// Nama project (Grenze)
ctx.font = `400 54px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText(projectName, canvas.width * 0.57, canvas.height * 0.615);
// Certificate no — besar (Grenze)
ctx.font = `50px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText(certificateNo, canvas.width * 0.666, canvas.height * 0.305);
// Certificate no — kecil di pojok bawah
ctx.font = `20px "Grenze", serif`;
ctx.fillStyle = "#800000";
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
// QR code — kalau verifyUrl diisi
// if (verifyUrl) {
// const qrDataUrl = await QRCode.toDataURL(verifyUrl, { width: 200, margin: 1 });
// const qrImg = await loadImage(qrDataUrl);
// const qrSize = canvas.width * 0.08;
// ctx.drawImage(
// qrImg,
// canvas.width * 0.1 - qrSize / 2, // posisi X — sesuaikan
// canvas.height * 0.88 - qrSize / 2, // posisi Y — sesuaikan
// qrSize,
// qrSize
// );
// }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
+50 -28
View File
@@ -1,39 +1,61 @@
<!-- test-cert.html — buka langsung di browser -->
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&family=Grenze:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
</head>
<body> <body>
<canvas id="preview"></canvas> <canvas id="preview" style="max-width: 100%;"></canvas>
<script> <script>
// GANTI INI UNTUK MENGGUNAKAN TEMPLATE YANG BERBEDA async function draw() {
const templateUrl = "http://localhost:9000/uploads/template.png"; // ganti URL template // Tunggu semua font dari Google Fonts selesai load
await document.fonts.ready;
const img = new Image(); const img = new Image();
img.crossOrigin = "anonymous"; img.crossOrigin = "anonymous";
img.onload = () => { img.onload = () => {
const canvas = document.getElementById("preview"); const canvas = document.getElementById("preview");
canvas.width = img.naturalWidth; canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight; canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
// Nama // Nama intern — pakai Great Vibes (font kaligrafi/script)
ctx.font = "bold 48px Georgia, serif"; ctx.font = `105px "Great Vibes", cursive`;
ctx.fillStyle = "#800000"; ctx.fillStyle = "#800000";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.fillText("Rafi Athallah", canvas.width / 2, canvas.height * 0.55); ctx.fillText("Jonathan Kristina", canvas.width * 0.57, canvas.height * 0.48);
// Project // Nama intern — versi kecil
ctx.font = "24px Georgia, serif"; ctx.font = `30px "Great Vibes", cursive`;
ctx.fillStyle = "#444444"; ctx.fillStyle = "#800000";
ctx.fillText("Internify Platform Dev", canvas.width / 2, canvas.height * 0.65); ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Jonathan Kristina", canvas.width * 0.825, canvas.height * 0.895);
// UUID // Nama project — pakai Grenze
ctx.font = "16px monospace"; ctx.font = `400 54px "Grenze", serif`;
ctx.fillStyle = "#888888"; ctx.fillStyle = "#090909";
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width / 2, canvas.height * 0.88); ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615);
};
img.src = templateUrl; // Certificate no — monospace biasa
ctx.font = `50px Grenze`;
ctx.fillStyle = "#090909";
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.666, canvas.height * 0.305);
// Certificate no — monospace biasa
ctx.font = `20px Grenze`;
ctx.fillStyle = "#800000";
ctx.fillText("CERT/20260609/PRJ2/USR5",canvas.width * 0.825, canvas.height * 0.918);
};
img.src = "http://localhost:9000/uploads/1783080498359-420570422.png"; // ganti URL template kamu
}
draw();
</script> </script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -31,7 +31,7 @@
--color-primary-foreground: #ffffff; --color-primary-foreground: #ffffff;
--color-primary-hover: #84281c; --color-primary-hover: #84281c;
--color-red: #e82222; --color-red: #9f1e1e;
--color-red-foreground: #fef2f2; --color-red-foreground: #fef2f2;
--color-border: #a8a9aa; --color-border: #a8a9aa;