- - - -
- -
+
+
+
+ +
+ +
+
+ +
+ +
+ +
); }; +const WORDS_PER_MINUTE = 220; + +const readingMinutes = (content: string) => + Math.max(1, Math.round(content.split(/\s+/).length / WORDS_PER_MINUTE)); + const getStaticProps = async (slug: string) => { const postFilePath = path.join(BLOGS_PATH, `${slug}.mdx`); const source = fs.readFileSync(postFilePath); @@ -119,7 +141,6 @@ const getStaticProps = async (slug: string) => { return { content, - scope: data, frontMatter: { ...data, slug, diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index ded2f7271..d5e8ad19c 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -1,3 +1,4 @@ +import SectionLabel from '@/components/SectionLabel'; import SubscribeForm from '@/components/SubscribeForm'; import {newCommonMetaTags} from '@/frontend-utils/meta-tags'; import type {IBlogMetadata} from '@/models/mdxFiles'; @@ -17,15 +18,35 @@ async function BlogPage() { props: {blogs}, } = await getStaticProps(); const {t} = await createTranslation('blog'); + const {t: tNewsletter} = await createTranslation('newsletter'); return ( - <> -
-

{t('myBlogs')}

- -
- - +
+
+

+ {t('myBlogs')} +

+

+ {t('postCount', {count: blogs.length})} +

+
+ + + +
+ + {tNewsletter('form.button')} + +

+ {tNewsletter('subtitle')} +

+ +
+
); } diff --git a/src/app/i18n/locales/en/blog.json b/src/app/i18n/locales/en/blog.json index bd7b107ab..a12d6b9ef 100644 --- a/src/app/i18n/locales/en/blog.json +++ b/src/app/i18n/locales/en/blog.json @@ -1,5 +1,18 @@ { "myBlogs": "My Blogs", "filterByTags": "Filter by tags", - "date": "{{val, datetime}}" + "date": "{{val, datetime}}", + "search": "Search posts", + "moreTags": "+{{count}} more", + "fewerTags": "fewer", + "clearTags": "clear", + "noResults": "No posts match that.", + "reset": "Reset filters", + "minRead": "{{count}} min read", + "onThisPage": "On this page", + "share": "Share", + "copyLink": "Copy link", + "copied": "Copied", + "allPosts": "All posts", + "postCount": "{{count}} posts" } diff --git a/src/app/i18n/locales/en/home.json b/src/app/i18n/locales/en/home.json index 00876e623..ae194edf5 100644 --- a/src/app/i18n/locales/en/home.json +++ b/src/app/i18n/locales/en/home.json @@ -6,7 +6,12 @@ "recent": "Recent", "about": { "kicker": "About me", - "viewResume": "View full resume" + "viewResume": "View full resume", + "more": "More about me", + "title": "About Carlo Gino Catapang", + "elsewhere": "Elsewhere", + "seeAlso": "See also", + "source": "Source of this site" }, "skillsMarquee": { "kicker": "My stack", @@ -33,13 +38,20 @@ }, "activity": { "kicker": "Coding activity", - "title": "Always building" + "title": "Always building", + "contributions": "contributions", + "last12Months": "last 12 months", + "languages": "languages", + "last7Days": "7 days" }, "contact": { "kicker": "Get in touch", "title": "Let's talk", "description": "Pick a slot that works for you and let's chat about your project, an opportunity, or anything tech.", - "emailMe": "Email me instead" + "emailMe": "Email me instead", + "ratherTalk": "Rather talk?", + "or": "or", + "email": "email" }, "greetings": { "filename": "Greetings.sh", @@ -57,5 +69,17 @@ "10": "Let's make them come into existence!", "11": "We'll create something marvelous together.", "12": "Scroll down to know more about me..." + }, + "now": { + "label": "now", + "role": "role", + "based": "based", + "writing": "writing", + "building": "building", + "readBlog": "Read the blog", + "resume": "Resume" + }, + "newsletter": { + "short": "Get new posts by email. No spam." } } diff --git a/src/app/i18n/locales/sv/blog.json b/src/app/i18n/locales/sv/blog.json index 4d11e59df..0eace5aaa 100644 --- a/src/app/i18n/locales/sv/blog.json +++ b/src/app/i18n/locales/sv/blog.json @@ -1,5 +1,18 @@ { "myBlogs": "Mina bloggar", "filterByTags": "Filtrera efter taggar", - "date": "{{val, datetime}}" + "date": "{{val, datetime}}", + "search": "Sök inlägg", + "moreTags": "+{{count}} till", + "fewerTags": "färre", + "clearTags": "rensa", + "noResults": "Inga inlägg matchar.", + "reset": "Återställ filter", + "minRead": "{{count}} min läsning", + "onThisPage": "På den här sidan", + "share": "Dela", + "copyLink": "Kopiera länk", + "copied": "Kopierad", + "allPosts": "Alla inlägg", + "postCount": "{{count}} inlägg" } diff --git a/src/app/i18n/locales/sv/home.json b/src/app/i18n/locales/sv/home.json index 940a5cd00..28536d80b 100644 --- a/src/app/i18n/locales/sv/home.json +++ b/src/app/i18n/locales/sv/home.json @@ -6,7 +6,12 @@ "recent": "Senaste", "about": { "kicker": "Om mig", - "viewResume": "Se hela CV:t" + "viewResume": "Se hela CV:t", + "more": "Mer om mig", + "title": "Om Carlo Gino Catapang", + "elsewhere": "På andra ställen", + "seeAlso": "Se även", + "source": "Källkoden till sidan" }, "skillsMarquee": { "kicker": "Min stack", @@ -33,13 +38,20 @@ }, "activity": { "kicker": "Kodningsaktivitet", - "title": "Bygger alltid" + "title": "Bygger alltid", + "contributions": "bidrag", + "last12Months": "senaste 12 månaderna", + "languages": "språk", + "last7Days": "7 dagar" }, "contact": { "kicker": "Hör av dig", "title": "Låt oss prata", "description": "Välj en tid som passar dig så pratar vi om ditt projekt, en möjlighet eller vad som helst inom teknik.", - "emailMe": "Mejla mig i stället" + "emailMe": "Mejla mig i stället", + "ratherTalk": "Hellre prata?", + "or": "eller", + "email": "mejl" }, "greetings": { "filename": "Hälsningsfraser.sh", @@ -57,5 +69,17 @@ "10": "Låt oss få dem att komma till stånd!", "11": "Vi skapar något fantastiskt tillsammans.", "12": "Scrolla ner för att veta mer om mig..." + }, + "now": { + "label": "nu", + "role": "roll", + "based": "bor i", + "writing": "skriver", + "building": "bygger", + "readBlog": "Läs bloggen", + "resume": "CV" + }, + "newsletter": { + "short": "Få nya inlägg via mejl. Ingen spam." } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7efb53272..5a6ff1970 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,7 @@ import {Viewport} from 'next'; import GlobalEffects from './global-effects'; import {Providers} from './providers'; -import {Inter, Roboto} from 'next/font/google'; +import {IBM_Plex_Mono, IBM_Plex_Sans} from 'next/font/google'; import {twMerge} from 'tailwind-merge'; import {LocaleProvider} from './hooks/locale-provider'; import Header from '@/components/header/Header'; @@ -49,28 +49,34 @@ export const metadata = { ], }; -const inter = Inter({ +// next/font self-hosts these and generates size-adjusted fallbacks, so text +// doesn't shift when the web fonts swap in. +const plexSans = IBM_Plex_Sans({ subsets: ['latin'], - weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], - variable: '--font-inter', + weight: ['400', '500', '600', '700'], + variable: '--font-plex-sans', }); -const roboto = Roboto({ +const plexMono = IBM_Plex_Mono({ subsets: ['latin'], - weight: ['400'], - variable: '--font-roboto', + weight: ['400', '500', '600'], + variable: '--font-plex-mono', }); export default function RootLayout({children}: {children: React.ReactNode}) { return ( - + - +
diff --git a/src/app/page.tsx b/src/app/page.tsx index 6e996f99c..409908704 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,22 +1,14 @@ -import GreetingsContent from '@/components/GreetingsContent'; import type {NextPage} from 'next'; -import dynamicImport from 'next/dynamic'; -import {FullScreenWrapper} from '../components/FullScreenWrapper'; -import AboutTeaser from '../components/home/AboutTeaser'; +import AboutBlurb from '../components/home/AboutBlurb'; import CodingActivity from '../components/home/CodingActivity'; -import ContactCta from '../components/home/ContactCta'; +import HomeTerminal from '../components/home/HomeTerminal'; +import LatestWriting from '../components/home/LatestWriting'; +import NewsletterCard from '../components/home/NewsletterCard'; +import NowPanel from '../components/home/NowPanel'; import SideProjects from '../components/home/SideProjects'; -import SkillsHighlights from '../components/home/SkillsHighlights'; import {newCommonMetaTags} from '../frontend-utils/meta-tags'; -import BlogSuggestionsList from '../modules/blog/BlogSuggestionsList'; import {getBlurringImage} from '../utils/contentful.utils'; import {getBlogsMetadata} from '../utils/mdx.utils'; -import {fetchSkills} from '../utils/resume-props'; -import ParallaxEyes from '../components/ParallaxEyes'; - -const SubscribeForm = dynamicImport( - () => import('../components/SubscribeForm'), -); export const metadata = { ...newCommonMetaTags('Home Page', '/'), @@ -25,7 +17,7 @@ export const metadata = { const HomePage: NextPage = async () => { const { - props: {skills, profileImage, profileSvg, blogs}, + props: {profileImage, profileSvg, blogs}, } = await getStaticProps(); return ( @@ -43,68 +35,23 @@ const HomePage: NextPage = async () => { }), }} /> - - - - - {/* Decorative background accents */} -
-
- - - -
-
- - -
-
- -
-
- +
+
+ +
-
- -
-
- + +
+ +
+ +
-
- -
); }; @@ -116,13 +63,10 @@ const getStaticProps = async () => { const blogs = (await getBlogsMetadata()) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) - .slice(0, 4); - - const skills = await fetchSkills(true); + .slice(0, 5); return { props: { - skills, blogs, profileImage, profileSvg, diff --git a/src/app/resume/DownloadButtons.tsx b/src/app/resume/DownloadButtons.tsx deleted file mode 100644 index e0d5b7a18..000000000 --- a/src/app/resume/DownloadButtons.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; -import {FC} from 'react'; -import {FaFilePdf} from '@react-icons/all-files/fa/FaFilePdf'; -import {FaFileWord} from '@react-icons/all-files/fa/FaFileWord'; -import NextLink from '../../components/basic/NextLink'; -import {useTranslation} from '../i18n/client'; - -const DownloadButtons: FC<{ - resumePdfUrl: string; - resumeWordUrl: string; -}> = ({resumePdfUrl, resumeWordUrl}) => { - const {t} = useTranslation('resume'); - - return ( -
- - - {t('downloadPdf')} - - - - {t('downloadWord')} - -
- ); -}; - -export default DownloadButtons; diff --git a/src/app/resume/ResumeContact.tsx b/src/app/resume/ResumeContact.tsx new file mode 100644 index 000000000..d38794457 --- /dev/null +++ b/src/app/resume/ResumeContact.tsx @@ -0,0 +1,38 @@ +import BookACallButton from '../../components/booking/BookACallButton'; +import {createTranslation} from '../i18n/server'; + +const actionClass = + 'inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 font-mono text-xs lowercase transition-colors hover:border-accent hover:text-accent'; + +export default async function ResumeContact() { + const {t} = await createTranslation('resume'); + + return ( +
+

+ {t('ctaTitle')} +

+

{t('ctaSubtitle')}

+ +
+ ); +} diff --git a/src/app/resume/ResumeContactCta.tsx b/src/app/resume/ResumeContactCta.tsx deleted file mode 100644 index f87edff10..000000000 --- a/src/app/resume/ResumeContactCta.tsx +++ /dev/null @@ -1,84 +0,0 @@ -'use client'; -import {FaEnvelopeSquare} from '@react-icons/all-files/fa/FaEnvelopeSquare'; -import {FaGithub} from '@react-icons/all-files/fa/FaGithub'; -import {FaLinkedin} from '@react-icons/all-files/fa/FaLinkedin'; -import {FC} from 'react'; -import {Fade} from 'react-awesome-reveal'; -import NextLink from '../../components/basic/NextLink'; -import BookACallButton from '../../components/booking/BookACallButton'; -import {useTranslation} from '../i18n/client'; -import DownloadButtons from './DownloadButtons'; - -const ResumeContactCta: FC<{ - resumePdfUrl: string; - resumeWordUrl: string; -}> = ({resumePdfUrl, resumeWordUrl}) => { - const {t} = useTranslation('resume'); - - return ( -
- -
-

{t('ctaTitle')}

-

- {t('ctaSubtitle')} -

- -
- - - {t('emailMe')} - - - - - LinkedIn - - - - GitHub - -
- -
-

- {t('grabACopy')} -

- -
-
-
-
- ); -}; - -export default ResumeContactCta; diff --git a/src/app/resume/ResumeExperience.tsx b/src/app/resume/ResumeExperience.tsx new file mode 100644 index 000000000..c85af7b1f --- /dev/null +++ b/src/app/resume/ResumeExperience.tsx @@ -0,0 +1,90 @@ +import dompurify from 'isomorphic-dompurify'; +import type {ReactNode} from 'react'; +import SectionLabel from '../../components/SectionLabel'; +import type {EducationExperience, WorkExperience} from '../../models/resume'; +import {createTranslation} from '../i18n/server'; + +const formatMonth = (date: string) => + new Intl.DateTimeFormat('en', {year: 'numeric', month: 'short'}).format( + new Date(date), + ); + +function Entry({ + exp, + present, + children, +}: { + exp: WorkExperience | EducationExperience; + present: string; + children?: ReactNode; +}) { + // The data marks an ongoing role by giving it the same start and end date. + const isCurrent = exp.endDate === exp.startDate; + + return ( +
  • +

    + {formatMonth(exp.startDate)} –{' '} + {isCurrent ? ( + {present} + ) : ( + formatMonth(exp.endDate) + )} +

    +
    +

    {exp.title}

    +

    + + {exp.organization} + + · {exp.role} +

    + {children} +
    +
  • + ); +} + +export default async function ResumeExperience({ + workExperiences, + educationExperiences, +}: { + workExperiences: WorkExperience[]; + educationExperiences: EducationExperience[]; +}) { + const {t} = await createTranslation('resume'); + + return ( + <> +
    + {t('workExperiences')} +
      + {workExperiences.map(exp => ( + +
      + + ))} +
    +
    + +
    + {t('education')} +
      + {educationExperiences.map(exp => ( + + ))} +
    +
    + + ); +} diff --git a/src/app/resume/ResumeHeader.tsx b/src/app/resume/ResumeHeader.tsx new file mode 100644 index 000000000..388157492 --- /dev/null +++ b/src/app/resume/ResumeHeader.tsx @@ -0,0 +1,104 @@ +import {BlurringImage} from '../../components/BlurringImage'; +import type {BlurImageType} from '../../utils/image-blur.utils'; +import {createTranslation} from '../i18n/server'; + +const summary = { + company: 'Tre', + companyWebsite: 'https://www.tre.se', + email: 'carloginocatapang@gmail.com', + location: 'Stockholm, Sweden', + name: 'Carlo Gino Catapang', +}; + +const contactLinks = [ + {label: summary.email, href: `mailto:${summary.email}`}, + {label: 'carlogino.com', href: 'https://carlogino.com'}, + { + label: 'linkedin.com/in/codegino', + href: 'https://www.linkedin.com/in/codegino', + }, + {label: 'github.com/pogicodes', href: 'https://github.com/pogicodes'}, +]; + +const linkClass = 'text-xs hover:text-accent print:text-ink'; + +export default async function ResumeHeader({ + img, + svg, + resumePdfUrl, + resumeWordUrl, +}: Pick & { + resumePdfUrl: string; + resumeWordUrl: string; +}) { + const {t} = await createTranslation('resume'); + + return ( +
    +
    +

    + {summary.name} +

    +

    + {t('jobTitle')} {t('at')}{' '} + + {summary.company} + +

    +

    {t('tagline')}

    + +
      +
    • {summary.location}
    • +
    • + 10+ {t('yearsOfExperience')} · 5 {t('companies')} +
    • +
    + + +

    + + {t('downloadPdf')} ↓ + + + {t('downloadWord')} ↓ + +

    +
    + +
    + +
    +
    + ); +} diff --git a/src/app/resume/ResumeHero.tsx b/src/app/resume/ResumeHero.tsx deleted file mode 100644 index 46954e18a..000000000 --- a/src/app/resume/ResumeHero.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import {FaEnvelopeSquare} from '@react-icons/all-files/fa/FaEnvelopeSquare'; -import {FaMapMarkerAlt} from '@react-icons/all-files/fa/FaMapMarkerAlt'; -import {BlurringImage} from '../../components/BlurringImage'; -import NextLink from '../../components/basic/NextLink'; -import {createTranslation} from '../i18n/server'; -import type {BlurImageType} from '../../utils/image-blur.utils'; -import DownloadButtons from './DownloadButtons'; - -const summary = { - company: 'Tre', - email: 'carloginocatapang@gmail.com', - address: 'Stockholm, Sweden', - name: 'Carlo Gino Catapang', - companyWebsite: 'https://www.tre.se', -}; - -export default async function ResumeHero({ - img, - svg, - resumePdfUrl, - resumeWordUrl, -}: Pick & { - resumePdfUrl: string; - resumeWordUrl: string; -}) { - const {t} = await createTranslation('resume'); - - return ( -
    - {/* Decorative background accents */} -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -

    - {summary.name} -

    -

    - {t('jobTitle')}{' '} - - {t('at')} - {' '} - - {summary.company} - -

    -
    - -

    - {t('tagline')} -

    - -
    - - - 10+ - - {t('yearsOfExperience')} - - - - - 5 - - {t('companies')} - - - - - {summary.address} - -
    - - - - {summary.email} - - -
    - -
    -
    -
    -
    - ); -} diff --git a/src/app/resume/ResumeSkills.tsx b/src/app/resume/ResumeSkills.tsx index db0b285d4..6ee28edec 100644 --- a/src/app/resume/ResumeSkills.tsx +++ b/src/app/resume/ResumeSkills.tsx @@ -1,114 +1,70 @@ -'use client'; -import {FaCheckDouble} from '@react-icons/all-files/fa/FaCheckDouble'; -import {FaDatabase} from '@react-icons/all-files/fa/FaDatabase'; -import {FaNodeJs} from '@react-icons/all-files/fa/FaNodeJs'; -import {FaPalette} from '@react-icons/all-files/fa/FaPalette'; -import {FaRandom} from '@react-icons/all-files/fa/FaRandom'; -import {FaReact} from '@react-icons/all-files/fa/FaReact'; -import {FaServer} from '@react-icons/all-files/fa/FaServer'; -import {FaStar} from '@react-icons/all-files/fa/FaStar'; -import {FaThumbsUp} from '@react-icons/all-files/fa/FaThumbsUp'; -import {FaTools} from '@react-icons/all-files/fa/FaTools'; -import {Fade} from 'react-awesome-reveal'; -import type {CategorizedSkill, Skill, SkillCategory} from '../../models/skill'; -import NextLink from '../../components/basic/NextLink'; -import {useTranslation} from '../i18n/client'; +import SectionLabel from '../../components/SectionLabel'; +import type {CategorizedSkill, Skill} from '../../models/skill'; +import {createTranslation} from '../i18n/server'; const byLevelDesc = (a: Skill, b: Skill) => b.level - a.level; -export default function ResumeSkills({skills}: {skills: CategorizedSkill[]}) { - const {t} = useTranslation('resume'); +function SkillList({skills}: {skills: Skill[]}) { + return ( +
      + {[...skills].sort(byLevelDesc).map((skill, index) => ( +
    • + + {skill.name} + + {index < skills.length - 1 ? ( + + , + + ) : null} +
    • + ))} +
    + ); +} + +export default async function ResumeSkills({ + skills, +}: { + skills: CategorizedSkill[]; +}) { + const {t} = await createTranslation('resume'); const highlights = - skills.find(c => c.category === 'highlights')?.skills ?? []; - const otherCategories = skills.filter( - c => c.category !== 'highlights' && c.skills.length > 0, - ); + skills.find(group => group.category === 'highlights')?.skills ?? []; + const groups = [ + ...(highlights.length ? [{label: t('coreStack'), skills: highlights}] : []), + ...skills + .filter( + group => group.category !== 'highlights' && group.skills.length > 0, + ) + .map(group => ({label: group.category, skills: group.skills})), + ]; return ( -
    -

    {t('skills')}

    - - {/* Core stack highlights */} - {highlights.length > 0 && ( - -
    -

    - - {t('coreStack')} -

    -
    - {[...highlights].sort(byLevelDesc).map(skill => ( - - {skill.name} - - ))} -
    +
    + {t('skills')} +
    + {groups.map(group => ( +
    +
    + {group.label} +
    +
    + +
    - - )} - - {/* Categorized clusters */} -
    - {otherCategories.map(category => ( - -
    -

    - {getSkillCategoryIcon(category.category)} - {category.category} -

    -
    - {[...category.skills].sort(byLevelDesc).map(skill => ( - - {skill.name} - - ))} -
    -
    -
    ))} -
    +
    ); } - -function getSkillCategoryIcon(category: SkillCategory) { - switch (category) { - case 'backend': - return ; - case 'hosting': - return ; - case 'styling': - return ; - case 'testing': - return ; - case 'others': - return ; - case 'tools': - return ; - case 'frontend': - return ; - case 'database': - return ; - case 'discipline': - return ; - default: - return null; - } -} diff --git a/src/app/resume/ResumeTimeline.tsx b/src/app/resume/ResumeTimeline.tsx deleted file mode 100644 index 07487d3b6..000000000 --- a/src/app/resume/ResumeTimeline.tsx +++ /dev/null @@ -1,190 +0,0 @@ -'use client'; -import {useState, type FunctionComponent} from 'react'; -import {FaBriefcase} from '@react-icons/all-files/fa/FaBriefcase'; -import {FaChevronDown} from '@react-icons/all-files/fa/FaChevronDown'; -import {FaGraduationCap} from '@react-icons/all-files/fa/FaGraduationCap'; -import dompurify from 'isomorphic-dompurify'; -import {Fade} from 'react-awesome-reveal'; -import {useTranslation} from '../i18n/client'; -import type {EducationExperience, WorkExperience} from '../../models/resume'; -import NextLink from '../../components/basic/NextLink'; - -const EXPANDED_BY_DEFAULT = 2; - -export default function ResumeTimeline({ - workExperiences, - educationExperiences, -}: { - workExperiences: WorkExperience[]; - educationExperiences: EducationExperience[]; -}) { - const {t} = useTranslation('resume'); - - return ( -
    -

    - - {t('workExperiences')} -

    -
    - - {workExperiences.map((exp, i) => ( - - - - ))} -
    - -

    - - {t('education')} -

    -
    - - {educationExperiences.map(exp => ( - - - - ))} -
    -
    - ); -} - -const TimelineLine = () => ( -
    -); - -const TimelineDot = () => ( -
    -); - -const DateRange: FunctionComponent<{ - startDate: string; - endDate: string; -}> = ({startDate, endDate}) => { - const {t} = useTranslation('common'); - const {t: tResume} = useTranslation('resume'); - - const format = (date: string) => - t('date', { - val: new Date(date), - formatParams: {val: {year: 'numeric', month: 'short'}}, - }); - - return ( -

    - {format(startDate)} - {' — '} - {endDate === startDate ? ( - - {tResume('present')} - - ) : ( - format(endDate) - )} -

    - ); -}; - -const WorkItem: FunctionComponent<{ - exp: WorkExperience; - defaultExpanded: boolean; -}> = ({exp, defaultExpanded}) => { - const [expanded, setExpanded] = useState(defaultExpanded); - const {t} = useTranslation('resume'); - const contentId = `exp-details-${exp.id}`; - - return ( -
    - -
    - -
    -
    -
    -
    - - {t('visitCompany', {company: exp.organization})} → - -
    -
    -
    -
    -
    - ); -}; - -const EducationItem: FunctionComponent<{exp: EducationExperience}> = ({ - exp, -}) => { - return ( -
    - -
    -
    - -

    {exp.title}

    -

    - - {exp.organization} - -  · {exp.role} -

    -
    -
    -
    - ); -}; diff --git a/src/app/resume/page.tsx b/src/app/resume/page.tsx index c9d1b640f..21175b516 100644 --- a/src/app/resume/page.tsx +++ b/src/app/resume/page.tsx @@ -1,16 +1,11 @@ import {NextPage} from 'next'; -import dynamicImport from 'next/dynamic'; import {newCommonMetaTags} from '../../frontend-utils/meta-tags'; import {getBlurringImage} from '../../utils/contentful.utils'; import {fectchExperiences, fetchSkills} from '../../utils/resume-props'; -import ResumeContactCta from './ResumeContactCta'; -import ResumeHero from './ResumeHero'; +import ResumeContact from './ResumeContact'; +import ResumeExperience from './ResumeExperience'; +import ResumeHeader from './ResumeHeader'; import ResumeSkills from './ResumeSkills'; -import ResumeTimeline from './ResumeTimeline'; - -const CustomGithubCalendar = dynamicImport( - () => import('../../components/CustomGithubCalendar'), -); export const metadata = { ...newCommonMetaTags('Resume Page', '/resume'), @@ -31,29 +26,19 @@ const ResumePage: NextPage = async () => { } = await getStaticProps(); return ( -
    - + - -
    - -
    -
    -
    - -
    -
    - + +
    ); }; diff --git a/src/components/AboutMeHero.tsx b/src/components/AboutMeHero.tsx deleted file mode 100644 index c96d05fdf..000000000 --- a/src/components/AboutMeHero.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; - -import {useScrollToView} from '../utils/scroll-to-view-hook'; -import {GuideArrow} from './GuideArrow'; -import {Zoom} from 'react-awesome-reveal'; -import {BlurringImage} from './BlurringImage'; -import type {BlurImageType} from '../utils/image-blur.utils'; - -const quote = [ - 'With great power', - 'comes great', - 'responsibility', - '- Superman', -]; - -export default function AboutMeHero({ - img, - svg, -}: Pick) { - const {scrollToContent} = useScrollToView('#about-me-details'); - - return ( -
    - -
    - {quote.map((word, i) => ( - -

    {word}

    -
    - ))} -
    -
    - - - -
    -
    - ); -} diff --git a/src/components/ClipboardCopyButton.tsx b/src/components/ClipboardCopyButton.tsx index 8cc6c8fc2..0f8d869ac 100644 --- a/src/components/ClipboardCopyButton.tsx +++ b/src/components/ClipboardCopyButton.tsx @@ -59,23 +59,18 @@ function ClipboardCopyButton({value}: ClipboardCopyButtonProps) { return ( ); } diff --git a/src/components/Coffee.tsx b/src/components/Coffee.tsx deleted file mode 100644 index 0fa499bbd..000000000 --- a/src/components/Coffee.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import {SiBuymeacoffee} from '@react-icons/all-files/si/SiBuymeacoffee'; -import Link from './basic/NextLink'; - -function Coffee() { - return ( -
    - - - -

    Buy me a coffee

    -
    - -
    - ); -} - -export default Coffee; diff --git a/src/components/CustomGithubCalendar.tsx b/src/components/CustomGithubCalendar.tsx index c63fc6d69..f775bc086 100644 --- a/src/components/CustomGithubCalendar.tsx +++ b/src/components/CustomGithubCalendar.tsx @@ -29,7 +29,7 @@ export default function CustomGithubCalendar() {
    setTheme(otherTheme)} > - - + + ); } diff --git a/src/components/Eye.tsx b/src/components/Eye.tsx deleted file mode 100644 index 44f96f25e..000000000 --- a/src/components/Eye.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; -import React, {useState, useEffect} from 'react'; -import {useBlink} from '../contexts/BlinkContext'; - -export default function Eye() { - const isBlinking = useBlink(); - const [position, setPosition] = useState({x: 0, y: 0}); - const [isMouseOver, setIsMouseOver] = useState(false); - const eyeRef = React.useRef(null); - - useEffect(() => { - const handleMouseMove = (event: MouseEvent) => { - if (eyeRef.current && !isBlinking) { - const rect = eyeRef.current.getBoundingClientRect(); - const eyeX = rect.left + rect.width / 2; - const eyeY = rect.top + rect.height / 2; - const deltaX = event.clientX - eyeX; - const deltaY = event.clientY - eyeY; - const angle = Math.atan2(deltaY, deltaX); - const distance = Math.min(10, Math.hypot(deltaX, deltaY)); - setPosition({ - x: distance * Math.cos(angle), - y: distance * Math.sin(angle), - }); - } - }; - - window.addEventListener('mousemove', handleMouseMove); - return () => window.removeEventListener('mousemove', handleMouseMove); - }, [isBlinking]); - - return ( -
    setIsMouseOver(true)} - onMouseLeave={() => setIsMouseOver(false)} - style={{ - width: 120, - height: 60, - backgroundColor: '#fff', - border: '5px solid #000', - borderRadius: '60px / 30px', // Oval shape for human eye - position: 'relative', - overflow: 'hidden', - }} - > -
    - {/* Eyelid */} -
    -
    - ); -} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 9a70396b6..22d570908 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,11 +1,8 @@ -import NextLink from './basic/NextLink'; -import BookACallButton from './booking/BookACallButton'; -import {BottomRightShape} from './extras/BottomRightShape'; -import {TopLeftShape} from './extras/TopLeftShape'; -import SocialMedia from './social/SocialMedia'; import type {FC} from 'react'; import {createTranslation} from '../app/i18n/server'; -import BuyMeACoffeeIcon from './social/BuyMeACoffeeIcon'; +import NextLink from './basic/NextLink'; +import BookACallButton from './booking/BookACallButton'; +import {socialLinks} from './social/social-links'; const CURRENT_YEAR = new Date().getFullYear(); @@ -13,44 +10,42 @@ const Footer: FC = async () => { const {t} = await createTranslation('common'); return ( -
    - - -
    -
    +
    +
    + +
    + - {t('sitemap')} + {t('rssFeed')} - {t('rssFeed')} + {t('sitemap')} -

    {t('allRightsReserved')}

    -

    © Carlo Gino Catapang {CURRENT_YEAR}

    -
    -
    - -
    - - - -
    + © {CURRENT_YEAR} Carlo Gino Catapang
    diff --git a/src/components/FullScreenWrapper.tsx b/src/components/FullScreenWrapper.tsx deleted file mode 100644 index e9515eeea..000000000 --- a/src/components/FullScreenWrapper.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type {FunctionComponent, ReactNode} from 'react'; -import clsx from 'clsx'; -import {BottomLeftShape} from './extras/BottomLeftShape'; -import {BottomRightShape} from './extras/BottomRightShape'; -import {TopLeftShape} from './extras/TopLeftShape'; -import {TopRightShape} from './extras/TopRightShape'; - -export const FullScreenWrapper: FunctionComponent<{ - tr?: boolean; - tl?: boolean; - bl?: boolean; - br?: boolean; - id?: string; - children: ReactNode; - className?: string; -}> = ({children, tr, bl, br, tl, className, ...props}) => { - const classNames = - 'min-h-screen min-w-full flex flex-col justify-center relative overflow-hidden'; - - return ( -
    - {tr && } - {bl && } - {tl && } - {br && } - {children} -
    - ); -}; diff --git a/src/components/GreetingsContent.tsx b/src/components/GreetingsContent.tsx deleted file mode 100644 index 4bbfa7f18..000000000 --- a/src/components/GreetingsContent.tsx +++ /dev/null @@ -1,184 +0,0 @@ -'use client'; -import {useCallback, useMemo, useState} from 'react'; -import type {FC, FunctionComponent} from 'react'; -import {BsChevronRight} from '@react-icons/all-files/bs/BsChevronRight'; -import {BsTerminalFill} from '@react-icons/all-files/bs/BsTerminalFill'; -import clsx from 'clsx'; -import {useScrollToView} from '../utils/scroll-to-view-hook'; -import type {TypewriterChunk, TypewriterStep} from '../utils/typewriter-hook'; -import {useTypewriter} from '../utils/typewriter-hook'; -import {GuideArrow} from './GuideArrow'; -import {useTranslation} from '@/app/i18n/client'; - -type TypedLine = { - hasPrompt: boolean; - texts: Extract[]; -}; - -const GreetingsContent: FC = () => { - const {scrollToContent} = useScrollToView('#resume-summary'); - const [isGuideVisible, setIsGuideVisible] = useState(false); - - const {t} = useTranslation('home'); - - const steps = useMemo( - () => [ - {kind: 'prompt'}, - {kind: 'delay', ms: 1200}, - {kind: 'text', value: t('greetings.1')}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'delay', ms: 500}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'delay', ms: 500}, - {kind: 'text', value: t('greetings.2')}, - {kind: 'delay', ms: 500}, - {kind: 'text', value: t('greetings.3')}, - {kind: 'delay', ms: 1000}, - {kind: 'backspace', count: Number(t('greetings.del1'))}, - {kind: 'text', value: t('greetings.4')}, - {kind: 'delay', ms: 1200}, - {kind: 'backspace', count: Number(t('greetings.del2'))}, - { - kind: 'text', - value: t('greetings.5'), - className: 'font-bold text-primary-500 dark:text-primary-100', - }, - {kind: 'delay', ms: 200}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'text', value: t('greetings.6')}, - {kind: 'delay', ms: 1500}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'delay', ms: 300}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'text', value: t('greetings.7')}, - {kind: 'delay', ms: 300}, - {kind: 'text', value: t('greetings.8')}, - {kind: 'delay', ms: 300}, - {kind: 'text', value: t('greetings.9')}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'text', value: t('greetings.10')}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'text', value: t('greetings.11')}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'delay', ms: 300}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'delay', ms: 300}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'delay', ms: 300}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'delay', ms: 300}, - {kind: 'break'}, - {kind: 'prompt'}, - {kind: 'delay', ms: 1000}, - {kind: 'text', value: `${t('greetings.12')}.`}, - ], - [t], - ); - - const onDone = useCallback(() => setIsGuideVisible(true), []); - const {chunks} = useTypewriter(steps, {charDelay: 10, onDone}); - - const lines = useMemo( - () => - chunks.reduce( - (grouped, chunk) => { - if (chunk.kind === 'break') { - return [...grouped, {hasPrompt: false, texts: []}]; - } - - const current = grouped.at(-1)!; - const updated = - chunk.kind === 'prompt' - ? {...current, hasPrompt: true} - : {...current, texts: [...current.texts, chunk]}; - - return [...grouped.slice(0, -1), updated]; - }, - [{hasPrompt: false, texts: []}], - ), - [chunks], - ); - - return ( - <> -
    -
    -
    -
    -
    -
    - -
    - {t('greetings.filename')} -
    -
    -
    - {lines.map((line, lineIndex) => ( -
    - {line.hasPrompt ? : null} - {/* Text stays in a single inline flow so spaces between chunks survive. */} -
    - {line.texts.map((chunk, index) => ( - - {chunk.value} - - ))} - {lineIndex === lines.length - 1 ? ( - | - ) : null} -
    -
    - ))} -
    -
    -
    - {isGuideVisible ? ( -
    - -
    - ) : null} - - ); -}; - -const StyledBsChevronRight = () => { - return ( - - ); -}; - -const Span: FunctionComponent<{ - className?: string; - children: React.ReactNode; -}> = props => { - return ( - - ); -}; - -export default GreetingsContent; diff --git a/src/components/GuideArrow.tsx b/src/components/GuideArrow.tsx deleted file mode 100644 index e73ae3a32..000000000 --- a/src/components/GuideArrow.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type {MouseEventHandler} from 'react'; -import {BsChevronDoubleDown} from '@react-icons/all-files/bs/BsChevronDoubleDown'; - -type Props = { - onClick: MouseEventHandler; -}; - -export const GuideArrow = ({onClick}: Props) => { - return ( -
    - -
    - ); -}; diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx deleted file mode 100644 index 8ea7d1314..000000000 --- a/src/components/Hero.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client'; -import {useScrollToView} from '../utils/scroll-to-view-hook'; -import {GuideArrow} from './GuideArrow'; -import {BottomRightShape} from './extras/BottomRightShape'; -import {useTranslation} from '../app/i18n/client'; -import {Zoom} from 'react-awesome-reveal'; -import {BlurringImage} from './BlurringImage'; -import type {BlurImageType} from '../utils/image-blur.utils'; - -export default function Hero({img, svg}: Pick) { - const {scrollToContent} = useScrollToView('#greetings'); - - const {t} = useTranslation('home'); - - return ( -
    - -
    - -

    - {t('hello')} -

    -

    - {t('welcome')} -

    -
    -
    -
    - - - -
    - -
    - ); -} diff --git a/src/components/ParallaxEyes.tsx b/src/components/ParallaxEyes.tsx deleted file mode 100644 index 5fc0343fd..000000000 --- a/src/components/ParallaxEyes.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; -import React, {useEffect, useState} from 'react'; -import Eye from './Eye'; -import {BlinkProvider} from '../contexts/BlinkContext'; - -const ParallaxEyes: React.FC = () => { - const [gap, setGap] = useState(16); // Initial gap is 8px - const [offsetY, setOffsetY] = useState(0); // Initial vertical offset - - useEffect(() => { - const handleScroll = () => { - const scrollY = window.scrollY; - // Adjust the gap based on scroll position - const newGap = 16 + scrollY * 0.1; - // Adjust the vertical position based on scroll position - const newOffsetY = scrollY * 0.1; // Adjust the multiplier as needed - - setGap(newGap); - setOffsetY(newOffsetY); - }; - - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, []); - - return ( - -
    - - -
    -
    - ); -}; - -export default ParallaxEyes; diff --git a/src/components/SectionLabel.tsx b/src/components/SectionLabel.tsx new file mode 100644 index 000000000..42f97c510 --- /dev/null +++ b/src/components/SectionLabel.tsx @@ -0,0 +1,30 @@ +import type {ReactNode} from 'react'; +import clsx from 'clsx'; + +/** Small mono "// label" used as the heading of every home section. */ +export default function SectionLabel({ + as: Tag = 'h2', + id, + className, + children, +}: { + as?: 'h2' | 'p'; + id?: string; + className?: string; + children: ReactNode; +}) { + return ( + + + //{' '} + + {children} + + ); +} diff --git a/src/components/SubscribeForm.tsx b/src/components/SubscribeForm.tsx index 1e9383887..9d001f2f3 100644 --- a/src/components/SubscribeForm.tsx +++ b/src/components/SubscribeForm.tsx @@ -1,115 +1,97 @@ 'use client'; -import {BiCool} from '@react-icons/all-files/bi/BiCool'; -import {RiSpamLine} from '@react-icons/all-files/ri/RiSpamLine'; +import clsx from 'clsx'; import {useRouter} from 'next/navigation'; import type {SubmitEvent} from 'react'; -import {useState} from 'react'; -import {Fade} from 'react-awesome-reveal'; +import {useId, useState} from 'react'; import {useTranslation} from '../app/i18n/client'; -import Button from './basic/Button'; -import Input from './basic/Input'; import {addSubscriberAction} from './subscribe.action'; -interface FormElements extends HTMLFormControlsCollection { - firstName: HTMLInputElement; - lastName: HTMLInputElement; - email: HTMLInputElement; -} -interface SubscribeFormElement extends HTMLFormElement { - readonly elements: FormElements; -} +const inputClass = + 'w-full min-w-0 rounded-md border border-line bg-canvas px-3 py-2 text-sm outline-none focus:border-accent'; -const SubscribeForm = () => { +/** Newsletter sign-up fields. `inline` puts them on one row from `sm` up. */ +export default function SubscribeForm({ + layout = 'stacked', + className, +}: { + layout?: 'stacked' | 'inline'; + className?: string; +}) { + const {t} = useTranslation('newsletter'); + const router = useRouter(); + const id = useId(); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); - const router = useRouter(); - - const {t} = useTranslation('newsletter'); - - const handleSubmit = async (e: SubmitEvent) => { - e.preventDefault(); + const handleSubmit = async (event: SubmitEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); setIsLoading(true); - const {email, firstName, lastName} = ( - e.currentTarget as SubscribeFormElement - ).elements; + setError(''); - await addSubscriberAction({ - email: email.value, - firstName: firstName.value, - lastName: lastName.value, - }).then(res => { - if (res.success) { - router.push('/signup-success'); - } else { - setError(res?.message as string); - } + const result = await addSubscriberAction({ + email: String(form.get('email')), + firstName: String(form.get('firstName')), + lastName: '', }); - setIsLoading(false); + if (result.success) { + router.push('/signup-success'); + } else { + setError(result.message ?? ''); + setIsLoading(false); + } }; return ( -
    - -
    -
    -

    {t('title')}

    -

    - {t('subtitle')} -

    -

    - {t('noSpam')} - . {t('unsubscribe')} - -

    -
    - -
    -
    -

    {t('form.title')}

    -
    -
    - - - - {error && ( -
    - {error} -
    - )} - -
    -
    -
    -
    -
    +
    +
    + + + + + +
    + {error ? ( +

    + {error} +

    + ) : null} +
    ); -}; - -export default SubscribeForm; +} diff --git a/src/components/TechStackCarousel.tsx b/src/components/TechStackCarousel.tsx deleted file mode 100644 index 8677b769c..000000000 --- a/src/components/TechStackCarousel.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; -import Image from 'next/image'; -import {Carousel} from 'react-responsive-carousel'; -import 'react-responsive-carousel/lib/styles/carousel.min.css'; - -export type TechStack = { - name: string; - icon?: string; - url?: string; -}; - -type Props = { - techStacks: TechStack[]; -}; - -export default function TechStackCarousel({techStacks}: Props) { - return ( -
    -
    - - {techStacks.map(techstack => { - return ( -
    - {techstack.url ? ( - src} - src={techstack.url} - fill={true} - alt={techstack.name} - title={techstack.name} - /> - ) : null} -
    - ); - })} -
    -
    -
    - ); -} diff --git a/src/components/WakatimeCharts.tsx b/src/components/WakatimeCharts.tsx deleted file mode 100644 index ba78fa07e..000000000 --- a/src/components/WakatimeCharts.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; -import {Zoom} from 'react-awesome-reveal'; -import {useTranslation} from '../app/i18n/client'; -import NextLink from './basic/NextLink'; - -export default function WakatimeCharts() { - const {t} = useTranslation('resume'); - - return ( -
    -

    - {t('my')}  - - Wakatime - -  {t('activity')} -

    - -
    - {/* oxlint-disable-next-line nextjs/no-img-element */} - Wakatime coding activity chart -
    -
    -
    - ); -} diff --git a/src/components/basic/Button.tsx b/src/components/basic/Button.tsx deleted file mode 100644 index 61a9f8f70..000000000 --- a/src/components/basic/Button.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import {forwardRef, type JSX} from 'react'; - -const Button = forwardRef( - ({className = '', ...props}, ref) => { - let classNames = - 'py-3 px-2 rounded-xl bg-primary-900 text-neutral-50 hover:bg-primary-800 text-light hover:shadow-xs hover:shadow-dark'; - - if (className) { - classNames += ` ${className}`; - } - - return +
    +
    + + {isMenuOpen ? ( + + ) : null} +
    ); } diff --git a/src/components/header/Logo.tsx b/src/components/header/Logo.tsx deleted file mode 100644 index 6fefff244..000000000 --- a/src/components/header/Logo.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, {FunctionComponent} from 'react'; -import clsx from 'clsx'; -import Image from 'next/image'; -import RoundButton from '../basic/RoundButton'; - -const Logo: FunctionComponent<{onClick?: () => void; className?: string}> = ({ - onClick = () => {}, - className, -}) => { - return ( - - CG - - ); -}; - -export default Logo; diff --git a/src/components/header/LogoWrapper.tsx b/src/components/header/LogoWrapper.tsx deleted file mode 100644 index 0620d0ce8..000000000 --- a/src/components/header/LogoWrapper.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type {FunctionComponent} from 'react'; -import clsx from 'clsx'; -import {usePathname} from 'next/navigation'; -import NextLink from '../basic/NextLink'; -import Logo from './Logo'; -import {useHeader} from './header-context'; -import {twMerge} from 'tailwind-merge'; - -export const LogoWrapper: FunctionComponent<{className?: string}> = ({ - className, -}) => { - const {showSidebar, isSidebarVisible} = useHeader(); - const path = usePathname(); - - return ( -
    -
    - {!isSidebarVisible && ( - - )} -
    - - - - - Code Gino - - -
    - ); -}; diff --git a/src/components/header/SmallScreenSidebar.tsx b/src/components/header/SmallScreenSidebar.tsx deleted file mode 100644 index f8b7c815e..000000000 --- a/src/components/header/SmallScreenSidebar.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import {AiOutlineClose} from '@react-icons/all-files/ai/AiOutlineClose'; -import {FaEnvelopeSquare} from '@react-icons/all-files/fa/FaEnvelopeSquare'; -import {FaFacebookMessenger} from '@react-icons/all-files/fa/FaFacebookMessenger'; -import clsx from 'clsx'; -import {usePathname} from 'next/navigation'; -import type {FunctionComponent, MouseEvent} from 'react'; -import {useRef} from 'react'; -import {CSSTransition} from 'react-transition-group'; -import {useTranslation} from '../../app/i18n/client'; -import DarkModeToggle from '../DarkModeToggle'; -import NextLink from '../basic/NextLink'; -import RoundButton from '../basic/RoundButton'; -import CustomIcon from '../icon/CustomIcon'; -import BuyMeACoffeeIcon from '../social/BuyMeACoffeeIcon'; -import SocialMedia from '../social/SocialMedia'; -import {useHeader} from './header-context'; -import {navigationLinks} from './nav-links'; - -const sidebarLinks = [ - { - url: '/', - label: 'home', - }, - ...navigationLinks, -]; - -const CURRENT_YEAR = new Date().getFullYear(); - -const SmallScreenSidebar: FunctionComponent = () => { - const EMAIL_ADDRESS = 'carloginocatapang@gmail.com'; - const nodeRef = useRef(null); - const {hideSidebar, isSidebarVisible} = useHeader(); - const path = usePathname() as string; - - const sidebarClose = (e: MouseEvent) => { - e.stopPropagation(); - hideSidebar(); - }; - - const {t} = useTranslation('common'); - - return ( - <> - {isSidebarVisible && ( - - - - )} - -
    -
    e.stopPropagation()} - role="presentation" - className={clsx( - 'absolute left-0 top-0 flex h-screen w-80 flex-col bg-neutral-50 dark:bg-neutral-900', - 'cursor-default items-start p-4 text-2xl leading-10 shadow-md shadow-neutral-800', - )} - > -
    - Code Gino -
    -
    -
    -

    Links

    - - - -

    Social

    -
    - - -
    -

    Contact me

    -
    - - - - - - -
    -
    -

    Settings

    - -
    - -
    -
    -
    - -
    -

    All rights reserved

    -

    © Carlo Gino Catapang {CURRENT_YEAR}

    -
    -
    -
    - -
    -
    - - ); -}; - -export default SmallScreenSidebar; diff --git a/src/components/header/WideScreenContent.tsx b/src/components/header/WideScreenContent.tsx deleted file mode 100644 index e46deb0f1..000000000 --- a/src/components/header/WideScreenContent.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import clsx from 'clsx'; -import {usePathname} from 'next/navigation'; -import NextLink from '../basic/NextLink'; -import SocialMedia from '../social/SocialMedia'; -import {navigationLinks} from './nav-links'; -import {useTranslation} from '../../app/i18n/client'; -import BuyMeACoffeeIcon from '../social/BuyMeACoffeeIcon'; -import DarkModeToggle from '../DarkModeToggle'; - -export default function WideScreenContentImpl() { - const path = usePathname(); - - const {t} = useTranslation('common'); - - return ( -
    - -
    - - -
    - -
    -
    -
    - ); -} diff --git a/src/components/header/header-context.tsx b/src/components/header/header-context.tsx deleted file mode 100644 index 111545e27..000000000 --- a/src/components/header/header-context.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import {createContext, useContext, useState} from 'react'; -import type {Dispatch, FunctionComponent, SetStateAction} from 'react'; - -const HeaderContext = createContext(null); - -export const HeaderProvider: FunctionComponent<{children: React.ReactNode}> = ({ - children, -}) => { - const state = useState(false); - - return ( - {children} - ); -}; - -export const useHeader = () => { - const [isSidebarVisible, setIsSidebarVisible] = useContext(HeaderContext) as [ - boolean, - Dispatch>, - ]; - - const showSidebar = () => { - setIsSidebarVisible(true); - }; - - const hideSidebar = () => { - setIsSidebarVisible(false); - }; - - return { - isSidebarVisible, - showSidebar, - hideSidebar, - }; -}; diff --git a/src/components/header/nav-links.ts b/src/components/header/nav-links.ts index d1c8e74f3..f28b5b627 100644 --- a/src/components/header/nav-links.ts +++ b/src/components/header/nav-links.ts @@ -5,7 +5,6 @@ export const navigationLinks: NavLink[] = [ {url: '/about', label: 'about'}, {url: '/blog', label: 'blog'}, {url: 'https://note.carlogino.com', label: 'Notes'}, - {url: 'https://gpt.carlogino.com', label: 'Chatbot', target: '_blank'}, ]; type NavLink = { diff --git a/src/components/header/use-scroll-direction.tsx b/src/components/header/use-scroll-direction.tsx deleted file mode 100644 index 5f0a0253c..000000000 --- a/src/components/header/use-scroll-direction.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import {useEffect, useState} from 'react'; - -export const useScrollDirection = (threshold = 150) => { - const [scrollDir, setScrollDir] = useState<'down' | 'up'>('up'); - - useEffect(() => { - let lastScrollY = window.pageYOffset; - let ticking = false; - - const updateScrollDir = () => { - const scrollY = window.pageYOffset; - - if (Math.abs(scrollY - lastScrollY) < threshold) { - ticking = false; - return; - } - setScrollDir(scrollY > lastScrollY ? 'down' : 'up'); - lastScrollY = scrollY > 0 ? scrollY : 0; - ticking = false; - }; - - const onScroll = () => { - if (!ticking) { - window.requestAnimationFrame(updateScrollDir); - ticking = true; - } - }; - - window.addEventListener('scroll', onScroll); - - return () => window.removeEventListener('scroll', onScroll); - }, [scrollDir, threshold]); - - return { - direction: scrollDir, - }; -}; diff --git a/src/components/home/AboutBlurb.tsx b/src/components/home/AboutBlurb.tsx new file mode 100644 index 000000000..6cc442753 --- /dev/null +++ b/src/components/home/AboutBlurb.tsx @@ -0,0 +1,18 @@ +import {createTranslation} from '@/app/i18n/server'; +import NextLink from '@/components/basic/NextLink'; +import SectionLabel from '@/components/SectionLabel'; + +export default async function AboutBlurb() { + const {t} = await createTranslation('home'); + const {t: tResume} = await createTranslation('resume'); + + return ( +
    + {t('about.kicker')} +

    {tResume('tagline')}

    + + {t('about.more')} → + +
    + ); +} diff --git a/src/components/home/AboutTeaser.tsx b/src/components/home/AboutTeaser.tsx deleted file mode 100644 index 4e65c5963..000000000 --- a/src/components/home/AboutTeaser.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import {FaMapMarkerAlt} from '@react-icons/all-files/fa/FaMapMarkerAlt'; -import {Fade} from 'react-awesome-reveal'; -import {BlurringImage} from '../BlurringImage'; -import NextLink from '../basic/NextLink'; -import {createTranslation} from '../../app/i18n/server'; -import type {BlurImageType} from '../../utils/image-blur.utils'; - -const summary = { - company: 'Tre', - address: 'Stockholm, Sweden', - name: 'Carlo Gino Catapang', - companyWebsite: 'https://www.tre.se', -}; - -export default async function AboutTeaser({ - img, - svg, -}: Pick) { - const {t} = await createTranslation('resume'); - const {t: tHome} = await createTranslation('home'); - - return ( -
    - {/* Decorative background accents */} -
    -
    - - -
    -

    - {tHome('about.kicker')} -

    - -
    -
    -
    -
    - -
    -
    - -
    -
    -

    - {summary.name} -

    -

    - {t('jobTitle')}{' '} - - {t('at')} - {' '} - - {summary.company} - -

    -
    - -

    - {t('tagline')} -

    - -
    - - - 10+ - - {t('yearsOfExperience')} - - - - - 5 - - {t('companies')} - - - - - {summary.address} - -
    - - - {tHome('about.viewResume')} - -
    -
    -
    - -
    - ); -} diff --git a/src/components/home/CodingActivity.tsx b/src/components/home/CodingActivity.tsx index de8be4b8e..a98b974fe 100644 --- a/src/components/home/CodingActivity.tsx +++ b/src/components/home/CodingActivity.tsx @@ -1,45 +1,94 @@ -'use client'; -import dynamicImport from 'next/dynamic'; -import {Fade} from 'react-awesome-reveal'; -import {useTranslation} from '../../app/i18n/client'; -import SectionHeader from './SectionHeader'; +import type {ReactNode} from 'react'; +import {createTranslation} from '@/app/i18n/server'; +import ContributionCalendar from './ContributionCalendar'; +import SectionLabel from '@/components/SectionLabel'; +import {fetchTopLanguages} from './wakatime'; -// react-activity-calendar renders different markup on the server, so skip SSR -const CustomGithubCalendar = dynamicImport( - () => import('../CustomGithubCalendar'), - {ssr: false}, -); - -const WakatimeCharts = dynamicImport(() => import('../WakatimeCharts')); +function Card({ + label, + detail, + href, + linkText, + children, +}: { + label: string; + detail: string; + href: string; + linkText: string; + children: ReactNode; +}) { + return ( +
    +

    + + {label} + · {detail} + + + {linkText} ↗ + +

    + {children} +
    + ); +} -export default function CodingActivity() { - const {t} = useTranslation('home'); +export default async function CodingActivity() { + const {t} = await createTranslation('home'); + const languages = await fetchTopLanguages(); return ( -
    -
    - - - - +
    + {t('activity.kicker')} +
    + + + -
    - -
    - -
    -
    - -
    - -
    -
    + {languages.length ? ( + +
      + {languages.map((language, index) => ( +
    • +
      + {language.name} + + {language.percent.toFixed(1)}% + +
      +
      +
      +
      +
    • + ))} +
    +
    + ) : null}
    ); diff --git a/src/components/home/ContactCta.tsx b/src/components/home/ContactCta.tsx deleted file mode 100644 index e50969483..000000000 --- a/src/components/home/ContactCta.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; -import {FaEnvelopeSquare} from '@react-icons/all-files/fa/FaEnvelopeSquare'; -import {Fade} from 'react-awesome-reveal'; -import {useTranslation} from '../../app/i18n/client'; -import NextLink from '../basic/NextLink'; -import BookACallButton from '../booking/BookACallButton'; -import SectionHeader from './SectionHeader'; - -export default function ContactCta() { - const {t} = useTranslation('home'); - const {t: tCommon} = useTranslation('common'); - - return ( -
    -
    - - - - - - -
    -

    - {t('contact.description')} -

    -
    - - - - {t('contact.emailMe')} - -
    -
    -
    -
    - ); -} diff --git a/src/components/home/ContributionCalendar.tsx b/src/components/home/ContributionCalendar.tsx new file mode 100644 index 000000000..c2f3bca45 --- /dev/null +++ b/src/components/home/ContributionCalendar.tsx @@ -0,0 +1,59 @@ +'use client'; +import {useEffect, useRef, useSyncExternalStore} from 'react'; +import {useTheme} from 'next-themes'; +import {GitHubCalendar} from 'react-github-calendar'; + +const calendarTheme = { + light: ['#e4e1d7', '#b9dcc2', '#7cc296', '#3f9a5c', '#1f7a3a'], + dark: ['#1c2119', '#1d4a2a', '#2a7a42', '#3fb465', '#5ee08a'], +}; + +// react-activity-calendar renders different markup on the server, so it only +// renders after hydration. +const emptySubscribe = () => () => {}; + +export default function ContributionCalendar() { + const {resolvedTheme} = useTheme(); + const containerRef = useRef(null); + const isMounted = useSyncExternalStore( + emptySubscribe, + () => true, + () => false, + ); + + // The calendar loads async and scrolls inside its own container; once it + // overflows, jump to the newest month so phones see recent activity first. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver(() => { + container.querySelectorAll('*').forEach(element => { + if (element.scrollWidth > element.clientWidth) { + element.scrollLeft = element.scrollWidth; + } + }); + }); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + return ( +
    + {isMounted ? ( + + ) : null} +
    + ); +} diff --git a/src/components/home/Eyes.tsx b/src/components/home/Eyes.tsx new file mode 100644 index 000000000..f6286a7aa --- /dev/null +++ b/src/components/home/Eyes.tsx @@ -0,0 +1,65 @@ +'use client'; +import {useEffect, useRef, useState} from 'react'; +import {BlinkProvider, useBlink} from '@/contexts/BlinkContext'; + +const INK = '#111'; + +function Eye({size}: {size: number}) { + const isBlinking = useBlink(); + const ref = useRef(null); + const [pupil, setPupil] = useState({x: 0, y: 0}); + + useEffect(() => { + const onMouseMove = (event: MouseEvent) => { + if (!ref.current || isBlinking) return; + const rect = ref.current.getBoundingClientRect(); + const dx = event.clientX - (rect.left + rect.width / 2); + const dy = event.clientY - (rect.top + rect.height / 2); + const angle = Math.atan2(dy, dx); + const distance = Math.min(size / 10, Math.hypot(dx, dy)); + setPupil({x: distance * Math.cos(angle), y: distance * Math.sin(angle)}); + }; + + window.addEventListener('mousemove', onMouseMove); + return () => window.removeEventListener('mousemove', onMouseMove); + }, [isBlinking, size]); + + return ( +
    +
    + {/* Eyelid */} +
    +
    + ); +} + +export default function Eyes({size = 44}: {size?: number}) { + return ( + +
    + + +
    +
    + ); +} diff --git a/src/components/home/HomeTerminal.tsx b/src/components/home/HomeTerminal.tsx new file mode 100644 index 000000000..4049bc55f --- /dev/null +++ b/src/components/home/HomeTerminal.tsx @@ -0,0 +1,132 @@ +'use client'; +import {useMemo} from 'react'; +import {useTranslation} from '@/app/i18n/client'; +import type {TypewriterChunk, TypewriterStep} from '@/utils/typewriter-hook'; +import {useTypewriter} from '@/utils/typewriter-hook'; + +type TypedLine = { + hasPrompt: boolean; + texts: Extract[]; +}; + +function Prompt() { + return ( + + ❯ + + ); +} + +const lineClass = 'm-0 min-h-[1lh] text-[1em] leading-[inherit]'; + +export default function HomeTerminal() { + const {t} = useTranslation('home'); + const {t: tResume} = useTranslation('resume'); + + const steps = useMemo( + () => [ + {kind: 'delay', ms: 600}, + {kind: 'text', value: t('greetings.1')}, + {kind: 'break'}, + {kind: 'prompt'}, + {kind: 'delay', ms: 400}, + {kind: 'text', value: t('greetings.2')}, + {kind: 'delay', ms: 400}, + {kind: 'text', value: t('greetings.3')}, + {kind: 'delay', ms: 800}, + {kind: 'backspace', count: Number(t('greetings.del1'))}, + {kind: 'text', value: t('greetings.4')}, + {kind: 'delay', ms: 900}, + {kind: 'backspace', count: Number(t('greetings.del2'))}, + { + kind: 'text', + value: t('greetings.5'), + className: 'font-semibold text-terminal-accent', + }, + {kind: 'break'}, + {kind: 'prompt'}, + {kind: 'text', value: t('greetings.6')}, + {kind: 'delay', ms: 900}, + {kind: 'break'}, + {kind: 'prompt'}, + {kind: 'text', value: t('greetings.7')}, + {kind: 'text', value: t('greetings.8')}, + {kind: 'text', value: t('greetings.9')}, + {kind: 'break'}, + {kind: 'prompt'}, + {kind: 'text', value: t('greetings.10')}, + ], + [t], + ); + + const {chunks} = useTypewriter(steps, {charDelay: 18}); + + const lines = useMemo( + () => + chunks.reduce( + (grouped, chunk) => { + if (chunk.kind === 'break') { + return [...grouped, {hasPrompt: false, texts: []}]; + } + + const current = grouped.at(-1)!; + const updated = + chunk.kind === 'prompt' + ? {...current, hasPrompt: true} + : {...current, texts: [...current.texts, chunk]}; + + return [...grouped.slice(0, -1), updated]; + }, + [{hasPrompt: true, texts: []}], + ), + [chunks], + ); + + return ( +
    +
    + + + + + gino@stockholm: ~ + +
    + {/* Fixed height so the page doesn't move while the greeting types. */} +
    + {/* Pre-printed so who I am is readable before any typing happens. */} +

    + + whoami +

    +

    + Carlo Gino Catapang +

    +

    + {tResume('jobTitle')} · Tre · Stockholm, Sweden +

    +

    + + ./{t('greetings.filename')} +

    +
    + {lines.map((line, index) => ( +

    + {line.hasPrompt ? : null} + {line.texts.map((chunk, chunkIndex) => ( + + {chunk.value} + + ))} + {index === lines.length - 1 ? ( + + ▍ + + ) : null} +

    + ))} +
    +
    +
    + ); +} diff --git a/src/components/home/LatestWriting.tsx b/src/components/home/LatestWriting.tsx new file mode 100644 index 000000000..c9ec417b9 --- /dev/null +++ b/src/components/home/LatestWriting.tsx @@ -0,0 +1,28 @@ +import {createTranslation} from '@/app/i18n/server'; +import NextLink from '@/components/basic/NextLink'; +import PostListItem from '@/components/blog/PostListItem'; +import SectionLabel from '@/components/SectionLabel'; +import type {IBlogMetadata} from '@/models/mdxFiles'; + +export default async function LatestWriting({blogs}: {blogs: IBlogMetadata[]}) { + const {t} = await createTranslation('home'); + + return ( +
    + {t('blogSection.kicker')} +
      + {blogs.map((blog, index) => ( +
    1. + +
    2. + ))} +
    + + {t('blogSection.viewAll')} → + +
    + ); +} diff --git a/src/components/home/NewsletterCard.tsx b/src/components/home/NewsletterCard.tsx new file mode 100644 index 000000000..e1597b739 --- /dev/null +++ b/src/components/home/NewsletterCard.tsx @@ -0,0 +1,44 @@ +'use client'; +import {useTranslation} from '@/app/i18n/client'; +import SubscribeForm from '@/components/SubscribeForm'; +import BookACallButton from '@/components/booking/BookACallButton'; +import SectionLabel from '@/components/SectionLabel'; + +/** Newsletter sign-up and the contact options, merged into one sidebar card. */ +export default function NewsletterCard() { + const {t} = useTranslation('home'); + const {t: tNewsletter} = useTranslation('newsletter'); + const {t: tCommon} = useTranslation('common'); + + return ( +
    + {tNewsletter('form.button')} +

    {t('newsletter.short')}

    + +
    + {t('contact.ratherTalk')}{' '} + {' '} + {t('contact.or')}{' '} + + {t('contact.email')} + + . +
    +
    + ); +} diff --git a/src/components/home/NowPanel.tsx b/src/components/home/NowPanel.tsx new file mode 100644 index 000000000..eacc5e2f1 --- /dev/null +++ b/src/components/home/NowPanel.tsx @@ -0,0 +1,89 @@ +import {createTranslation} from '@/app/i18n/server'; +import {BlurringImage} from '@/components/BlurringImage'; +import NextLink from '@/components/basic/NextLink'; +import type {IBlogMetadata} from '@/models/mdxFiles'; +import type {BlurImageType} from '@/utils/image-blur.utils'; +import Eyes from './Eyes'; +import SectionLabel from '@/components/SectionLabel'; +import {sideProjects} from './side-projects'; + +type Props = Pick & {latestBlog?: IBlogMetadata}; + +const termClass = 'font-mono text-sm text-ink-muted'; +const valueClass = 'm-0 text-sm'; + +export default async function NowPanel({img, svg, latestBlog}: Props) { + const {t} = await createTranslation('home'); + const {t: tResume} = await createTranslation('resume'); + + return ( + + ); +} diff --git a/src/components/home/SectionHeader.tsx b/src/components/home/SectionHeader.tsx deleted file mode 100644 index 6ad98794b..000000000 --- a/src/components/home/SectionHeader.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type {FC} from 'react'; - -const SectionHeader: FC<{ - kicker: string; - title: string; - id?: string; -}> = ({kicker, title, id}) => { - return ( -
    -

    - {kicker} -

    -

    - {title} -

    -
    - ); -}; - -export default SectionHeader; diff --git a/src/components/home/SideProjects.tsx b/src/components/home/SideProjects.tsx index 9e83bdafb..d8bdfc208 100644 --- a/src/components/home/SideProjects.tsx +++ b/src/components/home/SideProjects.tsx @@ -1,81 +1,32 @@ -import {FaExternalLinkAlt} from '@react-icons/all-files/fa/FaExternalLinkAlt'; -import {FaFish} from '@react-icons/all-files/fa/FaFish'; -import {FaMusic} from '@react-icons/all-files/fa/FaMusic'; -import {FaWind} from '@react-icons/all-files/fa/FaWind'; -import {Fade} from 'react-awesome-reveal'; -import {createTranslation} from '../../app/i18n/server'; -import NextLink from '../basic/NextLink'; -import SectionHeader from './SectionHeader'; - -const projects = [ - { - key: 'larotune', - name: 'LaroTune', - url: 'https://www.larotune.com/', - icon: FaMusic, - }, - { - key: 'breathing', - name: 'Breathing Companion', - url: 'https://exercise.larotune.com/breathing', - icon: FaWind, - }, - { - key: 'tankably', - name: 'Tankably', - url: 'https://tankably.com', - icon: FaFish, - }, -] as const; +import {createTranslation} from '@/app/i18n/server'; +import SectionLabel from '@/components/SectionLabel'; +import {sideProjects} from './side-projects'; export default async function SideProjects() { const {t} = await createTranslation('home'); return ( -
    -
    - - - - - -
    - {projects.map(({key, name, url, icon: Icon}) => ( - - + {t('sideProjects.kicker')} +
    +
    ); } diff --git a/src/components/home/SkillsHighlights.tsx b/src/components/home/SkillsHighlights.tsx deleted file mode 100644 index b1be31641..000000000 --- a/src/components/home/SkillsHighlights.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client'; -import {Fade} from 'react-awesome-reveal'; -import type {CategorizedSkill} from '../../models/skill'; -import NextLink from '../basic/NextLink'; -import SectionHeader from './SectionHeader'; -import {useTranslation} from '../../app/i18n/client'; - -export default function SkillsHighlights({ - skills, -}: { - skills: CategorizedSkill[]; -}) { - const {t} = useTranslation('home'); - - const highlights = - skills.find(c => c.category === 'highlights')?.skills ?? []; - - if (highlights.length === 0) return null; - - return ( -
    - - - -
    - {highlights.map(skill => ( - - {skill.name} - - ))} -
    - -
    - - {t('skillsMarquee.cta')} - -
    -
    -
    - ); -} diff --git a/src/components/home/side-projects.ts b/src/components/home/side-projects.ts new file mode 100644 index 000000000..b696934f7 --- /dev/null +++ b/src/components/home/side-projects.ts @@ -0,0 +1,9 @@ +export const sideProjects = [ + {key: 'larotune', name: 'LaroTune', url: 'https://www.larotune.com/'}, + { + key: 'breathing', + name: 'Breathing Companion', + url: 'https://exercise.larotune.com/breathing', + }, + {key: 'tankably', name: 'Tankably', url: 'https://tankably.com'}, +] as const; diff --git a/src/components/home/wakatime.ts b/src/components/home/wakatime.ts new file mode 100644 index 000000000..3d0f25219 --- /dev/null +++ b/src/components/home/wakatime.ts @@ -0,0 +1,55 @@ +const WAKATIME_LANGUAGES_SVG = + 'https://wakatime.com/share/@codegino/aa8c74ed-2174-445a-805d-e46a869c3b8b.svg'; + +export type LanguageShare = {name: string; percent: number}; + +const ENTITIES: Record = { + '&': '&', + '<': '<', + '>': '>', + ''': "'", + '"': '"', +}; + +// One pass, so "&lt;" becomes "<" rather than being decoded twice. +const decodeEntities = (text: string) => + text.replace(/&(?:amp|lt|gt|#39|quot);/g, entity => ENTITIES[entity]); + +/** + * Top languages from the last 7 days, as shares of code-only time. + * + * WakaTime only exposes this share as an SVG chart, so the legend entries + * ("Markdown (39.49%)") are read out of it. A WakaTime JSON share would be + * sturdier: swap this for it once one is created. + */ +export async function fetchTopLanguages(limit = 6): Promise { + try { + const response = await fetch(WAKATIME_LANGUAGES_SVG, { + next: {revalidate: 60 * 60 * 24}, + }); + if (!response.ok) return []; + + const svg = await response.text(); + const entries = [...svg.matchAll(/>([^<>]+?) \(([\d.]+)%\) ({ + name: decodeEntities(name.trim()), + percent: Number(percent), + }), + ); + + // Image files are tracked by WakaTime but aren't code. + const code = entries.filter(entry => !entry.name.startsWith('Image')); + const total = code.reduce((sum, entry) => sum + entry.percent, 0); + if (!total) return []; + + const top = code + .filter(entry => entry.name !== 'Other') + .slice(0, limit) + .map(entry => ({...entry, percent: (entry.percent / total) * 100})); + const rest = 100 - top.reduce((sum, entry) => sum + entry.percent, 0); + + return [...top, {name: 'Other', percent: rest}]; + } catch { + return []; + } +} diff --git a/src/components/icon/CustomIcon.tsx b/src/components/icon/CustomIcon.tsx deleted file mode 100644 index d5005840a..000000000 --- a/src/components/icon/CustomIcon.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; -import {useState} from 'react'; -import {IconType} from '@react-icons/all-files'; - -export default function CustomIcon(sm: IconProps) { - const [hover, setHover] = useState(false); - - return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - /> - ); -} - -export type SocialMediaProps = IconProps & { - url: string; -}; - -export type IconProps = { - size?: number; - icon: IconType; - title: string; - color: string; - hoverColor: string; -}; diff --git a/src/components/mdx.tsx b/src/components/mdx.tsx deleted file mode 100644 index bb74b5b1a..000000000 --- a/src/components/mdx.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import {evaluate, type EvaluateOptions} from '@mdx-js/mdx'; - -import * as runtime from 'react/jsx-runtime'; - -export interface MDXProps extends Omit< - EvaluateOptions, - 'Fragment' | 'jsx' | 'jsxDEV' | 'jsxs' | 'development' -> { - source: Parameters[0]; -} -export async function MDX(props: MDXProps) { - const {source, ...rest} = props; - const {default: MDXContent} = await evaluate(source, { - ...rest, - ...(runtime as Pick), - }); - - return ; -} diff --git a/src/components/social/BuyMeACoffeeIcon.tsx b/src/components/social/BuyMeACoffeeIcon.tsx deleted file mode 100644 index fa34945be..000000000 --- a/src/components/social/BuyMeACoffeeIcon.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, {FC} from 'react'; -import {SiBuymeacoffee} from '@react-icons/all-files/si/SiBuymeacoffee'; -import NextLink from '../basic/NextLink'; - -const BuyMeACoffeeIcon: FC<{className?: string}> = ({className}) => { - return ( - - - - {bmac.title} - - - ); -}; - -const bmac = { - url: 'https://l.carlogino.com/bmac', - title: 'Buy me a coffee', -}; - -export default BuyMeACoffeeIcon; diff --git a/src/components/social/SocialMedia.tsx b/src/components/social/SocialMedia.tsx deleted file mode 100644 index 9ba015a0d..000000000 --- a/src/components/social/SocialMedia.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; -import {FaDev} from '@react-icons/all-files/fa/FaDev'; -import {FaFacebookSquare} from '@react-icons/all-files/fa/FaFacebookSquare'; -import {FaGithubSquare} from '@react-icons/all-files/fa/FaGithubSquare'; -import {FaLinkedin} from '@react-icons/all-files/fa/FaLinkedin'; -import {FaPinterestSquare} from '@react-icons/all-files/fa/FaPinterestSquare'; -import {FaTwitterSquare} from '@react-icons/all-files/fa/FaTwitterSquare'; -import NextLink from '../basic/NextLink'; -import CustomIcon, {SocialMediaProps} from '../icon/CustomIcon'; - -export default function SocialMedia() { - return ( -
    - -
    - ); -} - -const socialMedia: SocialMediaProps[] = [ - { - url: 'https://dev.to/codegino', - icon: FaDev, - title: 'dev', - color: '', - hoverColor: '#333333', - }, - { - url: 'https://github.com/pogicodes', - icon: FaGithubSquare, - title: 'github', - color: '', - hoverColor: '#333333', - }, - { - url: 'https://www.linkedin.com/in/codegino', - icon: FaLinkedin, - title: 'linkedin', - color: '', - hoverColor: '#0a66c2', - }, - { - url: 'https://twitter.com/codegino', - icon: FaTwitterSquare, - title: 'twitter', - color: '', - hoverColor: '#1da1f2', - }, - { - url: 'https://pinterest.com/code_gino', - icon: FaPinterestSquare, - title: 'pinterest', - color: '', - hoverColor: '#bd081c', - }, - { - url: 'https://www.facebook.com/codegino', - icon: FaFacebookSquare, - title: 'facebook', - color: '', - hoverColor: '#1877f2', - }, -]; diff --git a/src/components/social/social-links.ts b/src/components/social/social-links.ts new file mode 100644 index 000000000..bf0acedde --- /dev/null +++ b/src/components/social/social-links.ts @@ -0,0 +1,7 @@ +export const socialLinks = [ + {label: 'GitHub', url: 'https://github.com/pogicodes'}, + {label: 'LinkedIn', url: 'https://www.linkedin.com/in/codegino'}, + {label: 'X', url: 'https://x.com/pogicodes'}, + {label: 'DEV', url: 'https://dev.to/pogicodes'}, + {label: 'Buy me a coffee', url: 'https://l.carlogino.com/bmac'}, +] as const; diff --git a/src/components/timeline/Experience.tsx b/src/components/timeline/Experience.tsx deleted file mode 100644 index 2c11be3c8..000000000 --- a/src/components/timeline/Experience.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import {useId} from 'react'; -import type {FunctionComponent} from 'react'; - -// Deterministic, stable pseudo-random rotation in the range [-1, 1] degrees. -// Derived from `useId()` so the server and client agree (no hydration mismatch) -// and the angle does not jitter on re-render. -function useRotation() { - const id = useId(); - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = (hash * 31 + id.charCodeAt(i)) | 0; - } - return (Math.abs(hash) % 201) / 100 - 1; -} - -export const Experience: FunctionComponent<{ - hasConnector?: boolean; - children: React.ReactNode; -}> = ({children}) => { - const rotation = useRotation(); - - return ( -
    - {/* Pin */} -
    -
    -
    - - {/* Sticky Note */} -
    -
    - {children} - {/* Add subtle lines to make it look more like paper */} -
    -
    -
    -
    -
    -
    - ); -}; diff --git a/src/components/timeline/Timeline.tsx b/src/components/timeline/Timeline.tsx deleted file mode 100644 index 9ec1dbf2b..000000000 --- a/src/components/timeline/Timeline.tsx +++ /dev/null @@ -1,130 +0,0 @@ -'use client'; -import {FaBriefcase} from '@react-icons/all-files/fa/FaBriefcase'; -import {FaBuilding} from '@react-icons/all-files/fa/FaBuilding'; -import {FaGraduationCap} from '@react-icons/all-files/fa/FaGraduationCap'; -import {FaScroll} from '@react-icons/all-files/fa/FaScroll'; -import dompurify from 'isomorphic-dompurify'; -import type {FunctionComponent} from 'react'; -import {Slide} from 'react-awesome-reveal'; -import {useTranslation} from '../../app/i18n/client'; -import type {EducationExperience, WorkExperience} from '../../models/resume'; -import NextLink from '../basic/NextLink'; -import {Experience} from './Experience'; - -export default function Timeline({ - workExperiences, - educationExperiences, -}: { - workExperiences: WorkExperience[]; - educationExperiences: EducationExperience[]; -}) { - return ( -
    -
    - - -

    - - Work Experiences -

    -
    -
    - {workExperiences.map(exp => ( - - - -
    - - - - ))} - - -

    - - Education -

    -
    -
    - {educationExperiences.map(exp => ( - - - - - - - - ))} -
    -
    - ); -} - -const Content: FunctionComponent<{ - exp: WorkExperience | EducationExperience; - children: React.ReactNode; -}> = ({exp, children = null}) => { - const {t} = useTranslation('common'); - - return ( - <> -
    -

    - {exp.endDate === exp.startDate ? ( - Present - ) : ( - t('date', { - val: new Date(exp.endDate), - formatParams: { - val: { - year: 'numeric', - month: 'long', - day: 'numeric', - }, - }, - }) - )} -

    -
    - {exp.category === 'work' ? ( - - ) : ( - - )} - - {exp.organization} - -
    -
    -

    {exp.title}

    -

    ({exp.role})

    -
    -
    -
    {children}
    -

    - {t('date', { - val: new Date(exp.startDate), - formatParams: { - val: { - year: 'numeric', - month: 'long', - day: 'numeric', - }, - }, - })} -

    - - ); -}; diff --git a/src/data/experiences.ts b/src/data/experiences.ts index 5564712cd..f26b33ca2 100644 --- a/src/data/experiences.ts +++ b/src/data/experiences.ts @@ -84,7 +84,7 @@ export const workExperiences: WorkExperience[] = [ organization: 'Devoteam Creative Tech', url: 'https://se.devoteam.com', startDate: '2022/08/02', - endDate: '2022/05/31', + endDate: '2023/05/31', role: 'Software Engineer', title: 'Senior Full Stack Developer', markdown: `
      @@ -99,7 +99,7 @@ export const workExperiences: WorkExperience[] = [ organization: 'Tre', url: 'https://www.tre.se/', startDate: '2023/06/01', - endDate: '2022/06/01', + endDate: '2023/06/01', role: 'Software Engineer', title: 'Senior Full Stack Developer', markdown: `

      Projects:

      diff --git a/src/modules/blog/BlogCardPreview.tsx b/src/modules/blog/BlogCardPreview.tsx deleted file mode 100644 index ff2a3ee7d..000000000 --- a/src/modules/blog/BlogCardPreview.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import NextLink from '../../components/basic/NextLink'; -import type {IBlogMetadata} from '../../models/mdxFiles'; -import {useTranslation} from '../../app/i18n/client'; -import {getBlogBanner} from '../../data/blog-asset'; - -type Props = { - blog: IBlogMetadata; -}; - -export const BlogCardPreview = ({blog}: Props) => { - const {t} = useTranslation('common'); - const banner = getBlogBanner(blog.slug); - - return ( -
      - - {/* Drafts (published: false) have no slug, so there is no banner - asset. next/image cannot take an empty src. */} - {banner ? ( - {blog.bannerDescription - ) : ( -
      - )} -
      -

      {blog.title}

      -
      -

      {blog.description}

      -

      - {t('date', { - val: new Date(blog.date), - formatParams: { - val: { - weekday: 'short', - year: 'numeric', - month: 'short', - day: 'numeric', - }, - }, - })} -

      -
      -
      - -
      - ); -}; diff --git a/src/modules/blog/BlogContent.tsx b/src/modules/blog/BlogContent.tsx index b0dbc4c0a..05367d6b8 100644 --- a/src/modules/blog/BlogContent.tsx +++ b/src/modules/blog/BlogContent.tsx @@ -1,43 +1,36 @@ -'use client'; -import type {FunctionComponent, ComponentProps, JSX} from 'react'; +import {evaluate, type EvaluateOptions} from '@mdx-js/mdx'; +import type {ComponentProps, FunctionComponent, JSX} from 'react'; +import * as runtime from 'react/jsx-runtime'; +import rehypeMdxCodeProps from 'rehype-mdx-code-props'; import ContentAnchor from '../common/AnchorTag'; import ContentBlockQuote from '../common/BlockquoteTag'; import ContentBookMark from '../common/BookmarkElement'; import ContentCodeBlock from '../common/CodeBlockElement'; import ContentImage from '../common/ImageTag'; -import ContentListElement from '../common/ListElementTag'; -import ContentParagraph from '../common/ParagraphTag'; -import TableOfContents from './TableOfContents'; -import {MDX} from '@/components/mdx'; -import rehypeMdxCodeProps from 'rehype-mdx-code-props'; +import rehypeHeadingIds, {type TocItem} from './rehype-heading-ids'; const components: ComponentProps = { inlineCode: ({children}: {children: JSX.Element}) => {children}, blockquote: ContentBlockQuote, Anchor: ContentAnchor, a: ContentAnchor, - p: ContentParagraph, - li: ContentListElement, Img: ContentImage, Bookmark: ContentBookMark, - TableOfContents: TableOfContents, + // Older posts carry a hand-written TOC; the page now builds one for every + // post from its headings, so these render nothing. + TableOfContents: () => null, pre: ContentCodeBlock, }; -type Props = { - content: string; - scope: Record | undefined; -}; +/** Compiles a post and returns its content plus the h2/h3 outline. */ +export async function renderBlog(source: string) { + const toc: TocItem[] = []; + const {default: MDXContent} = await evaluate(source, { + ...(runtime as Pick), + rehypePlugins: [rehypeMdxCodeProps, [rehypeHeadingIds, {toc}]], + }); -function BlogContent({content, scope: _scope}: Props) { - return ( - components} - source={content} - rehypePlugins={[rehypeMdxCodeProps]} - remarkPlugins={[]} - /> - ); -} + const Content = () => ; -export default BlogContent; + return {Content, toc}; +} diff --git a/src/modules/blog/BlogFooter.tsx b/src/modules/blog/BlogFooter.tsx index c50ef1b5f..389fbda9a 100644 --- a/src/modules/blog/BlogFooter.tsx +++ b/src/modules/blog/BlogFooter.tsx @@ -1,46 +1,95 @@ 'use client'; -import {FaEnvelopeOpenText} from '@react-icons/all-files/fa/FaEnvelopeOpenText'; -import {FaTwitter} from '@react-icons/all-files/fa/FaTwitter'; -import Coffee from '../../components/Coffee'; -import SubscribeButton from '../../components/basic/Button'; +import {useState} from 'react'; +import {useTranslation} from '../../app/i18n/client'; +import SectionLabel from '../../components/SectionLabel'; +import SubscribeForm from '../../components/SubscribeForm'; import NextLink from '../../components/basic/NextLink'; -import {IBlogMetadata} from '../../models/mdxFiles'; -import ShareToSocialMedia from './ShareToSocialMedia'; +import type {IBlogMetadata} from '../../models/mdxFiles'; + +const linkClass = 'text-xs lowercase transition-colors hover:text-accent'; + +function CopyLinkButton({url}: {url: string}) { + const {t} = useTranslation('blog'); + const [isCopied, setIsCopied] = useState(false); + + return ( + + ); +} const BlogFooter = ({blog}: {blog: IBlogMetadata}) => { + const {t} = useTranslation('blog'); + const {t: tNewsletter} = useTranslation('newsletter'); + const url = `https://carlogino.com/blog/${blog.slug}`; + const encodedUrl = encodeURIComponent(url); + return ( -
      - - -

      - If you find this useful and you want to support me -

      - - -

      - Get latest updates directly into your mailbox. -

      -
      - - - Subscribe to my Newsletter - -
      - -

      - Connect with me -

      + ); diff --git a/src/modules/blog/BlogHeader.tsx b/src/modules/blog/BlogHeader.tsx index 7a443b0d3..f1734018c 100644 --- a/src/modules/blog/BlogHeader.tsx +++ b/src/modules/blog/BlogHeader.tsx @@ -1,59 +1,92 @@ -'use client'; -import type {FunctionComponent} from 'react'; -import {useTranslation} from '../../app/i18n/client'; +import {createTranslation} from '../../app/i18n/server'; import {BlurringImage} from '../../components/BlurringImage'; +import NextLink from '../../components/basic/NextLink'; import type {IBlogMetadata} from '../../models/mdxFiles'; import type {BlurImageType} from '../../utils/image-blur.utils'; type Props = { blog: IBlogMetadata; + readingMinutes: number; // Optional: drafts have no banner asset mapped yet. } & Partial>; -const BlogHeader: FunctionComponent = ({blog, img, svg}) => { - const {t} = useTranslation('common'); +const formatDate = (date: string) => + new Intl.DateTimeFormat('en', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(new Date(date)); + +export default async function BlogHeader({ + blog, + readingMinutes, + img, + svg, +}: Props) { + const {t} = await createTranslation('blog'); return ( -
      -

      {blog.title}

      - {blog.description && ( -

      {blog.description}

      - )} -

      - {t('date', { - val: new Date(blog.date), - formatParams: { - val: { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }, - }, - })} +

      + + ← {t('allPosts').toLowerCase()} + +

      + {blog.title} +

      + {blog.description ? ( +

      + {blog.description} +

      + ) : null} +

      + + + · + + {t('minRead', {count: readingMinutes})} + {blog.tags?.length ? ( + <> + + · + + {blog.tags.map(tag => ( + + #{tag} + + ))} + + ) : null}

      {img && svg && !blog.hideBanner ? ( -
      - -
      +
      +
      + +
      + {blog.bannerDescription ? ( +
      + {blog.bannerDescription} +
      + ) : null} +
      ) : null} - {blog.bannerDescription ? ( - - ) : null} -
      -
      + ); -}; - -export default BlogHeader; +} diff --git a/src/modules/blog/BlogSuggestionsList.tsx b/src/modules/blog/BlogSuggestionsList.tsx deleted file mode 100644 index 9227ca945..000000000 --- a/src/modules/blog/BlogSuggestionsList.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; -import {Fade} from 'react-awesome-reveal'; -import {useTranslation} from '../../app/i18n/client'; -import NextLink from '../../components/basic/NextLink'; -import SectionHeader from '../../components/home/SectionHeader'; -import type {IBlogMetadata} from '../../models/mdxFiles'; -import {BlogCardPreview} from './BlogCardPreview'; - -type Props = { - blogs: IBlogMetadata[]; -}; - -const BlogSuggestionsList = ({blogs}: Props) => { - const {t} = useTranslation('home'); - - return ( -
      - - - -
      - {blogs.map(blog => ( - -
      - -
      -
      - ))} -
      - - {t('blogSection.viewAll')} - -
      - ); -}; - -export default BlogSuggestionsList; diff --git a/src/modules/blog/BlogsFilter.tsx b/src/modules/blog/BlogsFilter.tsx index c1160e84d..1b6a93e10 100644 --- a/src/modules/blog/BlogsFilter.tsx +++ b/src/modules/blog/BlogsFilter.tsx @@ -1,39 +1,75 @@ 'use client'; import clsx from 'clsx'; -import {type FunctionComponent} from 'react'; +import {useState, type FunctionComponent} from 'react'; import {useTranslation} from '../../app/i18n/client'; +const VISIBLE_TAGS = 8; + const BlogsFilter: FunctionComponent<{ + /** Most-used first. */ tags: string[]; selectedTags: string[]; handleTagClick: (tag: string) => void; -}> = ({tags, handleTagClick, selectedTags}) => { + onClear: () => void; +}> = ({tags, handleTagClick, selectedTags, onClear}) => { const {t} = useTranslation('blog'); + const [isExpanded, setIsExpanded] = useState(false); + + // Selected tags stay visible even when the list is collapsed. + const visibleTags = isExpanded + ? tags + : tags.filter( + (tag, index) => index < VISIBLE_TAGS || selectedTags.includes(tag), + ); + const hiddenCount = tags.length - visibleTags.length; + + const chipClass = + 'rounded px-2 py-1 font-mono text-xs transition-colors hover:bg-panel hover:text-ink'; return ( -
      -

      - {t('filterByTags')} -

      -
      - {tags.map((tag, i) => ( -
      + {t('filterByTags')} + {visibleTags.map(tag => { + const isSelected = selectedTags.includes(tag); + + return ( +
      - ))} -
      -
      + #{tag} + + ); + })} + {hiddenCount > 0 || isExpanded ? ( + + ) : null} + {selectedTags.length ? ( + + ) : null} + ); }; diff --git a/src/modules/blog/OnThisPage.tsx b/src/modules/blog/OnThisPage.tsx new file mode 100644 index 000000000..309f4a337 --- /dev/null +++ b/src/modules/blog/OnThisPage.tsx @@ -0,0 +1,108 @@ +'use client'; +import clsx from 'clsx'; +import {useEffect, useState} from 'react'; +import {useTranslation} from '../../app/i18n/client'; +import type {TocItem} from './rehype-heading-ids'; + +/** Highlights the heading the reader is currently in. */ +function useActiveId(idList: string) { + const [activeId, setActiveId] = useState(null); + + useEffect(() => { + const headings = idList + .split(' ') + .filter(Boolean) + .map(id => document.getElementById(id)) + .filter((el): el is HTMLElement => el !== null); + if (!headings.length) return; + + const observer = new IntersectionObserver( + entries => { + const visible = entries.filter(entry => entry.isIntersecting); + if (visible.length) setActiveId(visible[0].target.id); + }, + // A band near the top of the viewport, below the sticky header. + {rootMargin: '-80px 0px -70% 0px'}, + ); + headings.forEach(heading => observer.observe(heading)); + return () => observer.disconnect(); + }, [idList]); + + return activeId; +} + +function TocList({ + toc, + activeId, + onNavigate, +}: { + toc: TocItem[]; + activeId?: string | null; + onNavigate?: () => void; +}) { + return ( + + ); +} + +export default function OnThisPage({ + toc, + variant, +}: { + toc: TocItem[]; + variant: 'sidebar' | 'inline'; +}) { + const {t} = useTranslation('blog'); + // Joined so the observer only resets when the headings actually change. + const activeId = useActiveId( + variant === 'sidebar' ? toc.map(item => item.id).join(' ') : '', + ); + const [isOpen, setIsOpen] = useState(false); + + if (toc.length < 2) return null; + + if (variant === 'inline') { + return ( +
      setIsOpen(event.currentTarget.open)} + className="mb-8 rounded-lg border border-line bg-panel" + > + + {t('onThisPage')} + + +
      + ); + } + + return ( + + ); +} diff --git a/src/modules/blog/ShareToSocialMedia.tsx b/src/modules/blog/ShareToSocialMedia.tsx deleted file mode 100644 index ac4d35e4a..000000000 --- a/src/modules/blog/ShareToSocialMedia.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type {FunctionComponent} from 'react'; -import {FaFacebookSquare} from '@react-icons/all-files/fa/FaFacebookSquare'; -import {FaLinkedin} from '@react-icons/all-files/fa/FaLinkedin'; -import {FaPinterest} from '@react-icons/all-files/fa/FaPinterest'; -import {FaShareAlt} from '@react-icons/all-files/fa/FaShareAlt'; -import {FaTwitterSquare} from '@react-icons/all-files/fa/FaTwitterSquare'; -import clsx from 'clsx'; -import {IBlogMetadata} from '../../models/mdxFiles'; -import {getBlogBanner} from '../../data/blog-asset'; - -const socialMedia = [ - { - name: 'Twitter', - href: (blog: IBlogMetadata) => - `https://twitter.com/share?url=https://carlogino.com/blog/${blog.slug}&text=${blog.title}`, - color: '#1da1f2', - icon: FaTwitterSquare, - }, - { - name: 'Facebook', - href: (blog: IBlogMetadata) => - `https://www.facebook.com/sharer/sharer.php?u=https://carlogino.com/blog/${blog.slug}`, - color: '#1877f2', - icon: FaFacebookSquare, - }, - { - name: 'LinkedIn', - href: (blog: IBlogMetadata) => - `https://www.linkedin.com/sharing/share-offsite/?url=https://carlogino.com/blog/${blog.slug}`, - color: '#0a66c2', - icon: FaLinkedin, - }, - { - name: 'Pinterest', - href: (blog: IBlogMetadata) => - `http://pinterest.com/pin/create/button/?url=https://carlogino.com/blog/${blog.slug}&media=${getBlogBanner(blog.slug)}&description=${blog.description}&title=${blog.title}`, - color: '#bd081c', - icon: FaPinterest, - }, -]; - -const ShareToSocialMedia: FunctionComponent<{blog: IBlogMetadata}> = ({ - blog, -}) => { - return ( -
      -

      - Share now -

      -
      - {socialMedia.map(sm => ( - - - - {sm.name} - - ))} -
      -
      - ); -}; - -export default ShareToSocialMedia; diff --git a/src/modules/blog/TableOfContents.tsx b/src/modules/blog/TableOfContents.tsx deleted file mode 100644 index 41c62c78b..000000000 --- a/src/modules/blog/TableOfContents.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type {FunctionComponent} from 'react'; - -const TableOfContents: FunctionComponent<{ - list: { - label: string; - href: string; - }[]; -}> = ({list, ...props}) => { - return ( -
      -

      Table of Contents

      -
        - {list.map(({label, href}) => { - return ( -
      • - - {label} - -
      • - ); - })} -
      -
      - ); -}; - -export default TableOfContents; diff --git a/src/modules/blog/rehype-heading-ids.ts b/src/modules/blog/rehype-heading-ids.ts new file mode 100644 index 000000000..5ff3f9fed --- /dev/null +++ b/src/modules/blog/rehype-heading-ids.ts @@ -0,0 +1,72 @@ +export type TocItem = {id: string; label: string; depth: 2 | 3}; + +// Minimal hast shape; avoids pulling in @types/hast for one plugin. +type HastNode = { + type: string; + tagName?: string; + name?: string; + value?: string; + properties?: Record; + attributes?: {name?: string; value?: unknown}[]; + children?: HastNode[]; +}; + +const textOf = (node: HastNode): string => + node.type === 'text' + ? (node.value ?? '') + : (node.children ?? []).map(textOf).join(''); + +export const slugify = (text: string) => + text + .toLowerCase() + .trim() + .replace(/[^\p{L}\p{N}\s-]/gu, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + +/** + * Gives every h2/h3 an id and collects them into `toc`. + * + * Older posts mark headings with ``; that id is reused so + * existing links keep working and no duplicate id is created. + */ +export default function rehypeHeadingIds({toc}: {toc: TocItem[]}) { + return (tree: HastNode) => { + const usedIds = new Set(); + + const walk = (node: HastNode) => { + if ( + node.type === 'element' && + (node.tagName === 'h2' || node.tagName === 'h3') + ) { + const label = textOf(node).trim(); + if (!label) return; + + const bookmarkId = node.children + ?.find( + child => + child.type === 'mdxJsxTextElement' && child.name === 'Bookmark', + ) + ?.attributes?.find(attribute => attribute.name === 'id')?.value; + + let id: string; + if (typeof bookmarkId === 'string') { + id = bookmarkId; + } else { + const base = slugify(label) || 'section'; + id = base; + for (let n = 2; usedIds.has(id); n++) id = `${base}-${n}`; + node.properties = {...node.properties, id}; + } + + usedIds.add(id); + toc.push({id, label, depth: node.tagName === 'h2' ? 2 : 3}); + return; + } + + node.children?.forEach(walk); + }; + + walk(tree); + }; +} diff --git a/src/modules/common/AnchorTag.tsx b/src/modules/common/AnchorTag.tsx index cd7935c0f..3c017ff9d 100644 --- a/src/modules/common/AnchorTag.tsx +++ b/src/modules/common/AnchorTag.tsx @@ -1,21 +1,19 @@ -import type {HTMLAttributes} from 'react'; +import type {AnchorHTMLAttributes} from 'react'; + +const isExternal = (href?: string) => !!href && /^https?:\/\//.test(href); const BlogAnchor = ({ children, + href, ...props -}: HTMLAttributes) => { - return ( - - {children} - - ); -}; +}: AnchorHTMLAttributes) => ( + + {children} + +); export default BlogAnchor; diff --git a/src/modules/common/BlockquoteTag.tsx b/src/modules/common/BlockquoteTag.tsx index 6ecb3c0f4..842891ac7 100644 --- a/src/modules/common/BlockquoteTag.tsx +++ b/src/modules/common/BlockquoteTag.tsx @@ -1,13 +1,8 @@ import type {HTMLAttributes} from 'react'; -const BlockQuote = (props: HTMLAttributes) => { - return ( -
      - ); -}; +// Styled by `.prose-blog`. +const BlockQuote = (props: HTMLAttributes) => ( +
      +); export default BlockQuote; diff --git a/src/modules/common/CodeBlockElement.tsx b/src/modules/common/CodeBlockElement.tsx index 4163281e9..f02a59e0d 100644 --- a/src/modules/common/CodeBlockElement.tsx +++ b/src/modules/common/CodeBlockElement.tsx @@ -5,11 +5,8 @@ const PreToCodeBlock = (preProps: any) => { const props = preToCodeBlock(preProps); if (props) { - return ( -
      -        
      -      
      - ); + // CodeBlockImpl renders its own
      .
      +    return ;
         } else {
           return 
      ;
         }
      diff --git a/src/modules/common/CodeBlockImpl.tsx b/src/modules/common/CodeBlockImpl.tsx
      index a3df11ec6..cd7afe7a5 100644
      --- a/src/modules/common/CodeBlockImpl.tsx
      +++ b/src/modules/common/CodeBlockImpl.tsx
      @@ -76,98 +76,87 @@ const CodeBlockImpl: FunctionComponent = ({
           }
         }
       
      +  const showMarkers = Boolean(add || del);
      +
         return (
      -    <>
      -      
      +    
      +
      {!noHeader && ( - - +
      + {fileName ? fileName : ''} - {!noExt && languageLabel} + {!noExt && !fileName?.endsWith(languageLabel) && languageLabel} - +
      )} - {({className, style, tokens, getLineProps, getTokenProps}) => ( -
      - {!noLine && ( - - {tokens.map((_, i) => ( + {({tokens, getLineProps, getTokenProps}) => ( +
      +              
      +                {tokens.map((line, i) => {
      +                  const lineNumber = i + 1;
      +                  const isAdded = addedLines.has(lineNumber);
      +                  const isRemoved = removedLines.has(lineNumber);
      +                  const {className: lineClassName, style: lineStyle} =
      +                    getLineProps({line});
      +
      +                  return (
                           
      -                      {i + 1}
      -                      {add && addedLines.has(i + 1) && (
      -                        
      -                          +
      +                      {!noLine && (
      +                        
      +                          {lineNumber}
                               
                             )}
      -                      {del && removedLines.has(i + 1) && (
      -                        
      -                          -
      +                      {showMarkers && (
      +                        
      +                          {isAdded ? '+' : isRemoved ? '-' : ' '}
                               
                             )}
      +                      
      +                        {line.map((token, key) => (
      +                          
      +                        ))}
      +                      
                           
      -                  ))}
      -                
      -              )}
      -
      -              
      -                {tokens.map((line, i) => (
      -                  
      -                    {line.map((token, key) => (
      -                      
      -                    ))}
      -                  
      -                ))}
      -              
      -            
      + ); + })} + +
      )} - +
    {codePenID && ( -
    - - Link to Codepen - -
    + + Open in CodePen ↗ + )} - +
    ); }; diff --git a/src/modules/common/ContentCard.tsx b/src/modules/common/ContentCard.tsx deleted file mode 100644 index e08895486..000000000 --- a/src/modules/common/ContentCard.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client'; - -import type {FunctionComponent} from 'react'; -import clsx from 'clsx'; -import Image from 'next/image'; -import NextLink from '../../components/basic/NextLink'; -import type {IBlogMetadata} from '../../models/mdxFiles'; -import {ISlideMetadata} from '../../models/slide'; -import {useTranslation} from '../../app/i18n/client'; -import {getBlogBanner} from '../../data/blog-asset'; - -const BlogCard: FunctionComponent<{ - blog: IBlogMetadata | ISlideMetadata; -}> = ({blog}) => { - const {t} = useTranslation('blog'); - const banner = getBlogBanner(blog.slug); - - return ( -
    -
    - {/* Drafts (published: false) have no slug, so there is no banner - asset. next/image cannot take an empty src. */} - {banner ? ( - {blog.bannerDescription - ) : ( -
    - )} -
    -
    - -

    {blog.title}

    -

    - {blog.description} -

    -

    - {t('date', { - val: new Date(blog.date), - formatParams: { - val: { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }, - }, - })} -

    - - {blog.tags && blog.tags.length ? ( -
    - {blog.tags.map(tag => ( - - {tag} - - ))} -
    - ) : null} -
    -
    -
    - ); -}; - -export default BlogCard; diff --git a/src/modules/common/ContentLayout.tsx b/src/modules/common/ContentLayout.tsx deleted file mode 100644 index 26f761f1d..000000000 --- a/src/modules/common/ContentLayout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; -import clsx from 'clsx'; -import type {FunctionComponent} from 'react'; - -const ContentLayout: FunctionComponent<{children: React.ReactNode}> = ({ - children, - ...props -}) => { - return ( -
    pre]:my-3', - '[&_blockquote>p]:pl-2', - '[&_ul]:m-0', - '[&_:is(ol,ul)>li]:text-base [&_:is(ol,ul)>li]:leading-5 [&_:is(ol,ul)>li]:ml-8', - '[&_ol>li]:list-decimal [&_ul>li]:list-disc', - '[&_ol]:m-0 [&>p]:mx-1 [&>p]:px-1 sm:[&>p]:mx-2 sm:[&>p]:px-2', - '[&_:is(li,p,h2,h3,h4,h5,h6)_code]:whitespace-nowrap [&_:is(li,p,h2,h3,h4,h5,h6)_code]:rounded-[3px] [&_:is(li,p,h2,h3,h4,h5,h6)_code]:px-1 [&_:is(li,p,h2,h3,h4,h5,h6)_code]:font-roboto [&_:is(li,p,h2,h3,h4,h5,h6)_code]:text-[0.95rem]', - '[&_:is(li,p,h2,h3,h4,h5,h6)_code]:ring-1 [&_:is(li,p,h2,h3,h4,h5,h6)_code]:ring-neutral-300/70 dark:[&_:is(li,p,h2,h3,h4,h5,h6)_code]:ring-neutral-600/70', - '[&_:is(h2,h3,h4)_em]:text-[0.95em]', - '[&_h4]:mt-4', - )} - > - {children} -
    - ); -}; - -export default ContentLayout; diff --git a/src/modules/common/ImageTag.tsx b/src/modules/common/ImageTag.tsx index adbffe8ce..293e53dc6 100644 --- a/src/modules/common/ImageTag.tsx +++ b/src/modules/common/ImageTag.tsx @@ -1,7 +1,7 @@ import Image, {type ImageProps} from 'next/image'; const BlogImg = ({width = 1100, height = 600, ...props}: ImageProps) => ( -
    + ( alt={props.alt || 'An image of a blog post'} title={props.title || 'An image of a blog post'} placeholder="blur" - className="object-contain object-center" + sizes="(max-width: 768px) 100vw, 720px" + className="h-auto w-full rounded-lg object-contain object-center" blurDataURL="/assets/blog-placeholder.jpeg" /> -
    + ); export default BlogImg; diff --git a/src/modules/common/ListElementTag.tsx b/src/modules/common/ListElementTag.tsx deleted file mode 100644 index 7b7f79629..000000000 --- a/src/modules/common/ListElementTag.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {HTMLAttributes} from 'react'; - -const BlogListElement = ({ - children, - ...props -}: HTMLAttributes) =>
  • {children}
  • ; - -export default BlogListElement; diff --git a/src/modules/common/ParagraphTag.tsx b/src/modules/common/ParagraphTag.tsx deleted file mode 100644 index 0981eadcf..000000000 --- a/src/modules/common/ParagraphTag.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {HTMLAttributes} from 'react'; - -const BlogParagraph = ({ - children, - ...props -}: HTMLAttributes) =>

    {children}

    ; - -export default BlogParagraph; diff --git a/src/utils/scroll-to-view-hook.ts b/src/utils/scroll-to-view-hook.ts deleted file mode 100644 index d0f83ceb9..000000000 --- a/src/utils/scroll-to-view-hook.ts +++ /dev/null @@ -1,21 +0,0 @@ -'use client'; -import {useEffect, useRef} from 'react'; - -export const useScrollToView = (querySelector: string) => { - const ref = useRef(null); - - useEffect(() => { - ref.current = document.querySelector(querySelector); - }, [querySelector]); - - const scrollToContent = () => { - if (ref.current) { - ref.current.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - } - }; - - return {scrollToContent}; -}; diff --git a/styles/tailwind.css b/styles/tailwind.css index 73ebd7cef..1be269d25 100644 --- a/styles/tailwind.css +++ b/styles/tailwind.css @@ -3,16 +3,16 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme { - --color-primary-50: hsl(125, 65%, 93%); - --color-primary-100: hsl(125, 49%, 84%); - --color-primary-200: hsl(125, 42%, 74%); - --color-primary-300: hsl(125, 38%, 63%); - --color-primary-400: hsl(125, 35%, 51%); - --color-primary-500: hsl(125, 39%, 41%); - --color-primary-600: hsl(125, 47%, 35%); - --color-primary-700: hsl(125, 56%, 29%); - --color-primary-800: hsl(125, 73%, 20%); - --color-primary-900: hsl(125, 86%, 14%); + --color-primary-50: hsl(140, 65%, 93%); + --color-primary-100: hsl(140, 49%, 84%); + --color-primary-200: hsl(140, 42%, 74%); + --color-primary-300: hsl(140, 38%, 63%); + --color-primary-400: hsl(140, 35%, 51%); + --color-primary-500: hsl(140, 39%, 41%); + --color-primary-600: hsl(140, 47%, 35%); + --color-primary-700: hsl(140, 56%, 29%); + --color-primary-800: hsl(140, 73%, 20%); + --color-primary-900: hsl(140, 86%, 14%); --breakpoint-xs: 400px; @@ -28,8 +28,45 @@ /* `inline` so the font vars injected by next/font on win over :root. */ @theme inline { - --font-inter: var(--font-inter); - --font-roboto: var(--font-roboto); + --font-sans: var(--font-plex-sans), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-plex-mono), ui-monospace, SFMono-Regular, monospace; + + /* Semantic colors; values swap with the theme below. */ + --color-canvas: var(--canvas); + --color-panel: var(--panel); + --color-ink: var(--ink); + --color-ink-muted: var(--ink-muted); + --color-line: var(--line); + --color-accent: var(--accent); + --color-accent-ink: var(--accent-ink); + --color-terminal: var(--terminal); + --color-terminal-ink: var(--terminal-ink); + --color-terminal-accent: var(--terminal-accent); +} + +/* Warm neutrals with a single green accent. */ +:root { + --canvas: #f4f2ec; + --panel: #ebe8df; + --ink: #171712; + --ink-muted: #64625a; + --line: #dedbd0; + --accent: #1f7a3a; + --accent-ink: #ffffff; + --terminal: #12150f; + --terminal-ink: #dfe6d8; + --terminal-accent: #5ee08a; +} + +.dark { + --canvas: #0f110e; + --panel: #161a14; + --ink: #e6e8e1; + --ink-muted: #8b9184; + --line: #23271f; + --accent: #5ee08a; + --accent-ink: #0b0d0a; + --terminal: #0a0c09; } /* @@ -88,12 +125,15 @@ body { padding: 0; margin: 0; - @apply bg-neutral-50 dark:bg-neutral-900; - @apply text-neutral-800 dark:text-neutral-300; + @apply bg-canvas text-ink; } body { - font-family: var(--font-inter), var(--font-roboto), sans-serif; + font-family: var(--font-sans); + } + + ::selection { + @apply bg-accent text-accent-ink; } a { @@ -150,3 +190,121 @@ display: inline-block; } } + +/* Long-form post typography. Components layer so utilities still win. */ +@layer components { + .prose-blog { + @apply text-ink; + font-size: 1.0625rem; + line-height: 1.75; + + & :where(p, li, a, strong, em, td, th) { + font-size: inherit; + line-height: inherit; + } + + & :where(p) { + @apply my-5; + } + + & :where(h2, h3, h4) { + @apply text-left font-semibold tracking-tight text-ink; + scroll-margin-top: 5rem; + } + & :where(h2) { + @apply mt-14 mb-4 text-2xl; + } + & :where(h3) { + @apply mt-10 mb-3 text-xl; + } + & :where(h4) { + @apply mt-8 mb-2 text-lg; + } + & :where([id]) { + scroll-margin-top: 5rem; + } + + & :where(a) { + @apply text-accent underline decoration-1 underline-offset-4; + } + & :where(a:hover) { + @apply decoration-2; + } + + & :where(ul, ol) { + @apply my-5 pl-6; + } + & :where(ul) { + @apply list-disc; + } + & :where(ol) { + @apply list-decimal; + } + & :where(li) { + @apply my-1.5 pl-1; + } + & :where(li)::marker { + @apply text-ink-muted; + } + + & :where(blockquote) { + @apply my-6 rounded-r-lg border-l-2 border-accent bg-panel px-5 py-1; + } + & :where(blockquote p) { + @apply my-3; + } + + & :where(:not(pre) > code) { + @apply rounded border border-line bg-panel px-1.5 py-0.5 font-mono; + font-size: 0.875em; + } + + & :where(hr) { + @apply my-12 border-line; + } + + & :where(table) { + @apply my-6 w-full border-collapse text-left; + font-size: 0.9375rem; + } + & :where(th, td) { + @apply border-b border-line px-3 py-2; + } + + & :where(img) { + @apply rounded-lg; + } + + & > :first-child { + @apply mt-0; + } + + /* Posts used
    as paragraph spacing; margins handle that now. */ + & > br { + display: none; + } + } +} + +/* Print: always light, plain white page, no app chrome (see print:hidden). */ +@media print { + :root, + .dark { + --canvas: #ffffff; + --panel: #ffffff; + --ink: #000000; + --ink-muted: #3d3d38; + --line: #d4d4cc; + --accent: #1f7a3a; + --accent-ink: #ffffff; + } + + /* Everything is rem-sized, so this scales the whole resume to fit A4. */ + html { + font-size: 12.5px; + } + + @page { + margin: 14mm 16mm; + } +}