Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@

## Project Summary

**Portfolio Website** for Adrián Montesinos López
**Portfolio Website** for Adrián Montes Linares
- **URL**: https://adrianmonteslinares.com/
- **Purpose**: Personal developer portfolio showcasing skills, projects, and CV
- **Status**: Active development — Hero section complete, other sections marked "under construction"
- **Status**: Live — all landing sections shipped (hero, profile, projects, skills, journey, references, certifications, contact). Content is kept in sync with the latest CV in `public/cv/` (September 2026 revision).

### Current Goals
1. **Preserve UI/UX**: Keep the current landing experience identical (hero, navbar, language switch, scroll guard modal)
2. **Refactor Architecture**: Migrate from ad-hoc structure to clean, scalable frontend architecture
3. **Migrate i18n**: Replace custom i18n with `next-intl` for better maintainability
4. **Prepare for Growth**: Structure supports adding blog, more projects, sections, etc.
1. **Keep content truthful**: every claim on the site must match the latest CV (`public/cv/*_NTF.pdf`) and the Code-XR changelog; never expose private data (phone, date of birth, certificate numbers)
2. **Preserve UI/UX**: keep the landing experience stable while content evolves; every user-visible string lives in both `src/i18n/messages/en.json` and `es.json`
3. **Prepare for Growth**: structure supports adding blog, more projects, sections, etc.

---

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# amontesl.github.io
# adrianmonteslinares.com

Personal portfolio website for **Adrián Montes Linares**.

Expand Down Expand Up @@ -53,7 +53,8 @@ npm run dev # Development server
npm run lint # ESLint
npm run typecheck # TypeScript strict check
npm run check-translations # i18n coverage sanity check
npm run check # lint + typecheck + translations
npm run check # lint + typecheck + translations + journey timeline
npm run gen:og # Regenerate the social share card (run on Windows for Segoe UI)
npm run build # Static export to ./out
npm run preview # Serve ./out locally
npm run ship "message" # Check, build, commit, and push current branch
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"ship": "node scripts/deploy-with-check.mjs",
"gen:content": "node scripts/generate-project-images.mjs && node scripts/generate-marketplace-stats.mjs",
"gen:images": "node scripts/generate-images.mjs",
"gen:og": "node scripts/generate-og.mjs",
"translate": "node scripts/translate.mjs",
"check-translations": "node scripts/check-translations.mjs"
},
Expand Down
Binary file modified public/cv/CV_Color_Adrian_Montes_Linares_EN_NTF.pdf
Binary file not shown.
Binary file modified public/cv/CV_Color_Adrian_Montes_Linares_NTF.pdf
Binary file not shown.
1 change: 1 addition & 0 deletions public/cv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ When ready, upload each PDF with the exact filename to enable download functiona
- Directory: ✅ Created
- Expected filenames: ✅ Defined for ES and EN
- Links configured: ✅ Locale-based CV URLs ready
- Served revision: September 2026 (NTF = no phone number) — English C1 (Oxford Test of English Advanced), UPM master's in progress, SATEC Cloud & Systems N2 internship ongoing
Binary file modified public/images/og/portfolio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion scripts/check-journey-timeline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,17 @@ const baseEntries = [
id: 'masterTelecomUPM',
startYear: 2026,
startMonth: 9,
startDay: 7,
endYear: null,
},
{
id: 'oxfordC1',
startYear: 2026,
startMonth: 3,
startDay: 1,
endYear: 2026,
endMonth: 9,
endDay: 1,
endDay: 4,
},
];

Expand Down
132 changes: 132 additions & 0 deletions scripts/generate-og.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Regenerates the social share card public/images/og/portfolio.png (1200x630).
*
* The card is drawn as SVG from the site's design tokens (src/app/globals.css) and the
* profile photo is composited on top with sharp. Run it locally on Windows so the text
* renders in Segoe UI like the original card (`npm run gen:og`); a Linux runner would
* pick a different fallback font, which is why this is not part of the build.
*/
import { stat } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const OUTPUT = path.join(ROOT, 'public/images/og/portfolio.png')
const AVATAR_SOURCE = path.join(ROOT, 'public/images/profile/hero-320@2x.jpg')

const WIDTH = 1200
const HEIGHT = 630
const TEXT_X = 88
const AVATAR = { cx: 946, cy: 316, radius: 126, ring: 4 }
const FONT_FAMILY = "'Segoe UI', Inter, 'Helvetica Neue', Arial, sans-serif"

// Design tokens (src/app/globals.css)
const COLORS = {
bg: '#040304',
fg: '#EFD2BC',
fgMuted: '#D2B6A1',
accent: '#DCA293',
copper: '#A66B57',
}

