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:
ilia 2026-03-24 23:00:25 -04:00
parent 77c050f4c1
commit 677f56f8fb
11 changed files with 220 additions and 203 deletions

View File

@ -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
View File

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

Binary file not shown.

View File

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

View File

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

View File

@ -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 && (

View 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;

View File

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

View File

@ -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>;

View File

@ -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:

View File

@ -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',