Merge pull request 'Features/visual improvements' (#7) from features/visual-improvements into main
Build and push / build (push) Successful in 1m5s

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-05-21 15:16:16 +00:00
17 changed files with 136 additions and 35 deletions
+2
View File
@@ -24,6 +24,8 @@ jobs:
with:
context: .
push: true
build-args: |
PB_PUBLIC_URL=${{ vars.PB_PUBLIC_URL }}
tags: |
docker.allmy.work/${{ gitea.repository }}:latest
docker.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
+3
View File
@@ -7,6 +7,8 @@ RUN yarn install --immutable
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
ARG PB_PUBLIC_URL
ENV PB_PUBLIC_URL=$PB_PUBLIC_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
@@ -22,6 +24,7 @@ RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+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' },
};
/**
+7 -7
View File
@@ -1,9 +1,10 @@
import type { NextConfig } from 'next';
/* PocketBase origin — used to allowlist remote images.
* PB_HOSTNAME and PB_PORT are server-only env vars; safe to read here. */
const pbHostname = process.env.PB_HOSTNAME ?? '127.0.0.1';
const pbPort = process.env.PB_PORT ?? '8090';
/* Public PocketBase host for the image optimizer allowlist.
* Derived from PB_PUBLIC_URL (e.g. https://cms.allmy.work) at BUILD time —
* remotePatterns is frozen into the build, so PB_PUBLIC_URL must be present
* during `next build` in CI (via build-arg), not just at runtime. */
const pbPublicHost = process.env.PB_PUBLIC_URL ? new URL(process.env.PB_PUBLIC_URL).hostname : '127.0.0.1';
const nextConfig: NextConfig = {
output: 'standalone',
@@ -11,9 +12,8 @@ const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: pbHostname,
port: pbPort,
protocol: 'https',
hostname: pbPublicHost,
pathname: '/api/files/**',
},
],
+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="M483.953 210.023c-12.558 3.37-26.493 11.531-155.686 92.422-49.373 30.867-48.34 29.98-55.566 40.446C263.928 355.486 170 572.083 170 579.711c0 9.934 6.365 15.611 40.083 36.543l113.023 69.361 119.56 73.619c59.866 37.075 56.081 36.011 79.305 21.464l269.398-165.863c18.923-11.53 33.545-21.819 35.61-24.835 7.053-10.289 11.526 1.419-75.177-197.084-19.611-45.058-24.428-53.573-33.546-60.846-10.493-8.16-177.018-111.581-188.715-117.08-8.946-4.257-13.935-5.322-25.805-5.854-8.085-.355-16.859 0-19.783.887zm69.156 60.491l90.487 56.411c27.352 17.03 49.028 31.576 48.168 32.286-1.72 1.774-29.245 18.981-120.592 75.215l-70.876 43.816-95.821-59.072-95.648-60.846c0-1.597 163.772-104.662 176.846-111.048 4.989-2.484 9.634-3.371 16.687-2.839 9.118.533 12.558 2.306 50.749 26.077zM386.584 450.569l96.509 59.604v115.483c0 91.535-.516 115.129-2.065 114.419-1.204-.355-22.019-12.95-46.103-27.851l-113.54-69.715-111.818-70.426c0-.709 10.321-24.835 22.88-53.395l39.222-89.761c8.774-20.755 16.687-37.785 17.375-37.785s44.556 26.786 97.54 59.427zm327.888-55.879c.688 2.128 14.278 33.882 30.449 70.602 45.588 104.308 46.62 106.969 45.071 108.388-.86.887-28.556 18.094-61.758 38.317L593.707 694.84l-75.176 45.767c-.688 0-.86-51.976-.688-115.66l.516-115.661 88.595-54.637 95.476-58.895c8.429-5.499 10.665-5.677 12.042-1.064z" fill="#041cf3"/></svg>

After

Width:  |  Height:  |  Size: 1.5 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';
@@ -5,7 +5,7 @@ export function buildFileUrl(
collectionId: string,
recordId: string,
filename: string,
baseUrl: string = process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090',
baseUrl: string = process.env.PB_PUBLIC_URL ?? 'http://127.0.0.1:8090',
): string {
return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
}
+51
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,9 @@
--duration-normal: 150ms;
--duration-slow: 350ms;
--duration-spring: 220ms;
--delay-normal: 200ms;
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
--slide-section-body-out: clamp(0.5rem, 1.5vw, 0.75rem);
}
@theme inline {
@@ -371,3 +375,50 @@
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(calc(-1 * var(--slide-section-body-out))) scale(0.98);
}
}
@keyframes section-body-in {
from {
opacity: 0;
transform: translateX(var(--slide-section-body-in)) scale(0.98);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
/* Keep footer above sliding section-body during view transitions */
.footer-vt {
view-transition-name: site-footer;
}
::view-transition-group(site-footer) {
z-index: 10;
}
+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="footer-vt 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 && (