const LINES = [
{ text: 'Adrián Montes Linares', y: 236, size: 64, weight: 700, fill: COLORS.fg },
{ text: 'Telematics Engineer', y: 298, size: 38, weight: 400, fill: COLORS.fgMuted },
{ text: "Master's student in Telecommunications Engineering", y: 342, size: 28, weight: 400, fill: COLORS.fgMuted },
{ text: 'Cloud & Systems N2 · Full-Stack · XR · Code-XR', y: 388, size: 28, weight: 400, fill: COLORS.accent },
{ text: 'Portfolio · adrianmonteslinares.com', y: 540, size: 28, weight: 400, fill: COLORS.fgMuted },
]
const RULE = { y: 418, x1: TEXT_X, x2: 560 }

function escapeXml(value) {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

function buildCardSvg() {
const texts = LINES.map(
(line) =>
`<text x="${TEXT_X}" y="${line.y}" font-size="${line.size}" font-weight="${line.weight}" fill="${line.fill}">${escapeXml(line.text)}</text>`,
).join('\n ')
const ringRadius = AVATAR.radius + AVATAR.ring / 2

return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
<defs>
<radialGradient id="glow-top" cx="${AVATAR.cx}" cy="150" r="440" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="${COLORS.copper}" stop-opacity="0.22"/>
<stop offset="1" stop-color="${COLORS.copper}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow-bottom" cx="140" cy="640" r="380" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="${COLORS.copper}" stop-opacity="0.12"/>
<stop offset="1" stop-color="${COLORS.copper}" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="${WIDTH}" height="${HEIGHT}" fill="${COLORS.bg}"/>
<rect width="${WIDTH}" height="${HEIGHT}" fill="url(#glow-top)"/>
<rect width="${WIDTH}" height="${HEIGHT}" fill="url(#glow-bottom)"/>
<g font-family="${FONT_FAMILY}">
${texts}
</g>
<rect x="${RULE.x1}" y="${RULE.y}" width="${RULE.x2 - RULE.x1}" height="1" fill="${COLORS.copper}" fill-opacity="0.64"/>
<circle cx="${AVATAR.cx}" cy="${AVATAR.cy}" r="${ringRadius}" fill="none" stroke="${COLORS.accent}" stroke-width="${AVATAR.ring}"/>
</svg>`
}

async function buildAvatar() {
const size = AVATAR.radius * 2
const mask = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="#fff"/></svg>`,
)

return sharp(AVATAR_SOURCE)
.resize(size, size, { fit: 'cover', position: 'centre' })
.composite([{ input: mask, blend: 'dest-in' }])
.png()
.toBuffer()
}

/** Right edge of the lit pixels in a text band, to make sure nothing runs into the photo. */
async function measureTextEdges() {
const { data, info } = await sharp(OUTPUT).raw().toBuffer({ resolveWithObject: true })
// Left edge of the photo ring; pixels from there on belong to the avatar, not the text.
const limitX = AVATAR.cx - AVATAR.radius - AVATAR.ring - 4

const rightEdge = (y0, y1) => {
let max = 0
for (let y = Math.max(0, y0); y < Math.min(info.height, y1); y += 1) {
for (let x = 0; x < limitX; x += 1) {
const i = (y * info.width + x) * info.channels
const luminance = (data[i] + data[i + 1] + data[i + 2]) / 3
if (luminance > 70 && x > max) max = x
}
}
return max
}

return LINES.map((line) => {
const edge = rightEdge(line.y - line.size, line.y + 10)
return { text: line.text, rightEdge: edge, fits: edge < limitX - 24 }
})
}

async function main() {
const avatar = await buildAvatar()

// sharp rasterises SVG at 72 dpi, so the 1200x630 viewBox maps 1:1 to pixels.
await sharp(Buffer.from(buildCardSvg()))
.composite([{ input: avatar, left: AVATAR.cx - AVATAR.radius, top: AVATAR.cy - AVATAR.radius }])
.png({ compressionLevel: 9 })
.toFile(OUTPUT)

const [metadata, fileStat, edges] = await Promise.all([sharp(OUTPUT).metadata(), stat(OUTPUT), measureTextEdges()])
console.log(`og: wrote ${path.relative(ROOT, OUTPUT)} ${metadata.width}x${metadata.height} (${Math.round(fileStat.size / 1024)} KB)`)
for (const edge of edges) {
console.log(`og: ${edge.fits ? 'ok ' : 'WIDE'} right edge x=${edge.rightEdge} — ${edge.text}`)
}
if (edges.some((edge) => !edge.fits)) {
process.exitCode = 1
}
}

