Profile: location, light/dark themes, project cards, resume
- Configurable location (Thornhill) and footer; optional config location overrides GitHub - External projects: language badges, drop duplicate Gitea avatars; Gitea API note in config - Theme switcher limited to light/dark with clear labels; default light - Resume card, public resume.pdf, PWA PDF navigate fallback denylist - resolvePublicUrl for assets under Vite base Made-with: Cursor
This commit is contained in:
parent
77c050f4c1
commit
677f56f8fb
@ -22,105 +22,58 @@ const CONFIG = {
|
|||||||
projects: [],
|
projects: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Thumbnails: same Gitea owner avatar repeats on every card — omit imageUrl for cleaner layout.
|
||||||
|
// From Gitea: GET /api/v1/repos/{owner}/{repo} → description, language, stars_count, updated_at
|
||||||
|
// (browser fetch needs CORS on your Gitea instance; copy fields into config if needed.)
|
||||||
external: {
|
external: {
|
||||||
header: 'Projects',
|
header: 'Projects',
|
||||||
projects: [
|
projects: [
|
||||||
{
|
|
||||||
title: 'punimtag',
|
|
||||||
description:
|
|
||||||
'TypeScript project — recently updated.',
|
|
||||||
imageUrl:
|
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/punimtag',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'nanobot',
|
|
||||||
description:
|
|
||||||
'Python-based automation bot.',
|
|
||||||
imageUrl:
|
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/nanobot',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'atlas',
|
title: 'atlas',
|
||||||
description:
|
description:
|
||||||
'Python project for infrastructure and tooling.',
|
'Python project for infrastructure and tooling.',
|
||||||
imageUrl:
|
language: 'Python',
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/atlas',
|
link: 'https://git.levkin.ca/ilia/atlas',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'ansible',
|
title: 'ansible',
|
||||||
description:
|
description:
|
||||||
'Infrastructure as code — Ansible playbooks and roles for provisioning and configuration.',
|
'Infrastructure as code — Ansible playbooks and roles for provisioning and configuration.',
|
||||||
imageUrl:
|
language: 'Ansible',
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/ansible',
|
link: 'https://git.levkin.ca/ilia/ansible',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'POTE',
|
title: 'POTE',
|
||||||
description:
|
description: 'Python project.',
|
||||||
'Python project.',
|
language: 'Python',
|
||||||
imageUrl:
|
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/POTE',
|
link: 'https://git.levkin.ca/ilia/POTE',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'homelab-notes',
|
|
||||||
description:
|
|
||||||
'Documentation and notes for self-hosted homelab infrastructure.',
|
|
||||||
imageUrl:
|
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/homelab-notes',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'mirror_match',
|
title: 'mirror_match',
|
||||||
description:
|
description: 'TypeScript application.',
|
||||||
'TypeScript application.',
|
language: 'TypeScript',
|
||||||
imageUrl:
|
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/mirror_match',
|
link: 'https://git.levkin.ca/ilia/mirror_match',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'linkedout',
|
title: 'linkedout',
|
||||||
description:
|
description:
|
||||||
'JavaScript tool for LinkedIn-related automation.',
|
'JavaScript tool for LinkedIn-related automation.',
|
||||||
imageUrl:
|
language: 'JavaScript',
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/linkedout',
|
link: 'https://git.levkin.ca/ilia/linkedout',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'llm_council',
|
title: 'llm_council',
|
||||||
description:
|
description:
|
||||||
'Python project — LLM orchestration and evaluation.',
|
'Python project — LLM orchestration and evaluation.',
|
||||||
imageUrl:
|
language: 'Python',
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/llm_council',
|
link: 'https://git.levkin.ca/ilia/llm_council',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'crkl',
|
title: 'crkl',
|
||||||
description:
|
description: 'Kotlin application.',
|
||||||
'Kotlin application.',
|
language: 'Kotlin',
|
||||||
imageUrl:
|
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/crkl',
|
link: 'https://git.levkin.ca/ilia/crkl',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'dotfiles',
|
|
||||||
description:
|
|
||||||
'Personal dotfiles and shell configuration.',
|
|
||||||
imageUrl:
|
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/dotfiles',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'onboarding',
|
|
||||||
description:
|
|
||||||
'Shell-based onboarding and environment setup scripts.',
|
|
||||||
imageUrl:
|
|
||||||
'https://git.levkin.ca/avatars/1',
|
|
||||||
link: 'https://git.levkin.ca/ilia/onboarding',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -130,6 +83,7 @@ const CONFIG = {
|
|||||||
'Software Development Engineer in Test with deep experience in Cypress, Playwright, Selenium, CI/CD, and end-to-end test automation.',
|
'Software Development Engineer in Test with deep experience in Cypress, Playwright, Selenium, CI/CD, and end-to-end test automation.',
|
||||||
imageURL: '',
|
imageURL: '',
|
||||||
},
|
},
|
||||||
|
location: 'Thornhill, Ontario, Canada',
|
||||||
social: {
|
social: {
|
||||||
linkedin: 'idobkin',
|
linkedin: 'idobkin',
|
||||||
x: '',
|
x: '',
|
||||||
@ -149,11 +103,17 @@ const CONFIG = {
|
|||||||
discord: '',
|
discord: '',
|
||||||
telegram: '',
|
telegram: '',
|
||||||
website: 'https://git.levkin.ca',
|
website: 'https://git.levkin.ca',
|
||||||
phone: '',
|
phone: '647 987 2792',
|
||||||
email: 'idobkin@gmail.com',
|
email: 'idobkin@gmail.com',
|
||||||
},
|
},
|
||||||
resume: {
|
resume: {
|
||||||
fileUrl: '',
|
fileUrl: '/resume.pdf',
|
||||||
|
previewLines: [
|
||||||
|
'Driven software engineer with deep experience in product, platform, and industrial test automation systems, from audit and financial software to modern web delivery.',
|
||||||
|
'Seeking roles where I can provide testing guidance, strengthen development operations through continuous integration, and collaborate with teams to optimize product delivery.',
|
||||||
|
'',
|
||||||
|
'Core strengths: end-to-end test automation (Cypress, Playwright, Selenium), CI/CD integration, accessibility (AODA/WCAG), and frameworks built for team adoption.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
skills: [
|
skills: [
|
||||||
'Cypress',
|
'Cypress',
|
||||||
@ -191,85 +151,7 @@ const CONFIG = {
|
|||||||
'Jira',
|
'Jira',
|
||||||
'Git',
|
'Git',
|
||||||
],
|
],
|
||||||
experiences: [
|
experiences: [],
|
||||||
{
|
|
||||||
company: 'Niyasoft Canada Inc.',
|
|
||||||
position: 'Test Automation Engineer',
|
|
||||||
from: 'August 2023',
|
|
||||||
to: 'April 2026',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'RIOS Canada',
|
|
||||||
position: 'Software Development Engineer in Test',
|
|
||||||
from: 'June 2022',
|
|
||||||
to: 'July 2023',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'Attabotics',
|
|
||||||
position: 'QA Automation Developer',
|
|
||||||
from: 'September 2021',
|
|
||||||
to: 'May 2022',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'Industry Group',
|
|
||||||
position: 'Senior JavaScript Developer',
|
|
||||||
from: 'October 2020',
|
|
||||||
to: 'August 2021',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'Accountants Templates Inc.',
|
|
||||||
position: 'Senior Software Developer',
|
|
||||||
from: 'August 2019',
|
|
||||||
to: 'August 2020',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'MNP',
|
|
||||||
position: 'Senior Application Developer',
|
|
||||||
from: 'August 2017',
|
|
||||||
to: 'June 2019',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'CaseWare International Inc.',
|
|
||||||
position: 'Software Developer',
|
|
||||||
from: 'August 2006',
|
|
||||||
to: 'June 2017',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'ROLI Consulting',
|
|
||||||
position: 'Web/Application Developer',
|
|
||||||
from: 'January 2001',
|
|
||||||
to: 'July 2012',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'Kaboose Inc.',
|
|
||||||
position: 'Software Developer',
|
|
||||||
from: 'February 2006',
|
|
||||||
to: 'August 2006',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'Coutts Information Services',
|
|
||||||
position: 'Junior Java/J2EE Programmer',
|
|
||||||
from: 'September 2005',
|
|
||||||
to: 'February 2006',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: 'EDS / Scotiabank CRM Data Warehouse',
|
|
||||||
position: 'Co-op Student',
|
|
||||||
from: 'May 2005',
|
|
||||||
to: 'August 2005',
|
|
||||||
companyLink: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
certifications: [],
|
certifications: [],
|
||||||
educations: [],
|
educations: [],
|
||||||
publications: [],
|
publications: [],
|
||||||
@ -283,7 +165,7 @@ const CONFIG = {
|
|||||||
},
|
},
|
||||||
hotjar: { id: '', snippetVersion: 6 },
|
hotjar: { id: '', snippetVersion: 6 },
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
defaultTheme: 'nord',
|
defaultTheme: 'light',
|
||||||
|
|
||||||
disableSwitch: false,
|
disableSwitch: false,
|
||||||
|
|
||||||
@ -291,47 +173,10 @@ const CONFIG = {
|
|||||||
|
|
||||||
displayAvatarRing: true,
|
displayAvatarRing: true,
|
||||||
|
|
||||||
themes: [
|
themes: ['light', 'dark'],
|
||||||
'light',
|
|
||||||
'dark',
|
|
||||||
'cupcake',
|
|
||||||
'bumblebee',
|
|
||||||
'emerald',
|
|
||||||
'corporate',
|
|
||||||
'synthwave',
|
|
||||||
'retro',
|
|
||||||
'cyberpunk',
|
|
||||||
'valentine',
|
|
||||||
'halloween',
|
|
||||||
'garden',
|
|
||||||
'forest',
|
|
||||||
'aqua',
|
|
||||||
'lofi',
|
|
||||||
'pastel',
|
|
||||||
'fantasy',
|
|
||||||
'wireframe',
|
|
||||||
'black',
|
|
||||||
'luxury',
|
|
||||||
'dracula',
|
|
||||||
'cmyk',
|
|
||||||
'autumn',
|
|
||||||
'business',
|
|
||||||
'acid',
|
|
||||||
'lemonade',
|
|
||||||
'night',
|
|
||||||
'coffee',
|
|
||||||
'winter',
|
|
||||||
'dim',
|
|
||||||
'nord',
|
|
||||||
'sunset',
|
|
||||||
'caramellatte',
|
|
||||||
'abyss',
|
|
||||||
'silk',
|
|
||||||
'procyon',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
footer: `Ilia Dobkin · Toronto, Canada · <a
|
footer: `Ilia Dobkin · Thornhill, Ontario, Canada · <a
|
||||||
class="text-primary" href="https://git.levkin.ca"
|
class="text-primary" href="https://git.levkin.ca"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
|||||||
13
global.d.ts
vendored
13
global.d.ts
vendored
@ -78,7 +78,10 @@ interface ExternalProjects {
|
|||||||
projects?: {
|
projects?: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Optional thumbnail; omit for a text-focused card */
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
/** Primary language or stack (matches Gitea repo `language` or a custom label) */
|
||||||
|
language?: string;
|
||||||
link: string;
|
link: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
@ -213,6 +216,11 @@ interface Resume {
|
|||||||
* Resume file url
|
* Resume file url
|
||||||
*/
|
*/
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview lines shown on the profile page
|
||||||
|
*/
|
||||||
|
previewLines?: Array<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Experience {
|
interface Experience {
|
||||||
@ -332,6 +340,11 @@ interface Config {
|
|||||||
*/
|
*/
|
||||||
seo?: SEO;
|
seo?: SEO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile location shown under "Based in:" (overrides GitHub profile location when set)
|
||||||
|
*/
|
||||||
|
location?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Social links
|
* Social links
|
||||||
*/
|
*/
|
||||||
|
|||||||
BIN
public/resume.pdf
Normal file
BIN
public/resume.pdf
Normal file
Binary file not shown.
@ -1,6 +1,6 @@
|
|||||||
import { FALLBACK_IMAGE } from '../../constants';
|
import { FALLBACK_IMAGE } from '../../constants';
|
||||||
import { Profile } from '../../interfaces/profile';
|
import { Profile } from '../../interfaces/profile';
|
||||||
import { skeleton } from '../../utils';
|
import { resolvePublicUrl, skeleton } from '../../utils';
|
||||||
import LazyImage from '../lazy-image';
|
import LazyImage from '../lazy-image';
|
||||||
|
|
||||||
interface AvatarCardProps {
|
interface AvatarCardProps {
|
||||||
@ -83,7 +83,7 @@ const AvatarCard: React.FC<AvatarCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
href={resumeFileUrl}
|
href={resolvePublicUrl(resumeFileUrl)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="btn btn-outline btn-sm text-xs mt-6 opacity-50"
|
className="btn btn-outline btn-sm text-xs mt-6 opacity-50"
|
||||||
download
|
download
|
||||||
|
|||||||
@ -15,6 +15,8 @@ const ExternalProjectCard = ({
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
googleAnalyticId?: string;
|
googleAnalyticId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
const showThumbnails = externalProjects.some((p) => p.imageUrl);
|
||||||
|
|
||||||
const renderSkeleton = () => {
|
const renderSkeleton = () => {
|
||||||
const array = [];
|
const array = [];
|
||||||
for (let index = 0; index < externalProjects.length; index++) {
|
for (let index = 0; index < externalProjects.length; index++) {
|
||||||
@ -32,6 +34,7 @@ const ExternalProjectCard = ({
|
|||||||
className: 'mb-2 mx-auto',
|
className: 'mb-2 mx-auto',
|
||||||
})}
|
})}
|
||||||
</h2>
|
</h2>
|
||||||
|
{showThumbnails ? (
|
||||||
<div className="avatar w-full h-full">
|
<div className="avatar w-full h-full">
|
||||||
<div className="w-24 h-24 mask mask-squircle mx-auto">
|
<div className="w-24 h-24 mask mask-squircle mx-auto">
|
||||||
{skeleton({
|
{skeleton({
|
||||||
@ -41,6 +44,16 @@ const ExternalProjectCard = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
{skeleton({
|
||||||
|
widthCls: 'w-16',
|
||||||
|
heightCls: 'h-5',
|
||||||
|
shape: 'rounded-full',
|
||||||
|
className: 'mx-auto',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{skeleton({
|
{skeleton({
|
||||||
widthCls: 'w-full',
|
widthCls: 'w-full',
|
||||||
@ -97,6 +110,13 @@ const ExternalProjectCard = ({
|
|||||||
<h2 className="font-medium text-center opacity-60 mb-2">
|
<h2 className="font-medium text-center opacity-60 mb-2">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
{item.language && (
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<span className="badge badge-sm badge-outline opacity-80">
|
||||||
|
{item.language}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{item.imageUrl && (
|
{item.imageUrl && (
|
||||||
<div className="avatar opacity-90">
|
<div className="avatar opacity-90">
|
||||||
<div className="w-24 h-24 mask mask-squircle">
|
<div className="w-24 h-24 mask mask-squircle">
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import AvatarCard from './avatar-card';
|
|||||||
import { Profile } from '../interfaces/profile';
|
import { Profile } from '../interfaces/profile';
|
||||||
import DetailsCard from './details-card';
|
import DetailsCard from './details-card';
|
||||||
import SkillCard from './skill-card';
|
import SkillCard from './skill-card';
|
||||||
import ExperienceCard from './experience-card';
|
import ResumeCard from './resume-card';
|
||||||
import EducationCard from './education-card';
|
import EducationCard from './education-card';
|
||||||
import CertificationCard from './certification-card';
|
import CertificationCard from './certification-card';
|
||||||
import { GithubProject } from '../interfaces/github-project';
|
import { GithubProject } from '../interfaces/github-project';
|
||||||
@ -108,7 +108,7 @@ const GitProfile = ({ config }: { config: Config }) => {
|
|||||||
avatar: data.avatar_url,
|
avatar: data.avatar_url,
|
||||||
name: data.name || ' ',
|
name: data.name || ' ',
|
||||||
bio: data.bio || '',
|
bio: data.bio || '',
|
||||||
location: data.location || '',
|
location: sanitizedConfig.location || data.location || '',
|
||||||
company: data.company || '',
|
company: data.company || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -124,6 +124,7 @@ const GitProfile = ({ config }: { config: Config }) => {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
sanitizedConfig.github.username,
|
sanitizedConfig.github.username,
|
||||||
|
sanitizedConfig.location,
|
||||||
sanitizedConfig.projects.github.display,
|
sanitizedConfig.projects.github.display,
|
||||||
getGithubProjects,
|
getGithubProjects,
|
||||||
]);
|
]);
|
||||||
@ -217,10 +218,12 @@ const GitProfile = ({ config }: { config: Config }) => {
|
|||||||
skills={sanitizedConfig.skills}
|
skills={sanitizedConfig.skills}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sanitizedConfig.experiences.length !== 0 && (
|
{sanitizedConfig.resume.fileUrl &&
|
||||||
<ExperienceCard
|
sanitizedConfig.resume.previewLines.length !== 0 && (
|
||||||
|
<ResumeCard
|
||||||
loading={loading}
|
loading={loading}
|
||||||
experiences={sanitizedConfig.experiences}
|
fileUrl={sanitizedConfig.resume.fileUrl}
|
||||||
|
previewLines={sanitizedConfig.resume.previewLines}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sanitizedConfig.certifications.length !== 0 && (
|
{sanitizedConfig.certifications.length !== 0 && (
|
||||||
|
|||||||
104
src/components/resume-card/index.tsx
Normal file
104
src/components/resume-card/index.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { resolvePublicUrl, skeleton } from '../../utils';
|
||||||
|
|
||||||
|
interface ResumeCardProps {
|
||||||
|
loading: boolean;
|
||||||
|
fileUrl: string;
|
||||||
|
previewLines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResumeCard = ({
|
||||||
|
loading,
|
||||||
|
fileUrl,
|
||||||
|
previewLines,
|
||||||
|
}: ResumeCardProps) => {
|
||||||
|
const pdfPath = resolvePublicUrl(fileUrl);
|
||||||
|
const pdfAbsoluteUrl =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? new URL(pdfPath, window.location.origin).href
|
||||||
|
: pdfPath;
|
||||||
|
|
||||||
|
const openPdfInNewTab = useCallback(
|
||||||
|
async (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const url = new URL(resolvePublicUrl(fileUrl), window.location.origin).href;
|
||||||
|
const tab = window.open('about:blank', '_blank');
|
||||||
|
if (!tab) {
|
||||||
|
window.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
tab.opener = null;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(String(res.status));
|
||||||
|
}
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('text/html')) {
|
||||||
|
tab.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const typed =
|
||||||
|
blob.type && !blob.type.includes('text/html')
|
||||||
|
? blob
|
||||||
|
: new Blob([blob], { type: 'application/pdf' });
|
||||||
|
const blobUrl = URL.createObjectURL(typed);
|
||||||
|
tab.location.href = blobUrl;
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 300_000);
|
||||||
|
} catch {
|
||||||
|
tab.location.href = url;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fileUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card shadow-lg card-sm bg-base-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="mx-3">
|
||||||
|
<h5 className="card-title">
|
||||||
|
{loading ? (
|
||||||
|
skeleton({ widthCls: 'w-32', heightCls: 'h-8' })
|
||||||
|
) : (
|
||||||
|
<span className="text-base-content opacity-70">Resume</span>
|
||||||
|
)}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div className="text-base-content text-sm mt-2 mx-3">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{skeleton({ widthCls: 'w-full', heightCls: 'h-4', shape: 'rounded' })}
|
||||||
|
{skeleton({ widthCls: 'w-11/12', heightCls: 'h-4', shape: 'rounded' })}
|
||||||
|
{skeleton({ widthCls: 'w-10/12', heightCls: 'h-4', shape: 'rounded' })}
|
||||||
|
{skeleton({ widthCls: 'w-9/12', heightCls: 'h-4', shape: 'rounded' })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1 opacity-80 leading-relaxed">
|
||||||
|
{previewLines.map((line, i) => (
|
||||||
|
<p key={i}>{line}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={pdfAbsoluteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-outline btn-sm text-xs mt-4 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
|
onClick={openPdfInNewTab}
|
||||||
|
>
|
||||||
|
View Full Resume (PDF)
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResumeCard;
|
||||||
@ -57,6 +57,10 @@ const ThemeChanger = ({
|
|||||||
<span className="text-base-content/50 capitalize text-sm">
|
<span className="text-base-content/50 capitalize text-sm">
|
||||||
{loading
|
{loading
|
||||||
? skeleton({ widthCls: 'w-16', heightCls: 'h-5' })
|
? skeleton({ widthCls: 'w-16', heightCls: 'h-5' })
|
||||||
|
: theme === 'light'
|
||||||
|
? 'Light'
|
||||||
|
: theme === 'dark'
|
||||||
|
? 'Dark'
|
||||||
: theme === themeConfig.defaultTheme
|
: theme === themeConfig.defaultTheme
|
||||||
? 'Default'
|
? 'Default'
|
||||||
: theme}
|
: theme}
|
||||||
@ -95,7 +99,13 @@ const ThemeChanger = ({
|
|||||||
className={`${theme === item ? 'active' : ''}`}
|
className={`${theme === item ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="opacity-60 capitalize">
|
<span className="opacity-60 capitalize">
|
||||||
{item === themeConfig.defaultTheme ? 'Default' : item}
|
{item === 'light'
|
||||||
|
? 'Light'
|
||||||
|
: item === 'dark'
|
||||||
|
? 'Dark'
|
||||||
|
: item === themeConfig.defaultTheme
|
||||||
|
? 'Default'
|
||||||
|
: item}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export interface SanitizedExternalProject {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
language?: string;
|
||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +68,7 @@ export interface SanitizedSocial {
|
|||||||
|
|
||||||
export interface SanitizedResume {
|
export interface SanitizedResume {
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
|
previewLines: Array<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SanitizedExperience {
|
export interface SanitizedExperience {
|
||||||
@ -128,6 +130,7 @@ export interface SanitizedConfig {
|
|||||||
github: SanitizedGithub;
|
github: SanitizedGithub;
|
||||||
projects: SanitizedProjects;
|
projects: SanitizedProjects;
|
||||||
seo: SanitizedSEO;
|
seo: SanitizedSEO;
|
||||||
|
location?: string;
|
||||||
social: SanitizedSocial;
|
social: SanitizedSocial;
|
||||||
resume: SanitizedResume;
|
resume: SanitizedResume;
|
||||||
skills: Array<string>;
|
skills: Array<string>;
|
||||||
|
|||||||
@ -24,6 +24,20 @@ type Colors = {
|
|||||||
[key: string]: { color: string | null; url: string };
|
[key: string]: { color: string | null; url: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a public asset path for the current Vite base (e.g. GitHub Pages subpaths).
|
||||||
|
* Absolute http(s) URLs are returned unchanged.
|
||||||
|
*/
|
||||||
|
export const resolvePublicUrl = (path: string): string => {
|
||||||
|
if (/^https?:\/\//i.test(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
const base = import.meta.env.BASE_URL;
|
||||||
|
const withSlash = base.endsWith('/') ? base : `${base}/`;
|
||||||
|
const trimmed = path.replace(/^\/+/, '');
|
||||||
|
return `${withSlash}${trimmed}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const getSanitizedConfig = (
|
export const getSanitizedConfig = (
|
||||||
config: Config,
|
config: Config,
|
||||||
): SanitizedConfig | Record<string, never> => {
|
): SanitizedConfig | Record<string, never> => {
|
||||||
@ -61,6 +75,7 @@ export const getSanitizedConfig = (
|
|||||||
description: config?.seo?.description,
|
description: config?.seo?.description,
|
||||||
imageURL: config?.seo?.imageURL,
|
imageURL: config?.seo?.imageURL,
|
||||||
},
|
},
|
||||||
|
location: config?.location,
|
||||||
social: {
|
social: {
|
||||||
linkedin: config?.social?.linkedin,
|
linkedin: config?.social?.linkedin,
|
||||||
x: config?.social?.x,
|
x: config?.social?.x,
|
||||||
@ -85,6 +100,7 @@ export const getSanitizedConfig = (
|
|||||||
},
|
},
|
||||||
resume: {
|
resume: {
|
||||||
fileUrl: config?.resume?.fileUrl || '',
|
fileUrl: config?.resume?.fileUrl || '',
|
||||||
|
previewLines: config?.resume?.previewLines || [],
|
||||||
},
|
},
|
||||||
skills: config?.skills || [],
|
skills: config?.skills || [],
|
||||||
experiences:
|
experiences:
|
||||||
|
|||||||
@ -33,9 +33,12 @@ export default defineConfig({
|
|||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallback: undefined,
|
// Omitting navigateFallback can still let some hosts/SW setups treat PDF
|
||||||
|
// navigations like SPA routes; denylist keeps real files out of the fallback.
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
navigateFallbackDenylist: [/\.pdf$/i],
|
||||||
},
|
},
|
||||||
includeAssets: ['logo.png'],
|
includeAssets: ['logo.png', 'resume.pdf'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Portfolio',
|
name: 'Portfolio',
|
||||||
short_name: 'Portfolio',
|
short_name: 'Portfolio',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user