Compare commits
2 Commits
8f03f8e9ca
...
b4426400f3
| Author | SHA1 | Date | |
|---|---|---|---|
| b4426400f3 | |||
| 9a778653cf |
@@ -2,6 +2,8 @@ name: Test and deploy
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -38,16 +40,16 @@ jobs:
|
|||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Audit production dependencies
|
- name: Audit production dependencies
|
||||||
run: npm audit --omit=dev --audit-level=high
|
run: npm audit --audit-level=high
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy production
|
name: Deploy production
|
||||||
needs: verify
|
needs: verify
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
environment: production
|
|
||||||
env:
|
env:
|
||||||
|
RELEASE_ID: ${{ gitea.sha }}
|
||||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||||
@@ -67,8 +69,6 @@ jobs:
|
|||||||
: "${DEPLOY_PATH:?DEPLOY_PATH secret is required}"
|
: "${DEPLOY_PATH:?DEPLOY_PATH secret is required}"
|
||||||
: "${DEPLOY_KNOWN_HOSTS:?DEPLOY_KNOWN_HOSTS secret is required}"
|
: "${DEPLOY_KNOWN_HOSTS:?DEPLOY_KNOWN_HOSTS secret is required}"
|
||||||
: "${SSH_PRIVATE_KEY:?DEPLOY_SSH_KEY secret is required}"
|
: "${SSH_PRIVATE_KEY:?DEPLOY_SSH_KEY secret is required}"
|
||||||
DEPLOY_PORT="${DEPLOY_PORT:-22}"
|
|
||||||
echo "DEPLOY_PORT=$DEPLOY_PORT" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
install -d -m 700 "$HOME/.ssh"
|
install -d -m 700 "$HOME/.ssh"
|
||||||
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/deploy_key"
|
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/deploy_key"
|
||||||
@@ -82,14 +82,15 @@ jobs:
|
|||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
git archive \
|
git archive \
|
||||||
--format=tar.gz \
|
--format=tar.gz \
|
||||||
--output="$RUNNER_TEMP/portfolio-$GITHUB_SHA.tar.gz" \
|
--output="$RUNNER_TEMP/portfolio-$RELEASE_ID.tar.gz" \
|
||||||
HEAD
|
HEAD
|
||||||
|
|
||||||
- name: Upload and activate release
|
- name: Upload and activate release
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
remote_archive="/tmp/portfolio-$GITHUB_SHA.tar.gz"
|
DEPLOY_PORT="${DEPLOY_PORT:-22}"
|
||||||
|
remote_archive="/tmp/portfolio-$RELEASE_ID.tar.gz"
|
||||||
ssh_options=(
|
ssh_options=(
|
||||||
-i "$HOME/.ssh/deploy_key"
|
-i "$HOME/.ssh/deploy_key"
|
||||||
-o BatchMode=yes
|
-o BatchMode=yes
|
||||||
@@ -102,12 +103,12 @@ jobs:
|
|||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
-o StrictHostKeyChecking=yes \
|
-o StrictHostKeyChecking=yes \
|
||||||
-P "$DEPLOY_PORT" \
|
-P "$DEPLOY_PORT" \
|
||||||
"$RUNNER_TEMP/portfolio-$GITHUB_SHA.tar.gz" \
|
"$RUNNER_TEMP/portfolio-$RELEASE_ID.tar.gz" \
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST:$remote_archive"
|
"$DEPLOY_USER@$DEPLOY_HOST:$remote_archive"
|
||||||
|
|
||||||
printf -v remote_command \
|
printf -v remote_command \
|
||||||
'bash -s -- %q %q %q' \
|
'bash -s -- %q %q %q' \
|
||||||
"$DEPLOY_PATH" "$GITHUB_SHA" "$remote_archive"
|
"$DEPLOY_PATH" "$RELEASE_ID" "$remote_archive"
|
||||||
|
|
||||||
ssh "${ssh_options[@]}" \
|
ssh "${ssh_options[@]}" \
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ Releases are stored in `<DEPLOY_PATH>/releases/<commit-sha>`. The `current`
|
|||||||
symlink is changed only after the new container is healthy. If startup fails,
|
symlink is changed only after the new container is healthy. If startup fails,
|
||||||
the deployment script automatically rebuilds and starts the previous release.
|
the deployment script automatically rebuilds and starts the previous release.
|
||||||
|
|
||||||
|
## Gitea Actions setup
|
||||||
|
|
||||||
|
Enable **Repository Actions** in the repository settings and register a trusted
|
||||||
|
Gitea Runner. The workflow uses the `ubuntu-latest` label; that runner image must
|
||||||
|
provide Bash, Git, `tar`, OpenSSH (`ssh` and `scp`), and standard GNU utilities.
|
||||||
|
Node.js 22 is installed by the workflow.
|
||||||
|
|
||||||
|
After the first successful verification run, protect `main` in Gitea and make
|
||||||
|
the **Verify production build** status check required. This prevents a pull
|
||||||
|
request from merging when linting, type-checking, tests, the production build,
|
||||||
|
or the dependency audit fails.
|
||||||
|
|
||||||
## Gitea repository secrets
|
## Gitea repository secrets
|
||||||
|
|
||||||
Add these secrets under the repository's **Settings > Actions > Secrets**:
|
Add these secrets under the repository's **Settings > Actions > Secrets**:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
experimental: {
|
||||||
|
globalNotFound: true,
|
||||||
|
},
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: process.cwd(),
|
root: process.cwd(),
|
||||||
},
|
},
|
||||||
|
|||||||
137
src/app/global-not-found.module.css
Normal file
137
src/app/global-not-found.module.css
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100svh;
|
||||||
|
padding: var(--space-6) var(--layout-page-gutter);
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
width: min(100%, 36rem);
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persian {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[lang='fa']) .english {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[lang='fa']) .persian {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
color: color-mix(in srgb, var(--accent-default) 38%, var(--bg-page));
|
||||||
|
font-size: clamp(6rem, 22vw, 12rem);
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
line-height: 0.82;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[dir='rtl']) .code {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 3rem;
|
||||||
|
height: 0.2rem;
|
||||||
|
margin-block: var(--space-7) var(--space-5);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--accent-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
max-width: 18ch;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
font-weight: 780;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1.12;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[dir='rtl']) .content h1 {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
max-width: 34rem;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-body-large);
|
||||||
|
line-height: var(--line-height-body-large);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.homeLink {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--size-button-height);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-7);
|
||||||
|
padding-inline: var(--space-5);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--action-primary-bg);
|
||||||
|
color: var(--action-primary-fg);
|
||||||
|
font-size: var(--font-size-button);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: var(--line-height-button);
|
||||||
|
transition:
|
||||||
|
background-color var(--motion-duration-fast) ease,
|
||||||
|
box-shadow var(--motion-duration-fast) ease,
|
||||||
|
transform var(--motion-duration-fast) var(--motion-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.homeLink svg {
|
||||||
|
width: var(--size-icon-md);
|
||||||
|
height: var(--size-icon-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.homeLink:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.homeLink:hover {
|
||||||
|
background: var(--action-primary-hover);
|
||||||
|
box-shadow: 0 0.25rem 0.75rem var(--shadow-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 650px) {
|
||||||
|
.page {
|
||||||
|
padding-block: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
margin-block: var(--space-6) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: var(--font-size-body-default);
|
||||||
|
line-height: var(--line-height-body-default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.homeLink {
|
||||||
|
transition: background-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.homeLink:hover,
|
||||||
|
.homeLink:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/app/global-not-found.tsx
Normal file
72
src/app/global-not-found.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import localFont from 'next/font/local';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||||
|
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||||
|
import '@/styles/globals.css';
|
||||||
|
import styles from './global-not-found.module.css';
|
||||||
|
|
||||||
|
const themeAndLocaleScript = `
|
||||||
|
try {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const isPersian = /^\\/fa(?:\\/|$)/.test(window.location.pathname);
|
||||||
|
root.lang = isPersian ? 'fa' : 'en';
|
||||||
|
root.dir = isPersian ? 'rtl' : 'ltr';
|
||||||
|
const savedTheme = localStorage.getItem('portfolio-theme');
|
||||||
|
root.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'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '404 | Amirhossein Khalili',
|
||||||
|
description: 'The requested page could not be found.',
|
||||||
|
};
|
||||||
|
|
||||||
|
function NotFoundContent({ locale }: { locale: Locale }) {
|
||||||
|
const copy = messagesByLocale[locale].notFound;
|
||||||
|
const isRtl = locale === 'fa';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${styles.content} ${locale === 'fa' ? styles.persian : styles.english}`}
|
||||||
|
lang={locale}
|
||||||
|
dir={isRtl ? 'rtl' : 'ltr'}
|
||||||
|
aria-labelledby={`not-found-title-${locale}`}
|
||||||
|
>
|
||||||
|
<p className={styles.code} aria-hidden="true">{copy.code}</p>
|
||||||
|
<span className={styles.divider} aria-hidden="true" />
|
||||||
|
<h1 id={`not-found-title-${locale}`}>{copy.title}</h1>
|
||||||
|
<p className={styles.description}>{copy.description}</p>
|
||||||
|
<Link href={isRtl ? '/fa' : '/'} className={styles.homeLink}>
|
||||||
|
{isRtl ? <ArrowRight aria-hidden="true" /> : <ArrowLeft aria-hidden="true" />}
|
||||||
|
<span>{copy.backHome}</span>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GlobalNotFound() {
|
||||||
|
return (
|
||||||
|
<html lang="en" dir="ltr" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeAndLocaleScript }} />
|
||||||
|
</head>
|
||||||
|
<body className={iranYekan.variable}>
|
||||||
|
<main className={styles.page}>
|
||||||
|
<NotFoundContent locale="en" />
|
||||||
|
<NotFoundContent locale="fa" />
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ export interface SiteMessages {
|
|||||||
description: string;
|
description: string;
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
};
|
};
|
||||||
|
notFound: {
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
backHome: string;
|
||||||
|
};
|
||||||
loader: {
|
loader: {
|
||||||
signature: string;
|
signature: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -146,6 +152,12 @@ export const messagesByLocale = {
|
|||||||
'Portfolio',
|
'Portfolio',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
notFound: {
|
||||||
|
code: '404',
|
||||||
|
title: 'This page isn’t here',
|
||||||
|
description: 'The address may have changed, or the page may no longer exist.',
|
||||||
|
backHome: 'Back to portfolio',
|
||||||
|
},
|
||||||
loader: {
|
loader: {
|
||||||
signature: 'Amirhossein Khalili',
|
signature: 'Amirhossein Khalili',
|
||||||
message: 'Assembling the experience',
|
message: 'Assembling the experience',
|
||||||
@@ -306,6 +318,12 @@ export const messagesByLocale = {
|
|||||||
'نمونهکار',
|
'نمونهکار',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
notFound: {
|
||||||
|
code: '۴۰۴',
|
||||||
|
title: 'این صفحه پیدا نشد',
|
||||||
|
description: 'ممکن است نشانی صفحه تغییر کرده باشد یا دیگر در دسترس نباشد.',
|
||||||
|
backHome: 'بازگشت به نمونهکارها',
|
||||||
|
},
|
||||||
loader: {
|
loader: {
|
||||||
signature: 'امیرحسین خلیلی',
|
signature: 'امیرحسین خلیلی',
|
||||||
message: 'در حال آمادهسازی صفحه',
|
message: 'در حال آمادهسازی صفحه',
|
||||||
|
|||||||
Reference in New Issue
Block a user