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 ( -