@@ -25,6 +25,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.183.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"schema-dts": "^2.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
},
|
||||
@@ -723,6 +724,10 @@
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"schema-dts": ["schema-dts@2.0.0", "", { "dependencies": { "schema-dts-lib": "^1.0.0" } }, "sha512-t7NoCy3Rn5GHGx6p7s1qIYK/AeIb8ZxJNR9WUNFkwMv2CiiGZBmqqYWc2FlZVm5ZbiHMY4OvBWhj7QtyrFO2Jw=="],
|
||||
|
||||
"schema-dts-lib": ["schema-dts-lib@1.0.0", "", { "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-9MEO5vpQH9JdBioUupqluzxSYxPLjhmqRUudk15adUl/ypnRsM2/M1kN3AmVJQeG7nZqcL68H8JlGqQQT6vy9A=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.183.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"schema-dts": "^2.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
|
||||
+3
-21
@@ -3,6 +3,8 @@ import { Courier_Prime, Outfit, Space_Grotesk } from "next/font/google";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { MotionProvider } from "@/components/motion-provider";
|
||||
import { graph } from "@/lib/schema";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, SITE_URL } from "@/content/site";
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
@@ -23,11 +25,6 @@ const courierPrime = Courier_Prime({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const SITE_URL = "https://luisdralves.dev";
|
||||
const SITE_TITLE = "luisdralves";
|
||||
const SITE_DESCRIPTION =
|
||||
"Personal site of Luís Alves. Building systems that connect, things to preserve, new ways to see familiar things. Easier to show than tell.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: SITE_TITLE,
|
||||
@@ -57,21 +54,6 @@ export const metadata: Metadata = {
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
const personSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
name: "Luís Alves",
|
||||
alternateName: "luisdralves",
|
||||
url: SITE_URL,
|
||||
sameAs: [
|
||||
"https://github.com/luisdralves",
|
||||
"https://gitea.luisdralves.dev/luis",
|
||||
"https://linkedin.com/in/luisdralves",
|
||||
],
|
||||
jobTitle: "Software Engineer",
|
||||
description: SITE_DESCRIPTION,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -121,7 +103,7 @@ export default function RootLayout({
|
||||
<script
|
||||
type="application/ld+json"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: required for JSON-LD schema markup
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(personSchema) }}
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(graph) }}
|
||||
/>
|
||||
<MotionProvider>{children}</MotionProvider>
|
||||
</body>
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const SITE_URL = "https://luisdralves.dev";
|
||||
import { SITE_URL } from "@/content/site";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import DocumentIcon from "@/components/icons/document.svg";
|
||||
import GiteaIcon from "@/components/icons/gitea.svg";
|
||||
import GitHubIcon from "@/components/icons/github.svg";
|
||||
import LinkedInIcon from "@/components/icons/linkedin.svg";
|
||||
import MailIcon from "@/components/icons/mail.svg";
|
||||
import SourceIcon from "@/components/icons/source.svg";
|
||||
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
|
||||
|
||||
@@ -19,6 +20,7 @@ type SocialLinksProps = {
|
||||
};
|
||||
|
||||
const PLATFORM_ICONS: Record<string, FC<SVGProps<SVGSVGElement>>> = {
|
||||
Email: MailIcon,
|
||||
GitHub: GitHubIcon,
|
||||
Gitea: GiteaIcon,
|
||||
LinkedIn: LinkedInIcon,
|
||||
@@ -41,7 +43,7 @@ const SocialLink = ({ link }: { link: Link }) => {
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-foreground/[0.02] px-5 py-2.5 font-body text-foreground/80 text-sm transition-colors hover:border-accent-cyan/40 hover:text-accent-cyan"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-foreground/2 px-5 py-2.5 font-body text-foreground/80 text-sm transition-colors hover:border-accent-cyan/40 hover:text-accent-cyan"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{link.platform}</span>
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const SITE_URL = "https://luisdralves.dev";
|
||||
import { SITE_URL } from "@/content/site";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 298 B |
@@ -10,8 +10,14 @@ export type FooterContent = {
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
import { person } from "@/content/person";
|
||||
|
||||
export const footer = {
|
||||
socialLinks: [
|
||||
{
|
||||
platform: "Email",
|
||||
url: `mailto:${person.email}`,
|
||||
},
|
||||
{
|
||||
platform: "GitHub",
|
||||
url: "https://github.com/luisdralves",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
export type PersonContent = {
|
||||
name: string;
|
||||
alternateName: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
jobTitle: string;
|
||||
email: string;
|
||||
image: string;
|
||||
/** BCP-47 language tags, most to least proficient. */
|
||||
knowsLanguage: readonly string[];
|
||||
knowsAbout: readonly string[];
|
||||
nationality: string;
|
||||
homeLocation: {
|
||||
locality: string;
|
||||
/** ISO 3166-1 alpha-2 country code. */
|
||||
countryCode: string;
|
||||
};
|
||||
worksFor: {
|
||||
name: string;
|
||||
url: string;
|
||||
sameAs: readonly string[];
|
||||
};
|
||||
alumnusOf: {
|
||||
name: string;
|
||||
alternateName: string;
|
||||
url: string;
|
||||
sameAs: readonly string[];
|
||||
parentOrganization: {
|
||||
name: string;
|
||||
url: string;
|
||||
sameAs: readonly string[];
|
||||
};
|
||||
};
|
||||
memberOf: {
|
||||
name: string;
|
||||
alternateName: string;
|
||||
url: string;
|
||||
role: string;
|
||||
sameAs: readonly string[];
|
||||
};
|
||||
};
|
||||
|
||||
export const person = {
|
||||
name: "Luís Alves",
|
||||
alternateName: "luisdralves",
|
||||
givenName: "Luís",
|
||||
familyName: "Alves",
|
||||
jobTitle: "Senior Frontend Engineer",
|
||||
email: "hello@luisdralves.dev",
|
||||
image: "https://avatars.githubusercontent.com/u/22676183?s=460",
|
||||
knowsLanguage: ["pt", "en", "nl"],
|
||||
knowsAbout: [
|
||||
"Frontend engineering",
|
||||
"TypeScript",
|
||||
"JavaScript",
|
||||
"Rust",
|
||||
"Python",
|
||||
"React",
|
||||
"Next.js",
|
||||
"React Native",
|
||||
"Svelte",
|
||||
"TanStack Query",
|
||||
"tRPC",
|
||||
"WebSockets",
|
||||
"Server-Sent Events",
|
||||
"Tailwind CSS",
|
||||
"CSS Modules",
|
||||
"styled-components",
|
||||
"Vite",
|
||||
"Bun",
|
||||
"Monorepo tooling",
|
||||
"WebGL",
|
||||
"GLSL shaders",
|
||||
"Three.js",
|
||||
"Canvas API",
|
||||
"Mapbox",
|
||||
"Directus",
|
||||
"Vitest",
|
||||
"Cypress",
|
||||
"Storybook",
|
||||
"PostgreSQL",
|
||||
"Redis",
|
||||
"SQLite",
|
||||
"Prisma",
|
||||
"Drizzle",
|
||||
"Docker",
|
||||
"Caddy",
|
||||
"Linux",
|
||||
"GitHub Actions",
|
||||
"Model Context Protocol",
|
||||
"Embeddings and vector search",
|
||||
"Web performance",
|
||||
"Self-hosting",
|
||||
],
|
||||
nationality: "Portugal",
|
||||
homeLocation: {
|
||||
locality: "Viana do Castelo",
|
||||
countryCode: "PT",
|
||||
},
|
||||
worksFor: {
|
||||
name: "Untile",
|
||||
url: "https://untile.pt",
|
||||
sameAs: [
|
||||
"https://www.linkedin.com/company/untiledigital/",
|
||||
"https://github.com/untile",
|
||||
"https://www.instagram.com/untile.digital/",
|
||||
],
|
||||
},
|
||||
alumnusOf: {
|
||||
name: "Faculty of Engineering of the University of Porto",
|
||||
alternateName: "FEUP",
|
||||
url: "https://fe.up.pt",
|
||||
sameAs: [
|
||||
"https://www.wikidata.org/wiki/Q5428834",
|
||||
"https://www.linkedin.com/school/feup/",
|
||||
"https://www.instagram.com/feup_porto/",
|
||||
],
|
||||
parentOrganization: {
|
||||
name: "University of Porto",
|
||||
url: "https://www.up.pt",
|
||||
sameAs: [
|
||||
"https://en.wikipedia.org/wiki/University_of_Porto",
|
||||
"https://www.wikidata.org/wiki/Q209842",
|
||||
],
|
||||
},
|
||||
},
|
||||
memberOf: {
|
||||
name: "Associação Juvenil de Deão",
|
||||
alternateName: "AJD",
|
||||
url: "https://ajdeao.pt",
|
||||
role: "President",
|
||||
sameAs: [
|
||||
"https://www.linkedin.com/company/associacao-juvenil-de-deao",
|
||||
"https://www.instagram.com/ajdeao",
|
||||
"https://www.facebook.com/assocjuvenildeao",
|
||||
],
|
||||
},
|
||||
} as const satisfies PersonContent;
|
||||
@@ -25,6 +25,7 @@ export type Project = {
|
||||
description: string;
|
||||
techStack: string[];
|
||||
liveUrl?: string;
|
||||
os?: string;
|
||||
sources?: ProjectSource[];
|
||||
media: ProjectMedia[];
|
||||
/** OKLCH "L C H" triplet (space-separated, no parentheses). Bound as --project-accent. */
|
||||
@@ -162,6 +163,7 @@ export const projects = [
|
||||
description:
|
||||
"15+ services on hardware I own, behind a reverse proxy. Email, photos, code, media, dashboards, personal projects. A repurposed gaming desktop that ran out of room in two different ways: no free space left on its disks and no free bays left inside the case. Shown here mid-upgrade. Don't worry about the cables, they don't look like this now :)",
|
||||
techStack: ["Linux", "Docker", "Caddy"],
|
||||
os: "Linux",
|
||||
media: [
|
||||
{
|
||||
type: "image",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const SITE_URL = "https://luisdralves.dev";
|
||||
export const SITE_TITLE = "luisdralves";
|
||||
export const SITE_DESCRIPTION =
|
||||
"Hi, Luís here. Welcome to my personal website. I'm a senior frontend engineer, six years in. I build tactile, slightly obsessive web experiments as well as practical solutions, and enjoy self-hosting anything that's critical to me, or just convenient.";
|
||||
|
||||
export const SITE_DESCRIPTION_OBJECTIVE =
|
||||
"Luís Alves is a senior frontend engineer with six years of experience. He builds tactile web experiments as well as practical solutions, and self-hosts both his own projects and the critical services he depends on.";
|
||||
|
||||
export const SITE_CREATED = "2026-02-19T00:12:21Z";
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
Graph,
|
||||
Person,
|
||||
ProfilePage,
|
||||
SoftwareApplication,
|
||||
WebSite,
|
||||
} from "schema-dts";
|
||||
import { footer } from "@/content/footer";
|
||||
import { person } from "@/content/person";
|
||||
import { type Project, projects } from "@/content/projects";
|
||||
import { SITE_CREATED, SITE_DESCRIPTION_OBJECTIVE, SITE_TITLE, SITE_URL } from "@/content/site";
|
||||
|
||||
const PERSON_ID = `${SITE_URL}/#person`;
|
||||
const WEBSITE_ID = `${SITE_URL}/#website`;
|
||||
const WEBPAGE_ID = `${SITE_URL}/#webpage`;
|
||||
|
||||
const sameAs = footer.socialLinks
|
||||
.map((link) => link.url)
|
||||
.filter((url) => url.startsWith("http"));
|
||||
|
||||
const websiteNode: WebSite = {
|
||||
"@type": "WebSite",
|
||||
"@id": WEBSITE_ID,
|
||||
url: `${SITE_URL}/`,
|
||||
name: SITE_TITLE,
|
||||
alternateName: [person.name, new URL(SITE_URL).hostname],
|
||||
description: SITE_DESCRIPTION_OBJECTIVE,
|
||||
inLanguage: "en",
|
||||
publisher: { "@id": PERSON_ID },
|
||||
image: {
|
||||
"@type": "ImageObject",
|
||||
"@id": `${SITE_URL}/#website-image`,
|
||||
url: `${SITE_URL}/og-image.png`,
|
||||
caption: person.name,
|
||||
},
|
||||
};
|
||||
|
||||
const personNode: Person = {
|
||||
"@type": "Person",
|
||||
"@id": PERSON_ID,
|
||||
url: `${SITE_URL}/`,
|
||||
name: person.name,
|
||||
alternateName: person.alternateName,
|
||||
givenName: person.givenName,
|
||||
familyName: person.familyName,
|
||||
description: SITE_DESCRIPTION_OBJECTIVE,
|
||||
jobTitle: person.jobTitle,
|
||||
email: `mailto:${person.email}`,
|
||||
image: {
|
||||
"@type": "ImageObject",
|
||||
"@id": `${SITE_URL}/#person-image`,
|
||||
url: person.image,
|
||||
caption: person.name,
|
||||
},
|
||||
knowsLanguage: person.knowsLanguage,
|
||||
knowsAbout: person.knowsAbout,
|
||||
nationality: { "@type": "Country", name: person.nationality },
|
||||
homeLocation: {
|
||||
"@type": "Place",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressLocality: person.homeLocation.locality,
|
||||
addressCountry: person.homeLocation.countryCode,
|
||||
},
|
||||
},
|
||||
worksFor: {
|
||||
"@type": "Organization",
|
||||
name: person.worksFor.name,
|
||||
url: person.worksFor.url,
|
||||
sameAs: person.worksFor.sameAs,
|
||||
},
|
||||
// I can't express how much it pains me to refer to myself (ourselves?) in the plural
|
||||
alumniOf: {
|
||||
"@type": "CollegeOrUniversity",
|
||||
name: person.alumnusOf.name,
|
||||
alternateName: person.alumnusOf.alternateName,
|
||||
url: person.alumnusOf.url,
|
||||
sameAs: person.alumnusOf.sameAs,
|
||||
parentOrganization: {
|
||||
"@type": "CollegeOrUniversity",
|
||||
name: person.alumnusOf.parentOrganization.name,
|
||||
url: person.alumnusOf.parentOrganization.url,
|
||||
sameAs: person.alumnusOf.parentOrganization.sameAs,
|
||||
},
|
||||
},
|
||||
memberOf: {
|
||||
"@type": "OrganizationRole",
|
||||
roleName: person.memberOf.role,
|
||||
memberOf: {
|
||||
"@type": "Organization",
|
||||
name: person.memberOf.name,
|
||||
alternateName: person.memberOf.alternateName,
|
||||
url: person.memberOf.url,
|
||||
sameAs: person.memberOf.sameAs,
|
||||
},
|
||||
},
|
||||
sameAs,
|
||||
};
|
||||
|
||||
const profilePageNode: ProfilePage = {
|
||||
"@type": "ProfilePage",
|
||||
"@id": WEBPAGE_ID,
|
||||
url: `${SITE_URL}/`,
|
||||
name: `${SITE_TITLE} — ${person.name}`,
|
||||
isPartOf: { "@id": WEBSITE_ID },
|
||||
inLanguage: "en",
|
||||
dateCreated: SITE_CREATED,
|
||||
dateModified: new Date().toISOString(),
|
||||
about: { "@id": PERSON_ID },
|
||||
mainEntity: { "@id": PERSON_ID },
|
||||
};
|
||||
|
||||
const softwareApplications = projects.map(
|
||||
(project: Project): SoftwareApplication => ({
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": `${SITE_URL}/#project-${project.name}`,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
applicationCategory: project.liveUrl ? "WebApplication" : "DeveloperApplication",
|
||||
operatingSystem: project.os ?? (project.liveUrl && "Web browser"),
|
||||
...(project.liveUrl && { url: project.liveUrl }),
|
||||
creator: { "@id": PERSON_ID },
|
||||
keywords: project.techStack.join(", "),
|
||||
...(project.sources && { sameAs: project.sources.map((source) => source.url) }),
|
||||
isPartOf: { "@id": WEBSITE_ID },
|
||||
}),
|
||||
);
|
||||
|
||||
export const graph: Graph = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [websiteNode, personNode, profilePageNode, ...softwareApplications],
|
||||
};
|
||||
Reference in New Issue
Block a user