diff --git a/gitprofile.config.ts b/gitprofile.config.ts index 2720ded..c9661bc 100644 --- a/gitprofile.config.ts +++ b/gitprofile.config.ts @@ -22,105 +22,58 @@ const CONFIG = { 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: { header: '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', description: 'Python project for infrastructure and tooling.', - imageUrl: - 'https://git.levkin.ca/avatars/1', + language: 'Python', link: 'https://git.levkin.ca/ilia/atlas', }, { title: 'ansible', description: 'Infrastructure as code — Ansible playbooks and roles for provisioning and configuration.', - imageUrl: - 'https://git.levkin.ca/avatars/1', + language: 'Ansible', link: 'https://git.levkin.ca/ilia/ansible', }, { title: 'POTE', - description: - 'Python project.', - imageUrl: - 'https://git.levkin.ca/avatars/1', + description: 'Python project.', + language: 'Python', 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', - description: - 'TypeScript application.', - imageUrl: - 'https://git.levkin.ca/avatars/1', + description: 'TypeScript application.', + language: 'TypeScript', link: 'https://git.levkin.ca/ilia/mirror_match', }, { title: 'linkedout', description: 'JavaScript tool for LinkedIn-related automation.', - imageUrl: - 'https://git.levkin.ca/avatars/1', + language: 'JavaScript', link: 'https://git.levkin.ca/ilia/linkedout', }, { title: 'llm_council', description: 'Python project — LLM orchestration and evaluation.', - imageUrl: - 'https://git.levkin.ca/avatars/1', + language: 'Python', link: 'https://git.levkin.ca/ilia/llm_council', }, { title: 'crkl', - description: - 'Kotlin application.', - imageUrl: - 'https://git.levkin.ca/avatars/1', + description: 'Kotlin application.', + language: 'Kotlin', 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.', imageURL: '', }, + location: 'Thornhill, Ontario, Canada', social: { linkedin: 'idobkin', x: '', @@ -149,11 +103,17 @@ const CONFIG = { discord: '', telegram: '', website: 'https://git.levkin.ca', - phone: '', + phone: '647 987 2792', email: 'idobkin@gmail.com', }, 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: [ 'Cypress', @@ -191,85 +151,7 @@ const CONFIG = { 'Jira', 'Git', ], - 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: '', - }, - ], + experiences: [], certifications: [], educations: [], publications: [], @@ -283,7 +165,7 @@ const CONFIG = { }, hotjar: { id: '', snippetVersion: 6 }, themeConfig: { - defaultTheme: 'nord', + defaultTheme: 'light', disableSwitch: false, @@ -291,47 +173,10 @@ const CONFIG = { displayAvatarRing: true, - themes: [ - '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', - ], + themes: ['light', 'dark'], }, - footer: `Ilia Dobkin · Toronto, Canada · ; } interface Experience { @@ -332,6 +340,11 @@ interface Config { */ seo?: SEO; + /** + * Profile location shown under "Based in:" (overrides GitHub profile location when set) + */ + location?: string; + /** * Social links */ diff --git a/public/resume.pdf b/public/resume.pdf new file mode 100644 index 0000000..3e2e5dd Binary files /dev/null and b/public/resume.pdf differ diff --git a/src/components/avatar-card/index.tsx b/src/components/avatar-card/index.tsx index b61161f..f08c0ee 100644 --- a/src/components/avatar-card/index.tsx +++ b/src/components/avatar-card/index.tsx @@ -1,6 +1,6 @@ import { FALLBACK_IMAGE } from '../../constants'; import { Profile } from '../../interfaces/profile'; -import { skeleton } from '../../utils'; +import { resolvePublicUrl, skeleton } from '../../utils'; import LazyImage from '../lazy-image'; interface AvatarCardProps { @@ -83,7 +83,7 @@ const AvatarCard: React.FC = ({ ) : ( { + const showThumbnails = externalProjects.some((p) => p.imageUrl); + const renderSkeleton = () => { const array = []; for (let index = 0; index < externalProjects.length; index++) { @@ -32,15 +34,26 @@ const ExternalProjectCard = ({ className: 'mb-2 mx-auto', })} -
-
+ {showThumbnails ? ( +
+
+ {skeleton({ + widthCls: 'w-full', + heightCls: 'h-full', + shape: '', + })} +
+
+ ) : ( +
{skeleton({ - widthCls: 'w-full', - heightCls: 'h-full', - shape: '', + widthCls: 'w-16', + heightCls: 'h-5', + shape: 'rounded-full', + className: 'mx-auto', })}
-
+ )}
{skeleton({ widthCls: 'w-full', @@ -97,6 +110,13 @@ const ExternalProjectCard = ({

{item.title}

+ {item.language && ( +
+ + {item.language} + +
+ )} {item.imageUrl && (
diff --git a/src/components/gitprofile.tsx b/src/components/gitprofile.tsx index 426dcb9..485b6bd 100644 --- a/src/components/gitprofile.tsx +++ b/src/components/gitprofile.tsx @@ -19,7 +19,7 @@ import AvatarCard from './avatar-card'; import { Profile } from '../interfaces/profile'; import DetailsCard from './details-card'; import SkillCard from './skill-card'; -import ExperienceCard from './experience-card'; +import ResumeCard from './resume-card'; import EducationCard from './education-card'; import CertificationCard from './certification-card'; import { GithubProject } from '../interfaces/github-project'; @@ -108,7 +108,7 @@ const GitProfile = ({ config }: { config: Config }) => { avatar: data.avatar_url, name: data.name || ' ', bio: data.bio || '', - location: data.location || '', + location: sanitizedConfig.location || data.location || '', company: data.company || '', }); @@ -124,6 +124,7 @@ const GitProfile = ({ config }: { config: Config }) => { } }, [ sanitizedConfig.github.username, + sanitizedConfig.location, sanitizedConfig.projects.github.display, getGithubProjects, ]); @@ -217,12 +218,14 @@ const GitProfile = ({ config }: { config: Config }) => { skills={sanitizedConfig.skills} /> )} - {sanitizedConfig.experiences.length !== 0 && ( - - )} + {sanitizedConfig.resume.fileUrl && + sanitizedConfig.resume.previewLines.length !== 0 && ( + + )} {sanitizedConfig.certifications.length !== 0 && ( { + const pdfPath = resolvePublicUrl(fileUrl); + const pdfAbsoluteUrl = + typeof window !== 'undefined' + ? new URL(pdfPath, window.location.origin).href + : pdfPath; + + const openPdfInNewTab = useCallback( + async (e: React.MouseEvent) => { + 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 ( +
+ +
+ ); +}; + +export default ResumeCard; diff --git a/src/components/theme-changer/index.tsx b/src/components/theme-changer/index.tsx index 8289c33..6eb1d0f 100644 --- a/src/components/theme-changer/index.tsx +++ b/src/components/theme-changer/index.tsx @@ -57,9 +57,13 @@ const ThemeChanger = ({ {loading ? skeleton({ widthCls: 'w-16', heightCls: 'h-5' }) - : theme === themeConfig.defaultTheme - ? 'Default' - : theme} + : theme === 'light' + ? 'Light' + : theme === 'dark' + ? 'Dark' + : theme === themeConfig.defaultTheme + ? 'Default' + : theme}
@@ -95,7 +99,13 @@ const ThemeChanger = ({ className={`${theme === item ? 'active' : ''}`} > - {item === themeConfig.defaultTheme ? 'Default' : item} + {item === 'light' + ? 'Light' + : item === 'dark' + ? 'Dark' + : item === themeConfig.defaultTheme + ? 'Default' + : item} diff --git a/src/interfaces/sanitized-config.tsx b/src/interfaces/sanitized-config.tsx index aff8fd3..fc44279 100644 --- a/src/interfaces/sanitized-config.tsx +++ b/src/interfaces/sanitized-config.tsx @@ -23,6 +23,7 @@ export interface SanitizedExternalProject { title: string; description?: string; imageUrl?: string; + language?: string; link: string; } @@ -67,6 +68,7 @@ export interface SanitizedSocial { export interface SanitizedResume { fileUrl?: string; + previewLines: Array; } export interface SanitizedExperience { @@ -128,6 +130,7 @@ export interface SanitizedConfig { github: SanitizedGithub; projects: SanitizedProjects; seo: SanitizedSEO; + location?: string; social: SanitizedSocial; resume: SanitizedResume; skills: Array; diff --git a/src/utils/index.tsx b/src/utils/index.tsx index 4fd19c0..bc2c0e3 100644 --- a/src/utils/index.tsx +++ b/src/utils/index.tsx @@ -24,6 +24,20 @@ type Colors = { [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 = ( config: Config, ): SanitizedConfig | Record => { @@ -61,6 +75,7 @@ export const getSanitizedConfig = ( description: config?.seo?.description, imageURL: config?.seo?.imageURL, }, + location: config?.location, social: { linkedin: config?.social?.linkedin, x: config?.social?.x, @@ -85,6 +100,7 @@ export const getSanitizedConfig = ( }, resume: { fileUrl: config?.resume?.fileUrl || '', + previewLines: config?.resume?.previewLines || [], }, skills: config?.skills || [], experiences: diff --git a/vite.config.ts b/vite.config.ts index df0480f..39ae59d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,9 +33,12 @@ export default defineConfig({ VitePWA({ registerType: 'autoUpdate', 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: { name: 'Portfolio', short_name: 'Portfolio',