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: [],
|
||||
},
|
||||
},
|
||||
// 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 · <a
|
||||
footer: `Ilia Dobkin · Thornhill, Ontario, Canada · <a
|
||||
class="text-primary" href="https://git.levkin.ca"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
|
||||
13
global.d.ts
vendored
13
global.d.ts
vendored
@ -78,7 +78,10 @@ interface ExternalProjects {
|
||||
projects?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Optional thumbnail; omit for a text-focused card */
|
||||
imageUrl?: string;
|
||||
/** Primary language or stack (matches Gitea repo `language` or a custom label) */
|
||||
language?: string;
|
||||
link: string;
|
||||
}[];
|
||||
}
|
||||
@ -213,6 +216,11 @@ interface Resume {
|
||||
* Resume file url
|
||||
*/
|
||||
fileUrl?: string;
|
||||
|
||||
/**
|
||||
* Preview lines shown on the profile page
|
||||
*/
|
||||
previewLines?: Array<string>;
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
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 { 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<AvatarCardProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={resumeFileUrl}
|
||||
href={resolvePublicUrl(resumeFileUrl)}
|
||||
target="_blank"
|
||||
className="btn btn-outline btn-sm text-xs mt-6 opacity-50"
|
||||
download
|
||||
|
||||
@ -15,6 +15,8 @@ const ExternalProjectCard = ({
|
||||
loading: boolean;
|
||||
googleAnalyticId?: string;
|
||||
}) => {
|
||||
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',
|
||||
})}
|
||||
</h2>
|
||||
<div className="avatar w-full h-full">
|
||||
<div className="w-24 h-24 mask mask-squircle mx-auto">
|
||||
{showThumbnails ? (
|
||||
<div className="avatar w-full h-full">
|
||||
<div className="w-24 h-24 mask mask-squircle mx-auto">
|
||||
{skeleton({
|
||||
widthCls: 'w-full',
|
||||
heightCls: 'h-full',
|
||||
shape: '',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center mb-2">
|
||||
{skeleton({
|
||||
widthCls: 'w-full',
|
||||
heightCls: 'h-full',
|
||||
shape: '',
|
||||
widthCls: 'w-16',
|
||||
heightCls: 'h-5',
|
||||
shape: 'rounded-full',
|
||||
className: 'mx-auto',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
{skeleton({
|
||||
widthCls: 'w-full',
|
||||
@ -97,6 +110,13 @@ const ExternalProjectCard = ({
|
||||
<h2 className="font-medium text-center opacity-60 mb-2">
|
||||
{item.title}
|
||||
</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 && (
|
||||
<div className="avatar opacity-90">
|
||||
<div className="w-24 h-24 mask mask-squircle">
|
||||
|
||||
@ -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 && (
|
||||
<ExperienceCard
|
||||
loading={loading}
|
||||
experiences={sanitizedConfig.experiences}
|
||||
/>
|
||||
)}
|
||||
{sanitizedConfig.resume.fileUrl &&
|
||||
sanitizedConfig.resume.previewLines.length !== 0 && (
|
||||
<ResumeCard
|
||||
loading={loading}
|
||||
fileUrl={sanitizedConfig.resume.fileUrl}
|
||||
previewLines={sanitizedConfig.resume.previewLines}
|
||||
/>
|
||||
)}
|
||||
{sanitizedConfig.certifications.length !== 0 && (
|
||||
<CertificationCard
|
||||
loading={loading}
|
||||
|
||||
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,9 +57,13 @@ const ThemeChanger = ({
|
||||
<span className="text-base-content/50 capitalize text-sm">
|
||||
{loading
|
||||
? skeleton({ widthCls: 'w-16', heightCls: 'h-5' })
|
||||
: theme === themeConfig.defaultTheme
|
||||
? 'Default'
|
||||
: theme}
|
||||
: theme === 'light'
|
||||
? 'Light'
|
||||
: theme === 'dark'
|
||||
? 'Dark'
|
||||
: theme === themeConfig.defaultTheme
|
||||
? 'Default'
|
||||
: theme}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-0">
|
||||
@ -95,7 +99,13 @@ const ThemeChanger = ({
|
||||
className={`${theme === item ? 'active' : ''}`}
|
||||
>
|
||||
<span className="opacity-60 capitalize">
|
||||
{item === themeConfig.defaultTheme ? 'Default' : item}
|
||||
{item === 'light'
|
||||
? 'Light'
|
||||
: item === 'dark'
|
||||
? 'Dark'
|
||||
: item === themeConfig.defaultTheme
|
||||
? 'Default'
|
||||
: item}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@ -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<string>;
|
||||
}
|
||||
|
||||
export interface SanitizedExperience {
|
||||
@ -128,6 +130,7 @@ export interface SanitizedConfig {
|
||||
github: SanitizedGithub;
|
||||
projects: SanitizedProjects;
|
||||
seo: SanitizedSEO;
|
||||
location?: string;
|
||||
social: SanitizedSocial;
|
||||
resume: SanitizedResume;
|
||||
skills: Array<string>;
|
||||
|
||||
@ -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<string, never> => {
|
||||
@ -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:
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user