await main()
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const metadata: Metadata = {
template: `%s | ${SITE.author}`,
},
description: 'Portfolio of Adrián Montes Linares, Telematics & Software Engineer focused on TypeScript, React, Node.js, DevTools and XR.',
keywords: ['Adrián Montes Linares', 'Adrián Montes', 'Telematics Engineer', 'Software Engineer', 'React', 'TypeScript', 'Node.js', 'XR', 'WebXR', 'Code-XR', 'VISSOFT', 'ICSME 2025', 'Portfolio'],
keywords: ['Adrián Montes Linares', 'Adrián Montes', 'Telematics Engineer', 'Software Engineer', 'React', 'TypeScript', 'Node.js', 'XR', 'WebXR', 'Code-XR', 'VISSOFT', 'ICSME 2025', 'Universidad Politécnica de Madrid', 'UPM', 'Machine Learning', 'Big Data', 'Cloud', 'Systems N2', 'Model Context Protocol', 'MCP', 'Oxford Test of English C1', 'Portfolio'],
authors: [{ name: SITE.author }],
creator: SITE.author,
manifest: '/favicons/site.webmanifest',
Expand Down
4 changes: 2 additions & 2 deletions src/components/common/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function Footer() {
<div className="relative py-10 sm:py-14">
<div className="mb-9 grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-[1.25fr_1.05fr_0.75fr_0.75fr] lg:gap-10">
<div className="sm:col-span-2 lg:col-span-1">
<p className="mb-2 text-lg font-semibold text-[var(--fg)]">{SITE.author}</p>
<p className="mb-2 text-lg font-semibold text-[var(--fg)]" translate="no">{SITE.author}</p>
<p className="max-w-sm text-sm leading-relaxed text-[var(--fg-muted)]">
{t('brand')}
</p>
Expand Down Expand Up @@ -129,7 +129,7 @@ export function Footer() {

<div className="flex flex-col items-start justify-between gap-3 text-left sm:flex-row sm:items-center">
<p className="text-xs text-[var(--fg-muted)]/60">
&copy; {currentYear} {SITE.author}. {t('copyright')}
&copy; {currentYear} <span translate="no">{SITE.author}</span>. {t('copyright')}
</p>
<p className="text-xs text-[var(--fg-muted)]/40">
{t('by')}
Expand Down
19 changes: 14 additions & 5 deletions src/components/common/LocalizedShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,35 @@

import type { ReactNode } from 'react'
import { Footer } from '@/components/common/Footer'
import { I18nProvider, type Locale } from '@/i18n'
import { LocalePreferenceGate } from '@/features/language'
import { I18nProvider, useTranslations, type Locale } from '@/i18n'
import { BrowserTranslateHint, LocalePreferenceGate } from '@/features/language'

export interface LocalizedShellProps {
children: ReactNode
locale: Locale
showFooter?: boolean
}

function SkipToContentLink() {
const t = useTranslations('language')

return (
<a href="#main-content" className="skip-to-content">
{t('skipToContent')}
</a>
)
}

export function LocalizedShell({ children, locale, showFooter = true }: LocalizedShellProps) {
return (
<I18nProvider locale={locale}>
<LocalePreferenceGate />
<a href="#main-content" className="skip-to-content">
Skip to main content
</a>
<SkipToContentLink />
<main id="main-content">
{children}
</main>
{showFooter && <Footer />}
<BrowserTranslateHint />
</I18nProvider>
)
}
24 changes: 14 additions & 10 deletions src/content/certifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Professional certifications, awards, and achievements
*/

import { LINKS } from '@/lib/constants'

export interface Certification {
id: string
name: string // i18n key for name
Expand All @@ -16,6 +18,16 @@ export interface Certification {
}

export const CERTIFICATIONS: Certification[] = [
{
id: 'oxford-c1',
name: 'oxfordC1',
issuer: 'oxfordC1Issuer',
date: '2026-09-04',
link: LINKS.oxfordC1Verify,
linkType: 'external',
tags: ['English', 'CEFR C1', 'Oxford'],
status: 'completed',
},
{
id: 'telematics-degree',
name: 'telematicsDegree',
Expand Down Expand Up @@ -54,20 +66,12 @@ export const CERTIFICATIONS: Certification[] = [
tags: ['Kotlin', 'Android', 'Maps', 'GPX'],
status: 'completed',
},
{
id: 'cambridge-c1',
name: 'cambridgeC1',
issuer: 'Cambridge University',
date: '2026-Q2',
tags: ['English', 'Professional Development'],
status: 'in-progress',
},
{
id: 'master-telecom-upm',
name: 'masterTelecomUPM',
issuer: 'masterTelecomUPMIssuer',
date: '2026-09',
tags: ['Telecommunications', 'UPM', 'Systems'],
status: 'planned',
tags: ['UPM', 'Telecommunications', 'Machine Learning', 'Big Data'],
status: 'in-progress',
},
]
Loading
Loading