Compare commits

..

6 Commits

Author SHA1 Message Date
Ilia Mashkov 886cf4b5c4 feat: add spring slide animation for section content 2026-05-21 16:36:00 +03:00
Ilia Mashkov fc588f9e66 feat: set page title and description 2026-05-21 16:01:42 +03:00
Ilia Mashkov f08ee51332 chore: remove unused files 2026-05-21 15:58:48 +03:00
Ilia Mashkov 9ded41db3c feat: set the favicon 2026-05-21 15:58:11 +03:00
Ilia Mashkov 0697e9ad72 feat: add error handling and tests for client.ts 2026-05-21 15:57:43 +03:00
Ilia Mashkov caff3fe7e3 fix: align footer paddings with main ones 2026-05-21 15:55:47 +03:00
12 changed files with 112 additions and 27 deletions
+3 -2
View File
@@ -4,8 +4,9 @@ import { Footer } from '$widgets/Footer';
import './globals.css';
export const metadata: Metadata = {
title: 'Portfolio',
description: 'Portfolio',
title: 'Ilia Mashkov — Portfolio',
description: 'Portfolio of Ilia Mashkov, a frontend software engineer.',
icons: { icon: '/favicon.svg' },
};
/**
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" fill="none" xmlns:v="https://vecta.io/nano"><path d="M1000 500c0 276.142-223.858 500-500 500S0 776.142 0 500 223.858 0 500 0s500 223.858 500 500z" fill="#f4f0e8"/><path d="M548.399 188.863l5.08 2.98 5.31 3.14 10.86 6.36 37.64 22.31 24.31 14.34 22.14 13.01 21.68 12.74 27.59 16.33 11.89 7.04 17.82 10.6 10.15 5.97 2.29 1.32 4.34 2.48c12.01 6.94 19.11 15.63 22.82 29.08 2.58 8.48 6.64 16.47 10.39 24.48l13.44 29.44 1.19 2.65 15.58 35.08 8.47 18.57 10.13 22.46 9.25 20.24 9.32 20.38.9 1.99 14.33 32.09 11.12 24.38c5.32 11.45 7.13 21.43 2.9 33.66-4.87 11.08-12.12 15.85-22.4 21.67l-13.85 8.17-8.82 5.23-1.77 1.04-11.03 6.52-25.76 15.31-33.84 20.14-16.66 9.86-3.34 1.98-18.26 10.84-39.33 23.24-1.83 1.07-3.6 2.11-46.12 27.36-23.39 13.9-17.5 10.44-28.56 16.88-20.14 11.89c-18.05 10.75-34.67 20.4-56.43 15.11-9.12-2.9-17.13-7.74-25.31-12.63l-2.94-1.75-15.97-9.6-15.4-9.02c-7.83-4.5-15.55-9.17-23.25-13.88-10.2-6.23-20.48-12.28-30.85-18.22l-28.56-16.9-17.22-10.26-2.7-1.59-5.38-3.17-42.96-25.56-28.22-16.69-23.55-13.96-16.62-9.9-1.74-1.03-25.73-15.14-33.78-20.03-4.23-2.56c-11.94-7.23-18.46-13.09-22.47-26.69-1.71-8.23-.74-15.3 2.59-22.88.15-.37.15-.37.94-2.22 3.26-7.6 6.71-15.11 10.2-22.61l5.64-12.2 1.12-2.43 8.46-18.9 14.81-33.18 1.24-2.74 17.94-39.37 16.42-36.34 9.38-20.84 5.25-11.62 4.32-9.55 4.95-10.98 1.49-3.29c2.47-5.5 4.58-10.9 6.14-16.74 4.02-12.73 15.77-19.9 26.68-26.16l5.74-3.31 9.92-5.71 22.65-13.35 27.07-16.07 47.41-28 10.09-6 2.03-1.2 32.84-19.35 19.12-11.26 8.05-4.74 10.1-6.08c9.76-5.92 18.87-10.15 29.98-12.93l1.88-.54c22.28-4.83 45.18 1.99 64.12 13.29zm-91.34 25.69c-3.68 2.68-7.6 4.77-11.61 6.91-5.64 3.05-11.15 6.34-16.67 9.59l-3.84 2.25-27.29 16.28-25.52 14.9-25.46 15.06-30.61 18.02-19.66 11.55-2.62 1.54-14.1 8.34-9.84 5.74-2.07 1.2-5.37 3.08c-4.44 3.11-7.64 6.09-8.62 11.6.77 4.33 2.45 6.6 5.62 9.5 3.56 2.44 7.29 4.53 11.06 6.63l6.56 3.73 3.29 1.87c4.3 2.46 8.54 5.01 12.78 7.58l25.12 14.88 21.69 12.81 21.25 12.56 54.94 32.57 33.61 19.8 9.7 5.69 6.17 3.62 7.6 4.58c12.1 7.39 25.07 9.88 39.23 7.12 13.28-4.14 25.32-12.74 37.09-19.87 4.27-2.59 8.56-5.12 12.91-7.57 6.26-3.53 12.41-7.23 18.56-10.94l27.82-16.5 32.29-19.03 17.95-10.59 20.13-12 26.09-15.48 5.51-3.24 17.27-10.01 3.09-1.78 5.68-3.23c7.71-4.47 7.71-4.47 9.61-9.2.65-4.46.51-6.29-2.14-9.98-2.67-3.03-5.58-4.96-9.09-6.97l-1.79-1.04-5.73-3.26-3.97-2.29-7.81-4.47-12.41-7.24-3.95-2.32-9-5.32-5.36-3.17-2.74-1.62-22.04-12.95-30.97-18.37-51.53-30.48-22.52-13.16-5.62-3.29-15.63-9.27-2.33-1.39-4.01-2.46c-23.38-14.06-50.24-5.72-70.7 9.49zm-214.66 153.56a627.1 627.1 0 0 0-13.62 28.56l-1.83 4.07-5.55 12.37-1.67 3.72-1.6 3.58-1.59 3.55-3.17 7.09-12.47 27.56-22.07 49.01-18.69 41.38-3.11 6.81-1.99 4.35-.97 2.12-5.46 11.84-2.21 4.8-.97 2.04c-1.76 3.88-2.75 6.91-2.03 11.15 3.81 6.22 9.69 9.08 15.94 12.56l6.5 3.71 3.32 1.88 14.68 8.6 5.44 3.22 2.61 1.55 11.45 6.73 17.4 10.36 16.35 9.67 13.06 7.78 15.18 9 1.9 1.11 9.48 5.54 20.72 12.47c4.07 2.49 8.2 4.89 12.35 7.26l16.93 9.93 18.25 10.88 2.86 1.68 8.58 5.07 40.42 23.94 22.83 13.5 20.32 12.11 17.75 10.5 22.28 13.48 2.87 1.76 2.57 1.58c1.96 1.13 1.96 1.13 2.96 1.13v-292l-12-3c-12.22-4.97-23.51-12.32-34.79-19.09l-16.24-9.55-27.3-16.16-32.83-19.39-1.85-1.08-1.86-1.1-3.75-2.19-1.9-1.11-24.79-14.73-22.5-13.29-25.46-15.07-17.72-10.49c-6.31-3.72-12.56-7.53-18.74-11.46-2.27-1.29-2.27-1.29-4.27-1.29zm512.06.85l-2.2 1.36-2.47 1.51-2.64 1.65-5.52 3.39-2.73 1.69-9.44 5.74-1.71 1.02-32.06 18.86-54.01 31.92-37.08 21.85-24.87 14.77-22.59 13.3-13.69 8.06c-10.31 6.2-19.16 10.39-31.05 13.03v292l18.81-10.56 2.15-1.28 4.62-2.73 7.53-4.44 32.91-19.6 25.98-15.39 26.56-15.75 22.13-13.06 28.08-16.64 41.77-24.75 38.46-22.8 26.08-15.5 19.73-11.69 22.44-13.25 5.82-3.43 4.02-2.35 6.22-3.65 1.87-1.09c4.04-2.39 7.68-4.76 9.82-9.04.43-5.52-.56-9.03-2.96-13.98l-.94-1.96-3.04-6.25-2.09-4.36-4.12-8.59c-2.26-4.69-4.42-9.42-6.54-14.17l-1.04-2.34-5.92-13.33-2.91-6.58-1.52-3.42-13.69-30.18-15.17-33.71-18.88-41.69-10.04-22.18-.91-2.08-4.21-9.66-1.48-3.38-1.28-2.95c-1.64-2.85-3.07-3.71-6.2-2.34z" fill="#1334da"/></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PBHttpError } from '../error';
import { getCollection } from './client';
describe('getCollection', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe('when PocketBase is unreachable', () => {
it('returns an empty list instead of throwing', async () => {
vi.stubEnv('PB_URL', 'http://localhost:8090');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
const result = await getCollection('projects');
expect(result.items).toEqual([]);
expect(result.totalItems).toBe(0);
});
});
describe('when PocketBase returns an HTTP error', () => {
it('rethrows PBHttpError', async () => {
vi.stubEnv('PB_URL', 'http://localhost:8090');
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: vi.fn(),
}),
);
await expect(getCollection('projects')).rejects.toBeInstanceOf(PBHttpError);
});
});
});
@@ -1,13 +1,10 @@
import { PBHttpError } from './error';
import type { ListResponse } from './types';
import { PBHttpError } from '../error';
import type { ListResponse } from '../types';
/*
* Native fetch wrapper for PocketBase API requests.
*/
/* Required in production; falls back to localhost in development. */
const PB_URL = process.env.PB_URL ?? (process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8090' : undefined);
/**
* Options for PocketBase collection fetching.
*/
@@ -40,12 +37,15 @@ export type PBFetchOptions = {
* Fetch a list of records from a PocketBase collection.
*/
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
const { sort, filter, expand, tags, revalidate } = options;
/* Required in production; falls back to localhost in development. */
const pbUrl = process.env.PB_URL ?? (process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8090' : undefined);
if (!PB_URL) {
if (!pbUrl) {
throw new Error('PB_URL is required in production');
}
const { sort, filter, expand, tags, revalidate } = options;
const params = new URLSearchParams();
if (sort) {
params.set('sort', sort);
@@ -57,20 +57,28 @@ export async function getCollection<T>(collection: string, options: PBFetchOptio
params.set('expand', expand);
}
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
const url = `${pbUrl}/api/collections/${collection}/records?${params.toString()}`;
const res = await fetch(url, {
next: {
tags: tags ?? [],
revalidate: revalidate ?? 3600,
},
});
try {
const res = await fetch(url, {
next: {
tags: tags ?? [],
revalidate: revalidate ?? 3600,
},
});
if (!res.ok) {
throw new PBHttpError(res.status, collection, res.statusText);
if (!res.ok) {
throw new PBHttpError(res.status, collection, res.statusText);
}
return res.json();
} catch (err) {
if (err instanceof PBHttpError) {
throw err;
}
console.warn(`[getCollection] "${collection}" unreachable — returning empty list`, err);
return { items: [], page: 1, perPage: 0, totalItems: 0, totalPages: 0 };
}
return res.json();
}
/**
+1 -1
View File
@@ -1,2 +1,2 @@
export * from './client';
export * from './client/client';
export * from './types';
+40
View File
@@ -84,6 +84,7 @@
/* === GRID === */
--grid-gap: var(--space-3);
--section-content-width: 72rem;
/* === ANIMATION === */
--ease-default: ease;
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
@@ -93,6 +94,7 @@
--duration-normal: 150ms;
--duration-slow: 350ms;
--duration-spring: 220ms;
--delay-normal: 200ms;
}
@theme inline {
@@ -371,3 +373,41 @@
transform: translateY(0);
}
}
/* Section body slide-in from right */
::view-transition-old(section-body) {
animation-name: section-body-out;
animation-duration: var(--duration-normal);
animation-timing-function: var(--ease-default);
animation-fill-mode: both;
}
::view-transition-new(section-body) {
animation-name: section-body-in;
animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring);
animation-fill-mode: both;
animation-delay: var(--delay-normal);
}
@keyframes section-body-out {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(-12px) scale(0.98);
}
}
@keyframes section-body-in {
from {
opacity: 0;
transform: translateX(48px) scale(0.98);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ export async function Footer() {
const socials = contacts?.expand?.socials ?? [];
return (
<footer className="fixed bottom-0 left-0 right-0 z-50 h-footer md:h-footer-wide brutal-border-top bg-background px-8 lg:px-16 flex items-center">
<footer className="fixed bottom-0 left-0 right-0 z-50 h-footer md:h-footer-wide brutal-border-top bg-background px-4 sm:px-8 lg:px-16 flex items-center">
<div className="w-full flex flex-row justify-between gap-4">
<div className="flex flex-wrap items-center gap-6 sm:gap-4">
{email && (