diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 626717f..8de12e7 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -31,10 +31,10 @@ jobs: - name: Type-check run: npm run typecheck - - name: Build static site + - name: Build standalone Next.js application run: npm run build - - name: Test static export + - name: Test standalone runtime run: npm test - name: Audit production dependencies diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 37d582d..ab114b0 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -49,9 +49,27 @@ Replace `deploy:deploy` and `/srv/portfolio` with the values used for `DEPLOY_USER` and `DEPLOY_PATH`. Add the public half of `DEPLOY_SSH_KEY` to the deployment user's `~/.ssh/authorized_keys`. -The container joins `caddy_proxy` under the `static-site` service name. Caddy -must also join that network and proxy to `static-site:80`. No host port is -published by this project. +The standalone Next.js container joins `caddy_proxy` under the `portfolio-app` +service name. Caddy must also join that network and proxy to +`portfolio-app:3000`. No host port is published by this project. + +A minimal Caddy route is: + +```caddyfile +portfolio.example.com { + reverse_proxy portfolio-app:3000 +} +``` + +Caddy forwards the browser's `Accept` header by default; keep that behavior so +Next.js can negotiate WebP responses. Do not replace the cache headers emitted +for `/_next/image`. The named `next-image-cache` volume persists `.next/cache` +between container replacements so optimized variants can be reused. + +The application expects immutable media URLs. When an admin or future upload +workflow replaces an image, write it to a UUID or content-versioned URL instead +of overwriting the previous object at the same URL. Next.js has no per-image +optimizer-cache invalidation API. ## Manual deployment @@ -64,3 +82,10 @@ docker compose --project-name portfolio up --detach --build --remove-orphans Old versioned release directories are deliberately retained for rollback. They can be removed manually after a newer release has been proven stable; keep the directory referenced by the `current` symlink. + +## Runtime verification + +The production image contains `.next/standalone`, `.next/static`, and `public`, +and runs the generated `server.js` with Node.js 22 on port 3000. CI smoke-tests +the English and Persian routes, a built static asset, and `/_next/image` with +WebP negotiation before deployment. diff --git a/Dockerfile b/Dockerfile index 89e562c..6ea8877 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,14 +8,25 @@ RUN npm ci COPY . . RUN npm run build -FROM nginx:alpine AS runtime +FROM node:22-alpine AS runtime -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /app/out/ /usr/share/nginx/html/ +WORKDIR /app +ENV NODE_ENV=production \ + HOSTNAME=0.0.0.0 \ + PORT=3000 -EXPOSE 80 +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://127.0.0.1/ || exit 1 +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache -CMD ["nginx", "-g", "daemon off;"] +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/ || exit 1 + +CMD ["node", "server.js"] diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 0000000..dd82fb8 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,36 @@ +# Performance baseline and targets + +The pre-change development build blocked the page behind a preload manifest of +76 images (about 6.49 MiB encoded). Under the recorded throttled mobile run the +loader remained visible for about 73 seconds and LCP was about 36 seconds. The +old initial client chunks were about 346 KiB gzip, including a Three.js chunk of +about 172 KiB gzip. + +Measured pre-change section heights used for intrinsic containment: + +| Viewport | Projects | Experience | Skills | Education | Milestones | Footer | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 390 px | 746 | 3776 | 3384 | 1010 | 3688 | 472 | +| 768 px | 727 | 2786 | 1877 | 1005 | 2338 | 495 | +| 1024 px | 745 | 2304 | 1391 | 568 | 1830 | 569 | +| 1440 px | 797 | 2081 | 1233 | 475 | 1665 | 613 | + +Cold-cache acceptance targets are initial JavaScript at or below 200 KiB gzip, +initial transfer at or below 500 KiB before scrolling, LCP at or below 2.5 s, +INP at or below 200 ms, and CLS at or below 0.1. Compare medians from three +mobile-profile runs; treat differences inside run-to-run variance as neutral. + +## Post-change measurements + +The production standalone build references 198,652 bytes (194.0 KiB) of +initial JavaScript after gzip. Its estimated English first-view transfer is +391,274 bytes (382.1 KiB), including compressed HTML, JavaScript, CSS, the hero +avatar, flags, and the two locale-relevant fonts. No font is force-preloaded; +the browser requests only the faces used by the active locale. The deferred +Three.js chunk is 131,036 bytes gzip and is not part of the initial script set. + +Three fresh 390 x 844 production-browser runs measured LCP at 1,048 ms, +1,296 ms, and 1,432 ms (1,296 ms median), with CLS 0 in every run. These local +runs validate the build and relative improvement but are not a substitute for +field data or a repeatable hosted-network/mobile-CPU lab profile. INP should be +tracked in production once real interaction samples are available. diff --git a/compose.yaml b/compose.yaml index c2e1de1..74b07c4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,11 +1,19 @@ services: - static-site: + portfolio-app: image: amiirkhl-portfolio:latest build: context: . dockerfile: Dockerfile - container_name: static-site + container_name: portfolio-app restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"] + interval: 30s + timeout: 3s + start_period: 10s + retries: 3 + volumes: + - next-image-cache:/app/.next/cache networks: - caddy_proxy @@ -13,3 +21,6 @@ networks: caddy_proxy: external: true name: caddy_proxy + +volumes: + next-image-cache: diff --git a/eslint.config.mjs b/eslint.config.mjs index be57f57..f4b6232 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,5 +5,5 @@ import nextTypeScript from 'eslint-config-next/typescript'; export default defineConfig([ ...nextVitals, ...nextTypeScript, - globalIgnores(['.next/**', 'out/**', 'node_modules/**', 'next-env.d.ts']), + globalIgnores(['.next/**', 'node_modules/**', 'next-env.d.ts']), ]); diff --git a/next.config.mjs b/next.config.mjs index 05c1535..1a67b9f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,8 +1,15 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - output: 'export', + output: 'standalone', + turbopack: { + root: process.cwd(), + }, images: { - unoptimized: true, + formats: ['image/webp'], + qualities: [60, 75, 85], + deviceSizes: [360, 390, 640, 768, 1024, 1280, 1440], + imageSizes: [32, 48, 64, 72, 96, 128, 192, 256, 320], + minimumCacheTTL: 86_400, }, }; diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 2a3d94d..0000000 --- a/nginx.conf +++ /dev/null @@ -1,41 +0,0 @@ -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - charset utf-8; - server_tokens off; - - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types - application/javascript - application/json - application/xml - image/svg+xml - text/css - text/plain; - - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - - location ^~ /_next/static/ { - try_files $uri =404; - add_header Cache-Control "public, max-age=31536000, immutable"; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - access_log off; - } - - location / { - try_files $uri $uri.html $uri/ =404; - } - - error_page 404 /404.html; - - location = /404.html { - internal; - } -} diff --git a/package-lock.json b/package-lock.json index c6549c5..87245c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "next": "^16.3.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "sharp": "0.35.4", "three": "^0.180.0" }, "devDependencies": { @@ -614,7 +615,6 @@ "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -2737,7 +2737,6 @@ "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -5406,7 +5405,6 @@ "version": "7.8.5", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5469,7 +5467,6 @@ "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/sharp/-/sharp-0.35.4.tgz", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", diff --git a/package.json b/package.json index 3107072..e158d7e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "next": "^16.3.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "sharp": "0.35.4", "three": "^0.180.0" }, "devDependencies": { diff --git a/public/fonts/IRANYekanXVF.ttf b/public/fonts/IRANYekanXVF.ttf deleted file mode 100644 index c73c150..0000000 Binary files a/public/fonts/IRANYekanXVF.ttf and /dev/null differ diff --git a/public/fonts/IRANYekanXVF.woff2 b/public/fonts/IRANYekanXVF.woff2 new file mode 100644 index 0000000..1e430a2 Binary files /dev/null and b/public/fonts/IRANYekanXVF.woff2 differ diff --git a/public/fonts/Pacifico-LatinExt.woff2 b/public/fonts/Pacifico-LatinExt.woff2 new file mode 100644 index 0000000..aa42e0b Binary files /dev/null and b/public/fonts/Pacifico-LatinExt.woff2 differ diff --git a/public/fonts/Pacifico-OFL.txt b/public/fonts/Pacifico-OFL.txt new file mode 100644 index 0000000..b8d0374 --- /dev/null +++ b/public/fonts/Pacifico-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Pacifico Project Authors (https://github.com/googlefonts/Pacifico) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/scripts/deploy-release.sh b/scripts/deploy-release.sh index e128355..5afa366 100644 --- a/scripts/deploy-release.sh +++ b/scripts/deploy-release.sh @@ -64,9 +64,9 @@ wait_until_healthy() { local directory="$1" local container_id status - container_id="$(compose "$directory" ps -q static-site)" + container_id="$(compose "$directory" ps -q portfolio-app)" if [[ -z "$container_id" ]]; then - echo "The static-site container was not created." >&2 + echo "The portfolio-app container was not created." >&2 return 1 fi diff --git a/src/app/(en)/loading.tsx b/src/app/(en)/loading.tsx new file mode 100644 index 0000000..9706c32 --- /dev/null +++ b/src/app/(en)/loading.tsx @@ -0,0 +1,5 @@ +import { PortfolioSkeleton } from '@/components/skeletons/PortfolioSkeleton'; + +export default function Loading() { + return ; +} diff --git a/src/app/(fa)/fa/loading.tsx b/src/app/(fa)/fa/loading.tsx new file mode 100644 index 0000000..9706c32 --- /dev/null +++ b/src/app/(fa)/fa/loading.tsx @@ -0,0 +1,5 @@ +import { PortfolioSkeleton } from '@/components/skeletons/PortfolioSkeleton'; + +export default function Loading() { + return ; +} diff --git a/src/app/PortfolioPage.tsx b/src/app/PortfolioPage.tsx index 9a0cbe5..b1da4a7 100644 --- a/src/app/PortfolioPage.tsx +++ b/src/app/PortfolioPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { Suspense } from 'react'; import { ScenicHeroJourney } from '@/components/ScenicHeroJourney'; import { ProjectsSection } from '@/components/ProjectsSection'; import { ExperienceSection } from '@/components/ExperienceSection'; @@ -7,21 +7,20 @@ import { EducationSection } from '@/components/EducationSection'; import { MilestonesSection } from '@/components/MilestonesSection'; import { FooterSection } from '@/components/FooterSection'; import { MotionController } from '@/components/MotionController'; -import { SiteLoader } from '@/components/SiteLoader'; +import { SectionSkeleton } from '@/components/skeletons/PortfolioSkeleton'; import type { Locale } from '@/i18n/content'; export function PortfolioPage({ locale }: { locale: Locale }) { return (
- - - - - - - + }> + }> + }> + }> + }> + }>
); } diff --git a/src/app/SiteRoot.tsx b/src/app/SiteRoot.tsx index 2f66e9e..ee22ec8 100644 --- a/src/app/SiteRoot.tsx +++ b/src/app/SiteRoot.tsx @@ -1,15 +1,45 @@ import type { ReactNode } from 'react'; +import localFont from 'next/font/local'; import { ThemeProvider } from '@/context/ThemeContext'; import type { Locale } from '@/i18n/content'; const themeScript = ` - document.documentElement.dataset.siteLoading = 'true'; try { const savedTheme = localStorage.getItem('portfolio-theme'); document.documentElement.dataset.theme = savedTheme === 'dark' ? 'dark' : 'light'; } catch {} `; +const iranYekan = localFont({ + src: '../../public/fonts/IRANYekanXVF.woff2', + variable: '--font-body', + weight: '100 1000', + style: 'normal', + display: 'swap', + preload: false, + fallback: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'], +}); + +const pacifico = localFont({ + src: '../../public/fonts/Pacifico-LatinExt.woff2', + variable: '--font-display', + weight: '400', + style: 'normal', + display: 'swap', + preload: false, + fallback: ['cursive'], +}); + +const peyda = localFont({ + src: '../../public/fonts/PeydaWeb-Black.woff2', + variable: '--font-display', + weight: '900', + style: 'normal', + display: 'swap', + preload: false, + fallback: ['sans-serif'], +}); + export function SiteRoot({ children, locale, @@ -26,11 +56,8 @@ export function SiteRoot({