diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 148b1e1..9b2b61b 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -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 }}
diff --git a/Dockerfile b/Dockerfile
index f9b39dc..cb57248 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
\ No newline at end of file
diff --git a/app/favicon.ico b/app/favicon.ico
deleted file mode 100644
index 718d6fe..0000000
Binary files a/app/favicon.ico and /dev/null differ
diff --git a/app/layout.tsx b/app/layout.tsx
index 3060eac..4ee2fdf 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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' },
};
/**
diff --git a/next.config.ts b/next.config.ts
index 486fb2f..a395085 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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/**',
},
],
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..c1c3c8c
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/file.svg b/public/file.svg
deleted file mode 100644
index 004145c..0000000
--- a/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
deleted file mode 100644
index 567f17b..0000000
--- a/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
deleted file mode 100644
index 5174b28..0000000
--- a/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
deleted file mode 100644
index 7705396..0000000
--- a/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
deleted file mode 100644
index b2b2a44..0000000
--- a/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/shared/api/client/client.test.ts b/src/shared/api/client/client.test.ts
new file mode 100644
index 0000000..a56005c
--- /dev/null
+++ b/src/shared/api/client/client.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/src/shared/api/client.ts b/src/shared/api/client/client.ts
similarity index 67%
rename from src/shared/api/client.ts
rename to src/shared/api/client/client.ts
index d936ff3..c3a7dd7 100644
--- a/src/shared/api/client.ts
+++ b/src/shared/api/client/client.ts
@@ -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(collection: string, options: PBFetchOptions = {}): Promise> {
- 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(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();
}
/**
diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts
index d860d06..6f88229 100644
--- a/src/shared/api/index.ts
+++ b/src/shared/api/index.ts
@@ -1,2 +1,2 @@
-export * from './client';
+export * from './client/client';
export * from './types';
diff --git a/src/shared/lib/utils/buildFileUrl/buildFileUrl.ts b/src/shared/lib/utils/buildFileUrl/buildFileUrl.ts
index 9ad792a..91f67a6 100644
--- a/src/shared/lib/utils/buildFileUrl/buildFileUrl.ts
+++ b/src/shared/lib/utils/buildFileUrl/buildFileUrl.ts
@@ -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}`;
}
diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css
index b091140..fbd5fec 100644
--- a/src/shared/styles/theme.css
+++ b/src/shared/styles/theme.css
@@ -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;
+}
diff --git a/src/widgets/Footer/ui/Footer/Footer.tsx b/src/widgets/Footer/ui/Footer/Footer.tsx
index 54f4e06..6f78a05 100644
--- a/src/widgets/Footer/ui/Footer/Footer.tsx
+++ b/src/widgets/Footer/ui/Footer/Footer.tsx
@@ -19,7 +19,7 @@ export async function Footer() {
const socials = contacts?.expand?.socials ?? [];
return (
-