├── .env ├── .github └── renovate.json ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .yarn └── releases │ └── yarn-4.9.1.cjs ├── .yarnrc ├── .yarnrc.yml ├── LICENSE ├── README.md ├── app ├── (backnav) │ ├── blog │ │ └── [slug] │ │ │ ├── getPost.ts │ │ │ ├── loading.tsx │ │ │ ├── not-found.tsx │ │ │ ├── opengraph-image.tsx │ │ │ └── page.tsx │ └── layout.tsx ├── (main) │ ├── about │ │ ├── components │ │ │ ├── ExperienceSection.tsx │ │ │ └── SocialLinks.tsx │ │ ├── opengraph-image.tsx │ │ ├── page.tsx │ │ └── profile.png │ ├── blog │ │ ├── components │ │ │ └── Posts.tsx │ │ ├── opengraph-image.tsx │ │ └── page.tsx │ ├── components │ │ ├── FeaturedProjects.tsx │ │ ├── Pronunciation.tsx │ │ └── RecentBlogPosts.tsx │ ├── default-og.tsx │ ├── error.tsx │ ├── layout.tsx │ ├── not-found.tsx │ ├── opengraph-image.tsx │ ├── page.tsx │ └── projects │ │ ├── components │ │ └── Projects.tsx │ │ ├── opengraph-image.tsx │ │ └── page.tsx ├── DraftIndicator.tsx ├── api │ ├── disable-draft │ │ └── route.ts │ └── draft │ │ └── route.ts ├── feed.xml │ └── route.ts ├── global.css ├── layout.tsx └── studio │ └── [[...index]] │ ├── Studio.tsx │ └── page.tsx ├── assets └── fonts │ ├── made-dillan.woff │ └── space-text-medium.woff ├── components ├── ArrowLink.tsx ├── CodeBlock.tsx ├── CustomBlockContent.tsx ├── Footer.tsx ├── Icons │ ├── Speaker.tsx │ └── Star.tsx ├── NavLink.tsx ├── Navbar.tsx ├── NextSanityImage.tsx ├── OpenGraphImageStar.tsx ├── Skeleton.tsx ├── SkeletonText.tsx └── WithDividers.tsx ├── environment.d.ts ├── eslint.config.js ├── lib ├── formatDate.ts └── sanity │ ├── client.ts │ ├── config.ts │ └── studio.ts ├── next-env.d.ts ├── next-sitemap.config.js ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public ├── favicons │ ├── favicon-16x16.png │ ├── favicon-192x192.png │ ├── favicon-32x32.png │ └── favicon.ico ├── name.flac ├── name.mp3 └── profile.png ├── tailwind.config.js ├── tsconfig.json ├── types.ts ├── vercel.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SANITY_PROJECT_ID=crizldqq 2 | NEXT_PUBLIC_SANITY_DATASET=production 3 | __NEXT_TEST_MODE=1 -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "timezone": "Europe/Stockholm", 4 | "rangeStrategy": "pin", 5 | "platformAutomerge": true, 6 | "schedule": ["every weekend"], 7 | "vulnerabilityAlerts": { 8 | "schedule": null 9 | }, 10 | "lockFileMaintenance": { 11 | "enabled": true, 12 | "automerge": true, 13 | "automergeType": "pr" 14 | }, 15 | "packageRules": [ 16 | { 17 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 18 | "automerge": true 19 | }, 20 | { 21 | "matchUpdateTypes": ["major"] 22 | }, 23 | { 24 | "packagePatterns": ["^@sanity/", "sanity-plugin-*"], 25 | "groupName": "Sanity packages", 26 | "enabled": true, 27 | "rangeStrategy": "update-lockfile", 28 | "minor": { 29 | "automerge": true 30 | }, 31 | "patch": { 32 | "automerge": true 33 | }, 34 | "pin": { 35 | "automerge": true 36 | }, 37 | "lockFileMaintenance": { 38 | "automerge": true 39 | } 40 | }, 41 | { 42 | "packagePatterns": ["react", "react-dom"], 43 | "groupName": "react" 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | /static 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | /public/sitemap.xml 38 | /public/sitemap-0.xml 39 | /public/feed.xml 40 | 41 | .vscode 42 | 43 | 44 | .yarn/* 45 | !.yarn/cache 46 | !.yarn/releases 47 | !.yarn/plugins 48 | !.yarn/sdks 49 | !.yarn/versions 50 | !.yarn/patches -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.16.0 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | vercel.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": false, 5 | "bracketSpacing": true, 6 | "arrowParens": "avoid", 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | yarn-path ".yarn/releases/yarn-1.22.22.cjs" 6 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-4.9.1.cjs 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alvar Lagerlöf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Portfolio 2 | 3 | Personal portfolio and blog 4 | 5 | ## Getting started 6 | 7 | ```bash 8 | yarn install 9 | ``` 10 | 11 | ```bash 12 | yarn dev 13 | ``` 14 | 15 | ## License 16 | 17 | [MIT](LICENSE) 18 | -------------------------------------------------------------------------------- /app/(backnav)/blog/[slug]/getPost.ts: -------------------------------------------------------------------------------- 1 | import { createSanityClientWithDraftMode } from "lib/sanity/client"; 2 | import { groq } from "next-sanity"; 3 | import { cache } from "react"; 4 | import { Post } from "types"; 5 | 6 | const query = groq` 7 | *[_type == "post" && slug.current == $slug][0] { 8 | _id, 9 | title, 10 | slug, 11 | description, 12 | date, 13 | body[] { 14 | ..., 15 | markDefs[] { 16 | ..., 17 | _type == "internalLink" => { 18 | "slug": @.reference->slug 19 | } 20 | }, 21 | _type == "image" => { 22 | asset->{ 23 | _id, 24 | metadata { 25 | lqip, 26 | dimensions { 27 | width, 28 | height 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | `; 36 | 37 | export const getPost = cache(async (slug: string) => { 38 | return (await createSanityClientWithDraftMode()).fetch( 39 | query, 40 | { 41 | slug, 42 | }, 43 | { 44 | next: { 45 | revalidate: 600, 46 | }, 47 | }, 48 | ); 49 | }); 50 | -------------------------------------------------------------------------------- /app/(backnav)/blog/[slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { SkeletonText } from "components/SkeletonText"; 2 | import { WithDividers } from "components/WithDividers"; 3 | 4 | export default function Loading() { 5 | return ( 6 | 7 |
8 | 9 | 10 | 11 |
12 |
13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/(backnav)/blog/[slug]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLink } from "components/ArrowLink"; 2 | 3 | export default function BlogPostFoundNotPage() { 4 | return ( 5 |
6 |

404

7 | 8 |

9 | Blog post not found. You can find all blog posts here 10 |

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/(backnav)/blog/[slug]/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "@vercel/og"; 2 | import { getPost } from "./getPost"; 3 | import { OpenGraphImageStar } from "components/OpenGraphImageStar"; 4 | 5 | export const revalidate = 600; 6 | 7 | export const runtime = "edge"; 8 | export const size = { 9 | width: 1200, 10 | height: 630, 11 | }; 12 | 13 | const getSpaceTextFont = async () => { 14 | const response = await fetch(new URL("/assets/fonts/space-text-medium.woff", import.meta.url)); 15 | const interSemiBold = await response.arrayBuffer(); 16 | 17 | return interSemiBold; 18 | }; 19 | 20 | const getMadeDillanFont = async () => { 21 | const response = await fetch(new URL("/assets/fonts/made-dillan.woff", import.meta.url)); 22 | const interSemiBold = await response.arrayBuffer(); 23 | 24 | return interSemiBold; 25 | }; 26 | 27 | export default async function Image({ params: { slug } }: { params: { slug: string } }) { 28 | const { title, description } = await getPost(slug); 29 | 30 | return new ImageResponse( 31 | ( 32 |
43 |
49 |

57 | {title} 58 |

59 |

60 | {description} 61 |

62 |
63 | 64 |
73 |
80 | {/* eslint-disable-next-line @next/next/no-img-element */} 81 | 90 |

99 | By Alvar Lagerlöf 100 |

101 |
102 | 103 |
104 |
105 | ), 106 | { 107 | width: 1200, 108 | height: 630, 109 | fonts: [ 110 | { 111 | name: "Space Text", 112 | data: await getSpaceTextFont(), 113 | style: "normal", 114 | }, 115 | { 116 | name: "MADE Dillan", 117 | data: await getMadeDillanFont(), 118 | style: "normal", 119 | }, 120 | ], 121 | }, 122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /app/(backnav)/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { CustomBlockContent } from "components/CustomBlockContent"; 2 | import { WithDividers } from "components/WithDividers"; 3 | import { formatDate } from "lib/formatDate"; 4 | import { Metadata } from "next"; 5 | import { notFound } from "next/navigation"; 6 | import { getPost } from "./getPost"; 7 | 8 | export async function generateMetadata({ 9 | params, 10 | }: { 11 | params: Promise<{ slug: string }>; 12 | }): Promise { 13 | const post = await getPost((await params).slug); 14 | 15 | if (!post) notFound(); 16 | 17 | return { 18 | title: post.title, 19 | description: post.description, 20 | }; 21 | } 22 | 23 | export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) { 24 | const post = await getPost((await params).slug); 25 | 26 | if (!post) notFound(); 27 | 28 | return ( 29 | 30 |
31 |

{post.title}

32 |

{post.description}

33 |

34 | Published {post.date ? formatDate(post.date.published) : "No date"} 35 | {post.date && post.date.updated && ` - Updated ${formatDate(post.date.updated)}`} - by 36 | Alvar Lagerlöf 37 |

38 |
39 | 40 |
41 | 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /app/(backnav)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "components/Footer"; 2 | import Link from "next/link"; 3 | 4 | export default function BackNavLayout({ children }) { 5 | return ( 6 |
7 |
8 | {/* */} 9 | 17 | 18 |
{children}
19 | 20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/(main)/about/components/ExperienceSection.tsx: -------------------------------------------------------------------------------- 1 | import { createSanityClientWithDraftMode } from "lib/sanity/client"; 2 | import { groq } from "next-sanity"; 3 | import { Suspense } from "react"; 4 | import { ArrowLink } from "components/ArrowLink"; 5 | import { Star } from "components/Icons/Star"; 6 | import { Skeleton } from "components/Skeleton"; 7 | import { SkeletonText } from "components/SkeletonText"; 8 | import { formatDate } from "lib/formatDate"; 9 | import { Experience } from "types"; 10 | import { PortableText } from "@portabletext/react"; 11 | 12 | const query = groq` 13 | *[_type == "experience"] | order(date.start desc) { 14 | _id, 15 | company, 16 | jobTitle, 17 | employmentType, 18 | date, 19 | body, 20 | link 21 | } 22 | `; 23 | 24 | export async function ExperienceSection() { 25 | return ( 26 |
27 |

Experience

28 |
    29 | }> 30 | 31 | 32 |
33 |
34 | ); 35 | } 36 | 37 | async function ExperienceList() { 38 | const experience: Experience[] = await ( 39 | await createSanityClientWithDraftMode() 40 | ).fetch(query, undefined, { 41 | next: { revalidate: 600 }, 42 | }); 43 | 44 | return ( 45 | <> 46 | {experience.map(item => ( 47 | 56 | ))} 57 | 58 | ); 59 | } 60 | 61 | function ExperienceListLoading() { 62 | return ( 63 | <> 64 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | export function ExperienceItem({ 72 | date, 73 | company, 74 | jobTitle, 75 | body, 76 | link, 77 | employmentType, 78 | }: Omit) { 79 | const getDate = (): string => { 80 | const format: Intl.DateTimeFormatOptions = { 81 | year: "numeric", 82 | month: "short", 83 | }; 84 | 85 | if (date?.end === date?.start) { 86 | return `${formatDate(date?.end, format)}`; 87 | } 88 | 89 | if (date?.start && !date?.end) { 90 | return `${formatDate(date?.start, format)} - Present`; 91 | } 92 | 93 | return `${formatDate(date?.start, format)} - ${formatDate(date?.end, format)}`; 94 | }; 95 | 96 | return ( 97 |
  • 98 | 99 | 100 |
    101 |

    102 | {jobTitle} at {company} 103 |

    104 | 105 | {employmentType} • {getDate()} 106 | 107 |
    108 | 109 |
    110 | {link && ( 111 |
    112 | Learn more 113 |
    114 | )} 115 |
    116 |
  • 117 | ); 118 | } 119 | 120 | export function ExperienceItemLoading() { 121 | return ( 122 |
    123 | 124 | {/* hack, fix this later */} 125 |
    126 | 127 | 128 | 129 | 130 |
    131 |
    132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /app/(main)/about/components/SocialLinks.tsx: -------------------------------------------------------------------------------- 1 | import { createSanityClientWithDraftMode } from "lib/sanity/client"; 2 | import { groq } from "next-sanity"; 3 | import { Suspense } from "react"; 4 | import { Social } from "types"; 5 | import { ArrowLink } from "components/ArrowLink"; 6 | import { NextSanityImage } from "components/NextSanityImage"; 7 | import { Skeleton } from "components/Skeleton"; 8 | import { SkeletonText } from "components/SkeletonText"; 9 | 10 | const query = groq` 11 | *[_type == "social"] { 12 | _id, 13 | networkName, 14 | link, 15 | icon { 16 | asset->{ 17 | _id, 18 | metadata { 19 | lqip, 20 | dimensions { 21 | width, 22 | height 23 | } 24 | } 25 | } 26 | } 27 | } 28 | `; 29 | 30 | export async function SocialLinks() { 31 | return ( 32 |
    33 |

    Social links

    34 | 35 |
      36 | }> 37 | 38 | 39 |
    40 |
    41 | ); 42 | } 43 | 44 | async function SocialLinksList() { 45 | const socialLinks: Social[] = await ( 46 | await createSanityClientWithDraftMode() 47 | ).fetch(query, undefined, { 48 | next: { revalidate: 600 }, 49 | }); 50 | 51 | return ( 52 | <> 53 | {socialLinks.map(social => { 54 | return ( 55 | 61 | ); 62 | })} 63 | 64 | ); 65 | } 66 | 67 | function SocialLinksListLoading() { 68 | return ( 69 | <> 70 | 71 | 72 | 73 | 74 | 75 | 76 | ); 77 | } 78 | 79 | export function SocialLinkItem({ icon, networkName, link }: Omit) { 80 | return ( 81 |
  • 82 | 83 |
    84 | 91 |
    92 | 93 | {networkName} 94 |
    95 |
  • 96 | ); 97 | } 98 | 99 | export function SocialLinkItemLoading() { 100 | return ( 101 |
    102 | 103 | 104 |
    105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /app/(main)/about/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOg } from "../default-og"; 2 | 3 | export const runtime = "edge"; 4 | 5 | export const size = { 6 | width: 1200, 7 | height: 630, 8 | }; 9 | 10 | export default async function Image() { 11 | return await defaultOg("About me", "My story starts with a $2 computer from a flea market"); 12 | } 13 | -------------------------------------------------------------------------------- /app/(main)/about/page.tsx: -------------------------------------------------------------------------------- 1 | import { WithDividers } from "components/WithDividers"; 2 | import Image from "next/image"; 3 | 4 | import { ExperienceSection } from "./components/ExperienceSection"; 5 | import { SocialLinks } from "./components/SocialLinks"; 6 | import profile from "./profile.png"; 7 | 8 | export const metadata = { 9 | title: "About me", 10 | description: "My story starts with a $2 computer from a flea market", 11 | }; 12 | 13 | export default function AboutPage() { 14 | return ( 15 | 16 |
    17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | function Header() { 29 | return ( 30 |
    31 |
    32 |

    Hej

    33 |

    34 | I’m a he/him living in Stockholm, Sweden. 35 |

    36 |
    37 |
    38 | Portrait of Alvar Lagerlöf 46 |
    47 |
    48 | ); 49 | } 50 | 51 | function Story() { 52 | return ( 53 |
    54 |

    My story

    55 |
    56 |

    57 | It all began with a $2 computer. How do you ask? Well, when I was younger, I played 58 | Minecraft a lot. Naturally, I wanted to play with my friends, so I figured I'd create a 59 | server for us. 60 |

    61 |

    62 | So I went to a flea market and searched for the cheapest computer I could find. For $2, I 63 | got an absolute wreck. Not knowing what I had bought, I took it home and installed a Linux 64 | distribution on it.{" "} 65 |

    66 |

    67 | It worked better than expected. But something was missing. All "cool" servers had a 68 | website. I wanted mine to have one too. An Apache install and some typing later, there was 69 | a website. It snowballed from there. Being a developer was more fun than playing the game. 70 |

    71 |

    72 | Since then, I've experimented with many things. Everything from Android and iOS apps, to 73 | decentralized tic-tac-toe, to neural networks based. Along the way, I realized that I am 74 | becoming more and more interested in design as well. When I'm not either of those, I like 75 | to ski and take photos. 76 |

    77 |
    78 |
    79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /app/(main)/about/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/app/(main)/about/profile.png -------------------------------------------------------------------------------- /app/(main)/blog/components/Posts.tsx: -------------------------------------------------------------------------------- 1 | import { Post, PostPreview } from "types"; 2 | import { SkeletonText } from "components/SkeletonText"; 3 | import { formatDate } from "lib/formatDate"; 4 | import Link from "next/link"; 5 | 6 | type PostListProps = { 7 | posts: PostPreview[]; 8 | }; 9 | 10 | export function Posts({ posts }: PostListProps) { 11 | return ( 12 |
      13 | {posts.map((post: Post) => { 14 | return ( 15 | 22 | ); 23 | })} 24 |
    25 | ); 26 | } 27 | 28 | const truncate = (input, len) => { 29 | return input.length > len ? `${input.substring(0, len)}...` : input; 30 | }; 31 | 32 | export function PostItem({ date, slug, description, title }: Omit) { 33 | return ( 34 |
  • 35 | {date && date.published ? formatDate(date.published) : "No date"} 36 |

    37 | {title} 38 |

    39 |

    {description ? truncate(description, 100) : "No description"}

    40 |
  • 41 | ); 42 | } 43 | 44 | export function PostItemLoading({ withYear }: { withYear: boolean }) { 45 | return ( 46 |
    47 | {withYear ? :
    } 48 | 49 |
    50 | 51 | 52 | 53 |
    54 |
    55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/(main)/blog/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOg } from "../default-og"; 2 | 3 | export const runtime = "edge"; 4 | 5 | export const size = { 6 | width: 1200, 7 | height: 630, 8 | }; 9 | 10 | export default async function Image() { 11 | return await defaultOg("Blog", "I try to put my thoughts into words sometimes"); 12 | } 13 | -------------------------------------------------------------------------------- /app/(main)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLink } from "components/ArrowLink"; 2 | import { WithDividers } from "components/WithDividers"; 3 | import { createSanityClientWithDraftMode } from "lib/sanity/client"; 4 | import { groq } from "next-sanity"; 5 | import { cache, Suspense } from "react"; 6 | import { Post, PostPreview, Sections } from "types"; 7 | 8 | import { Posts } from "./components/Posts"; 9 | import { PostItemLoading } from "./components/Posts"; 10 | 11 | const query = groq` 12 | *[_type == "post"] | order(date.published desc) { 13 | _id, 14 | slug, 15 | title, 16 | description, 17 | date, 18 | body 19 | } 20 | `; 21 | 22 | export const metadata = { 23 | title: "Blog", 24 | description: "I try to put my thoughts into words sometimes", 25 | }; 26 | 27 | export default function BlogPage() { 28 | return ( 29 | 30 |
    31 |

    Blog

    32 | 33 |

    34 | I try to put my thoughts into words sometimes. RSS is available{" "} 35 | here 36 |

    37 |
    38 | 39 | }> 40 | 41 | 42 |
    43 | ); 44 | } 45 | 46 | const getSections = cache(async () => { 47 | const posts: PostPreview[] = await ( 48 | await createSanityClientWithDraftMode() 49 | ).fetch(query, undefined, { 50 | next: { 51 | revalidate: 600, 52 | }, 53 | }); 54 | 55 | const sections = posts.reduce((acc: Sections, curr: Post) => { 56 | if (curr.date === null) { 57 | return { 58 | ...acc, 59 | Drafts: acc["Drafts"] ? [...acc["Drafts"], curr] : [curr], 60 | }; 61 | } 62 | 63 | const year: number = new Date(curr.date.published).getFullYear(); 64 | 65 | return { 66 | ...acc, 67 | [year]: acc[year] ? [...acc[year], curr] : [curr], 68 | }; 69 | }, {}); 70 | 71 | return sections; 72 | }); 73 | 74 | async function Data() { 75 | const sections = await getSections(); 76 | 77 | return ( 78 | 79 | {Object.entries(sections) 80 | .sort((a, b) => { 81 | if (a[0] === "Drafts") { 82 | return -1; 83 | } 84 | 85 | if (b[0] === "Drafts") { 86 | return 1; 87 | } 88 | 89 | // @ts-expect-error TODO: Fix type error 90 | return b[0] - a[0]; 91 | }) 92 | .map(([year, posts]) => { 93 | return ( 94 |
    95 |

    96 | {year} 97 |

    98 | 99 |
    100 | ); 101 | })} 102 |
    103 | ); 104 | } 105 | 106 | function Loading() { 107 | return ( 108 | <> 109 |
    110 | 111 | 112 | 113 |
    114 |
    115 | 116 | 117 |
    118 | 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /app/(main)/components/FeaturedProjects.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLink } from "components/ArrowLink"; 2 | import { createSanityClientWithDraftMode } from "lib/sanity/client"; 3 | import { groq } from "next-sanity"; 4 | import { Suspense } from "react"; 5 | import { NextSanityImage } from "components/NextSanityImage"; 6 | import Link from "next/link"; 7 | import { Project } from "types"; 8 | 9 | const query = groq` 10 | *[_type == "project" && featured == true] [0..3] { 11 | _id, 12 | name, 13 | description, 14 | link, 15 | banner { 16 | asset->{ 17 | _id, 18 | metadata { 19 | lqip, 20 | dimensions { 21 | width, 22 | height 23 | } 24 | } 25 | } 26 | } 27 | } 28 | `; 29 | 30 | export function FeaturedProjects() { 31 | return ( 32 |
    33 |

    Featured projects

    34 | }> 35 | 36 | 37 |
    38 | ); 39 | } 40 | 41 | async function FeaturedProjectsList() { 42 | const projects: Project[] = await ( 43 | await createSanityClientWithDraftMode() 44 | ).fetch(query, undefined, { 45 | next: { revalidate: 600 }, 46 | }); 47 | 48 | return ( 49 | <> 50 |
      51 | {projects.map(project => ( 52 | 59 | ))} 60 |
    61 |

    62 | All projects 63 |

    64 | 65 | ); 66 | } 67 | 68 | function FeaturedProjectsListLoading() { 69 | return ( 70 |
    71 | 72 | 73 | 74 |
    75 | ); 76 | } 77 | 78 | export function FeaturedProjectItem({ 79 | name, 80 | link, 81 | description, 82 | banner, 83 | }: Omit) { 84 | return ( 85 |
  • 86 |
    87 | 95 |
    96 |
    97 |

    98 | 99 | {name} 100 | 101 |

    102 |

    {description}

    103 |
    104 |
  • 105 | ); 106 | } 107 | 108 | export function FeaturedProjectItemLoading() { 109 | return ( 110 |
    111 |
    112 | 113 | {/* hacky, fix this */} 114 |
    115 |
    116 |
    117 |
    118 |
    119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /app/(main)/components/Pronunciation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Speaker } from "components/Icons/Speaker"; 4 | import { use, useRef, useState, useTransition } from "react"; 5 | 6 | export function Pronunciation() { 7 | const firstRender = useRef(true); 8 | const audioRef = useRef(null); 9 | const [hasClicked, setHasClicked] = useState(false); 10 | const [isPending, startTransition] = useTransition(); 11 | 12 | if (!hasClicked) { 13 | return ( 14 | 25 | ); 26 | } 27 | 28 | async function wait() { 29 | firstRender.current = false; 30 | return undefined; 31 | } 32 | 33 | return ( 34 | <> 35 | 46 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /app/(main)/components/RecentBlogPosts.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLink } from "components/ArrowLink"; 2 | import { createSanityClientWithDraftMode } from "lib/sanity/client"; 3 | import { groq } from "next-sanity"; 4 | import { Suspense } from "react"; 5 | import Link from "next/link"; 6 | import { PostPreview } from "types"; 7 | 8 | const query = groq` 9 | *[_type == "post"] | order(date.published desc) [0..3] { 10 | _id, 11 | slug, 12 | title, 13 | description, 14 | date { 15 | published, 16 | updated 17 | } 18 | } 19 | `; 20 | 21 | export function RecentBlogPosts() { 22 | return ( 23 |
    24 |

    Recent blog posts

    25 |
      26 | }> 27 | 28 | 29 |
    30 | 31 |

    32 | All posts 33 |

    34 |
    35 | ); 36 | } 37 | 38 | async function RecentBlogPostsList() { 39 | const posts: PostPreview[] = await ( 40 | await createSanityClientWithDraftMode() 41 | ).fetch(query, undefined, { 42 | next: { revalidate: 600 }, 43 | }); 44 | 45 | return ( 46 | <> 47 | {posts.map(post => ( 48 | 54 | ))} 55 | 56 | ); 57 | } 58 | 59 | function RecentBlogPostsListLoading() { 60 | return ( 61 | <> 62 | 63 | 64 | 65 | 66 | 67 | ); 68 | } 69 | 70 | export function PostItem({ title, description, slug }: Omit) { 71 | return ( 72 |
  • 73 |

    74 | {title} 75 |

    76 |

    {description}

    77 |
  • 78 | ); 79 | } 80 | 81 | export function PostItemLoading() { 82 | return ( 83 |
    84 |
    85 |
    86 |
    87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /app/(main)/default-og.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "@vercel/og"; 2 | import { OpenGraphImageStar } from "components/OpenGraphImageStar"; 3 | 4 | const getSpaceTextFont = async () => { 5 | const response = await fetch(new URL("/assets/fonts/space-text-medium.woff", import.meta.url)); 6 | const interSemiBold = await response.arrayBuffer(); 7 | 8 | return interSemiBold; 9 | }; 10 | 11 | const getMadeDillanFont = async () => { 12 | const response = await fetch(new URL("/assets/fonts/made-dillan.woff", import.meta.url)); 13 | const interSemiBold = await response.arrayBuffer(); 14 | 15 | return interSemiBold; 16 | }; 17 | 18 | export async function defaultOg(title?: string, description?: string) { 19 | const adjustedTitle = title ? title.slice(0, 100) : "Alvar Lagerlöf"; 20 | 21 | const adjustedDescription = description 22 | ? description.slice(0, 100) 23 | : "Developer and designer from Stockholm"; 24 | 25 | return new ImageResponse( 26 | ( 27 |
    38 |
    45 | 46 | {title != "Alvar Lagerlöf" && ( 47 |

    56 | Alvar Lagerlöf 57 |

    58 | )} 59 |
    60 | 61 |
    68 |

    69 | {adjustedTitle} 70 |

    71 |

    72 | {adjustedDescription} 73 |

    74 |
    75 |
    76 | ), 77 | { 78 | width: 1200, 79 | height: 630, 80 | fonts: [ 81 | { 82 | name: "Space Text", 83 | data: await getSpaceTextFont(), 84 | style: "normal", 85 | }, 86 | { 87 | name: "MADE Dillan", 88 | data: await getMadeDillanFont(), 89 | style: "normal", 90 | }, 91 | ], 92 | }, 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /app/(main)/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function Error({ error, reset }: { error: Error; reset: () => void }) { 4 | return ( 5 |
    6 |
    7 |

    Error

    8 | 9 |

    10 | Message: 11 |
    {JSON.stringify(error, null, 2)}
    12 |

    13 |
    14 |
    15 | 18 |
    19 |
    20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "components/Footer"; 2 | import { Navbar } from "components/Navbar"; 3 | 4 | export default function MainLayout({ children }: { children: React.ReactNode }) { 5 | return ( 6 |
    7 |
    8 | <> 9 | 10 |
    {children}
    11 |
    12 | 13 |
    14 |
    15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/(main)/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLink } from "components/ArrowLink"; 2 | 3 | export default function NotFoundPage() { 4 | return ( 5 |
    6 |

    404

    7 | 8 |

    9 | Page not found. Please go home 10 |

    11 |
    12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/(main)/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOg } from "./default-og"; 2 | 3 | export const runtime = "edge"; 4 | 5 | export const size = { 6 | width: 1200, 7 | height: 630, 8 | }; 9 | 10 | export default async function Image() { 11 | return await defaultOg("Alvar Lagerlöf", "Developer and designer from Stockholm"); 12 | } 13 | -------------------------------------------------------------------------------- /app/(main)/page.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLink } from "components/ArrowLink"; 2 | import { WithDividers } from "components/WithDividers"; 3 | import { FeaturedProjects } from "./components/FeaturedProjects"; 4 | import { Pronunciation } from "./components/Pronunciation"; 5 | import { RecentBlogPosts } from "./components/RecentBlogPosts"; 6 | 7 | export const metadata = { 8 | title: "Alvar Lagerlöf", 9 | description: "Developer and designer from Stockholm", 10 | }; 11 | 12 | export default function IndexPage() { 13 | return ( 14 | 15 |
    16 |

    I'm Alvar Lagerlöf

    17 | 18 | 19 |

    20 | A developer and designer. My story starts with a $2 computer from a flea market.{" "} 21 | Learn more 22 |

    23 |
    24 | 25 | 26 | 27 | 28 | 29 |
    30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/(main)/projects/components/Projects.tsx: -------------------------------------------------------------------------------- 1 | import { createSanityClientWithDraftMode } from "lib/sanity/client"; 2 | import { groq } from "next-sanity"; 3 | import { Suspense } from "react"; 4 | import { Project } from "types"; 5 | import { NextSanityImage } from "components/NextSanityImage"; 6 | import { Skeleton } from "components/Skeleton"; 7 | import { SkeletonText } from "components/SkeletonText"; 8 | import Link from "next/link"; 9 | 10 | const query = groq` 11 | *[_type == "project"] { 12 | _id, 13 | name, 14 | description, 15 | link, 16 | banner { 17 | asset->{ 18 | _id, 19 | metadata { 20 | lqip, 21 | dimensions { 22 | width, 23 | height 24 | } 25 | } 26 | } 27 | } 28 | } 29 | `; 30 | 31 | export async function Projects() { 32 | return ( 33 |
      34 | }> 35 | 36 | 37 |
    38 | ); 39 | } 40 | 41 | async function ProjectsList() { 42 | const projects: Project[] = await ( 43 | await createSanityClientWithDraftMode() 44 | ).fetch(query, undefined, { 45 | next: { revalidate: 600 }, 46 | }); 47 | 48 | return ( 49 | <> 50 | {projects.map((project, i) => { 51 | return ( 52 | 60 | ); 61 | })} 62 | 63 | ); 64 | } 65 | 66 | export function ProjectsListLoading() { 67 | return ( 68 | <> 69 | 70 | 71 | 72 | 73 | 74 | ); 75 | } 76 | 77 | type ProjectAndIsFirst = Project & { 78 | isFirst: boolean; 79 | }; 80 | 81 | export function ProjectItem({ 82 | isFirst, 83 | banner, 84 | link, 85 | name, 86 | description, 87 | }: Omit) { 88 | return ( 89 |
  • 90 | 98 |

    99 | 100 | {name} 101 | 102 |

    103 |

    {description}

    104 |
  • 105 | ); 106 | } 107 | 108 | export function ProjectItemLoading() { 109 | return ( 110 |
    111 | 112 | 113 | 114 |
    115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /app/(main)/projects/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { defaultOg } from "../default-og"; 2 | 3 | export const runtime = "edge"; 4 | 5 | export const size = { 6 | width: 1200, 7 | height: 630, 8 | }; 9 | 10 | export default async function Image() { 11 | return await defaultOg("Projects", "These are some of the projects I've worked on"); 12 | } 13 | -------------------------------------------------------------------------------- /app/(main)/projects/page.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLink } from "components/ArrowLink"; 2 | import { WithDividers } from "components/WithDividers"; 3 | import { Projects } from "./components/Projects"; 4 | 5 | export const metadata = { 6 | title: "Projects", 7 | description: "These are some of the projects I've worked on", 8 | }; 9 | 10 | export default function ProjectsPage() { 11 | return ( 12 | 13 |
    14 |

    Projects

    15 |

    16 | You can also find all my repos on{" "} 17 | 18 | GitHub 19 | 20 |

    21 |
    22 | 23 |
    24 | 25 |
    26 |
    27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/DraftIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { draftMode } from "next/headers"; 2 | 3 | export async function DraftIndicator() { 4 | const { isEnabled } = await draftMode(); 5 | 6 | if (!isEnabled) { 7 | return null; 8 | } 9 | 10 | return ( 11 |

    Draft mode enabled

    12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/api/disable-draft/route.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { draftMode } from "next/headers"; 3 | 4 | export async function GET() { 5 | (await draftMode()).disable(); 6 | redirect("/"); 7 | } 8 | -------------------------------------------------------------------------------- /app/api/draft/route.ts: -------------------------------------------------------------------------------- 1 | // route handler enabling draft mode 2 | import { draftMode } from "next/headers"; 3 | import { redirect } from "next/navigation"; 4 | 5 | export async function GET() { 6 | (await draftMode()).enable(); 7 | //return new Response("Draft mode is enabled"); 8 | redirect("/"); 9 | } 10 | -------------------------------------------------------------------------------- /app/feed.xml/route.ts: -------------------------------------------------------------------------------- 1 | import { toHTML } from "@portabletext/to-html"; 2 | import htm from "htm"; 3 | import vhtml from "vhtml"; 4 | 5 | import { Feed } from "feed"; 6 | 7 | import { Post } from "types"; 8 | import { sanityClient } from "lib/sanity/client"; 9 | import { groq } from "next-sanity"; 10 | 11 | const html = htm.bind(vhtml); 12 | 13 | const query = groq` 14 | *[_type == "post"] | order(date.published desc) { 15 | _id, 16 | slug, 17 | title, 18 | description, 19 | date, 20 | body 21 | } 22 | `; 23 | 24 | export async function GET() { 25 | try { 26 | const posts: Post[] = await sanityClient.fetch(query); 27 | 28 | const feed = new Feed({ 29 | title: "Alvar Lagerlöf's Blog", 30 | id: "https://alvar.dev", 31 | link: "https://alvar.dev", 32 | copyright: "", 33 | language: "en-us", 34 | favicon: "http://alvar.dev/favicon.ico", 35 | description: "Developer and designer living in Stockholm", 36 | author: { 37 | name: "Alvar Lagerlöf", 38 | email: "hello@alvar.dev", 39 | link: "https://alvar.dev", 40 | }, 41 | }); 42 | 43 | posts.forEach(post => { 44 | const link = `
    (This is an article posted to my blog at alvar.dev. You can read it online by clicking here.)`; 45 | const content = 46 | toHTML(post.body, { 47 | components: { 48 | types: { 49 | image: ({ value }) => String(html``), 50 | code: ({ value }) => String(html`
    ${value.code}
    `), 51 | }, 52 | }, 53 | }) + link; 54 | 55 | if (post.date! !== null) { 56 | feed.addItem({ 57 | title: post.title, 58 | id: `https://alvar.dev/blog/${post.slug.current}`, 59 | link: `https://alvar.dev/blog/${post.slug.current}`, 60 | description: post.description, 61 | author: [ 62 | { 63 | name: "Alvar Lagerlöf", 64 | email: "hello@alvar.dev", 65 | link: "https://alvar.dev", 66 | }, 67 | ], 68 | content: content, 69 | date: new Date(post.date.published), 70 | }); 71 | } 72 | }); 73 | 74 | const rss = feed.rss2(); 75 | 76 | return new Response(rss); 77 | } catch (e: unknown) { 78 | if (e instanceof Error) { 79 | console.log(`${e.message}`); 80 | } 81 | return new Response(JSON.stringify({ error: "failed generate feed" }), { status: 500 }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/global.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @config '../tailwind.config.js'; 4 | 5 | /* 6 | The default border color has changed to `currentColor` in Tailwind CSS v4, 7 | so we've added these compatibility styles to make sure everything still 8 | looks the same as it did with Tailwind CSS v3. 9 | 10 | If we ever want to remove these styles, we need to add an explicit border 11 | color utility to any element that depends on these defaults. 12 | */ 13 | @layer base { 14 | *, 15 | ::after, 16 | ::before, 17 | ::backdrop, 18 | ::file-selector-button { 19 | border-color: var(--color-gray-200, currentColor); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import localFont from "next/font/local"; 3 | import { Metadata, Viewport } from "next"; 4 | import Script from "next/script"; 5 | 6 | import "./global.css"; 7 | import { DraftIndicator } from "./DraftIndicator"; 8 | // import { Suspense, lazy } from "react"; 9 | 10 | const inter = Inter({ 11 | variable: "--font-inter", 12 | subsets: ["latin"], 13 | }); 14 | 15 | const madeDillan = localFont({ 16 | src: "../assets/fonts/made-dillan.woff", 17 | variable: "--font-made-dillan", 18 | }); 19 | 20 | const spaceText = localFont({ 21 | src: "../assets/fonts/space-text-medium.woff", 22 | variable: "--font-space-text", 23 | }); 24 | 25 | export const viewport: Viewport = { 26 | themeColor: "#16a34a", 27 | }; 28 | 29 | export const metadata: Metadata = { 30 | openGraph: { 31 | siteName: "alvar.dev", 32 | }, 33 | twitter: { 34 | site: "@alvarlagerlof", 35 | creator: "@alvarlagerlof", 36 | card: "summary_large_image", 37 | }, 38 | icons: [ 39 | { 40 | url: "/favicons/favicon.ico", 41 | }, 42 | { 43 | url: "/favicons/favicon-16x16.png", 44 | sizes: "16x16", 45 | }, 46 | { 47 | url: "/favicons/favicon-32x32.png", 48 | sizes: "32x32", 49 | }, 50 | { 51 | url: "/favicons/favicon-192x192.png", 52 | sizes: "192x192", 53 | }, 54 | ], 55 | alternates: { 56 | types: { 57 | "application/rss+xml": "https://alvar.dev/feed.xml", 58 | }, 59 | }, 60 | metadataBase: new URL("https://alvar.dev"), 61 | }; 62 | 63 | // const RscDevtoolsPanel = lazy(() => 64 | // import("@rsc-parser/embedded").then(module => ({ 65 | // default: module.RscDevtoolsPanel, 66 | // })), 67 | // ); 68 | 69 | export default function RootLayout({ children }: React.PropsWithChildren) { 70 | return ( 71 | 72 | 73 | 74 | {children} 75 | 76 | 77 | {/* Causes TypeError: Cannot read properties of undefined (reading 'ReactCurrentDispatcher') 78 | {process.env.NODE_ENV === "development" || 79 | process.env.NEXT_PUBLIC_VERCEL_ENV === "preview" ? ( 80 | 81 | 82 | 83 | ) : null} */} 84 | 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /app/studio/[[...index]]/Studio.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { NextStudio } from "next-sanity/studio"; 4 | import { studioConfig } from "../../../lib/sanity/studio"; 5 | 6 | export function Studio() { 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /app/studio/[[...index]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Studio } from "./Studio"; 2 | import "../../global.css"; 3 | 4 | export const dynamic = "force-dynamic"; 5 | 6 | export default function StudioPage() { 7 | return ( 8 |
    9 | 10 |
    11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /assets/fonts/made-dillan.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/assets/fonts/made-dillan.woff -------------------------------------------------------------------------------- /assets/fonts/space-text-medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/assets/fonts/space-text-medium.woff -------------------------------------------------------------------------------- /components/ArrowLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { WithChildren } from "types"; 3 | 4 | type ArrowLinkProps = { 5 | href: string; 6 | newTab?: boolean; 7 | }; 8 | 9 | export function ArrowLink({ href, newTab = false, children }: WithChildren) { 10 | return ( 11 | 17 | {children} → 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { Code } from "bright"; 2 | 3 | export function CodeBlock({ language, code }: { language: string; code: string }) { 4 | return ( 5 | 6 | {code.trim()} 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /components/CustomBlockContent.tsx: -------------------------------------------------------------------------------- 1 | import { PortableText } from "@portabletext/react"; 2 | import { Suspense } from "react"; 3 | 4 | import { ArrowLink } from "./ArrowLink"; 5 | import { CodeBlock } from "./CodeBlock"; 6 | import { NextSanityImage } from "./NextSanityImage"; 7 | import { Skeleton } from "./Skeleton"; 8 | 9 | export function CustomBlockContent({ blocks }) { 10 | return ( 11 | { 16 | const { href } = value; 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | }, 24 | 25 | internalLink: ({ value, children }) => { 26 | const { 27 | slug: { current }, 28 | } = value; 29 | const href = `/blog/${current}`; 30 | 31 | return {children}; 32 | }, 33 | 34 | code: ({ children }) => { 35 | return ( 36 | 37 | {children} 38 | 39 | ); 40 | }, 41 | }, 42 | 43 | types: { 44 | code(props) { 45 | const { language, code } = props.value; 46 | return ( 47 | }> 48 | 49 | 50 | ); 51 | }, 52 | image({ value }) { 53 | return ( 54 | 59 | ); 60 | }, 61 | }, 62 | 63 | block: { 64 | h1: ({ children }) => { 65 | return ( 66 |

    {children}

    67 | ); 68 | }, 69 | h2: ({ children }) => { 70 | return ( 71 |

    {children}

    72 | ); 73 | }, 74 | blockquote: ({ children }) => { 75 | return ( 76 |
    77 | {children} 78 |
    79 | ); 80 | }, 81 | }, 82 | }} 83 | /> 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLink } from "./ArrowLink"; 2 | 3 | export function Footer() { 4 | return ( 5 |
    6 |

    7 | @alvarlagerlof 8 |

    9 |
    10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /components/Icons/Speaker.tsx: -------------------------------------------------------------------------------- 1 | export function Speaker() { 2 | return ( 3 | 11 | Green loudspeaker 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /components/Icons/Star.tsx: -------------------------------------------------------------------------------- 1 | export function Star(props) { 2 | return ( 3 | 11 | Green star with rounded tips 12 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/NavLink.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | type NavLinkProps = { 7 | href: string; 8 | name: string; 9 | }; 10 | 11 | export function NavLink({ href, name }: NavLinkProps) { 12 | const pathname = usePathname(); 13 | const active = pathname == href; 14 | 15 | return ( 16 |
  • 17 | 24 | {name} 25 | 26 |
  • 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { Star } from "./Icons/Star"; 4 | import { NavLink } from "./NavLink"; 5 | 6 | export function Navbar() { 7 | return ( 8 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/NextSanityImage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { config } from "lib/sanity/config"; 4 | import { useNextSanityImage } from "next-sanity-image"; 5 | import Image, { ImageProps } from "next/image"; 6 | import { SanityImage } from "types"; 7 | 8 | type ImagePropsWithoutSrc = Omit; 9 | 10 | type SanityImageProps = { 11 | image: SanityImage; 12 | } & ImagePropsWithoutSrc; 13 | 14 | export function NextSanityImage({ image, placeholder = "blur", ...props }: SanityImageProps) { 15 | const imageProps = useNextSanityImage({ config: () => config }, image); 16 | 17 | return ( 18 | {image?.caption 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /components/OpenGraphImageStar.tsx: -------------------------------------------------------------------------------- 1 | export function OpenGraphImageStar() { 2 | return ( 3 | 4 | 5 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | export function Skeleton({ className }: { className: string }) { 2 | return
    ; 3 | } 4 | -------------------------------------------------------------------------------- /components/SkeletonText.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "./Skeleton"; 2 | 3 | export function SkeletonText({ className }: { className: string }) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /components/WithDividers.tsx: -------------------------------------------------------------------------------- 1 | import { WithChildren } from "types"; 2 | 3 | type WithDividersProps = { 4 | direction: "horizontal" | "vertical"; 5 | }; 6 | 7 | export function WithDividers({ direction, children }: WithChildren) { 8 | const map = { 9 | vertical: "inflate-y-8 md:inflate-y-14 divide-y-2 divide-separator divide-solid", 10 | horizontal: 11 | "inflate-y-8 md:inflate-x-14 md:inflate-y-0 divide-y-2 md:divide-y-0 md:divide-x-2 divide-solid divide-separator divide-solid flex flex-col md:flex-row", 12 | }; 13 | 14 | return
    {children}
    ; 15 | } 16 | -------------------------------------------------------------------------------- /environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | NEXT_PUBLIC_SANITY_DATASET: string; 5 | NEXT_PUBLIC_SANITY_PROJECT_ID: string; 6 | } 7 | } 8 | } 9 | 10 | export {}; 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from "@eslint/js"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | import nextPlugin from "@next/eslint-plugin-next"; 7 | import reactPlugin from "eslint-plugin-react"; 8 | import hooksPlugin from "eslint-plugin-react-hooks"; 9 | 10 | export default [ 11 | { 12 | ignores: [".next/**", ".yarn/**"], 13 | }, 14 | ...[eslint.configs.recommended, ...tseslint.configs.recommended].map(conf => ({ 15 | ...conf, 16 | files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 17 | })), 18 | { 19 | files: ["**/*.ts", "**/*.tsx"], 20 | plugins: { 21 | react: reactPlugin, 22 | "react-hooks": hooksPlugin, 23 | "@next/next": nextPlugin, 24 | }, 25 | rules: { 26 | ...reactPlugin.configs["jsx-runtime"].rules, 27 | ...hooksPlugin.configs.recommended.rules, 28 | ...nextPlugin.configs.recommended.rules, 29 | ...nextPlugin.configs["core-web-vitals"].rules, 30 | "@next/next/no-img-element": "error", 31 | // Throws TypeError: context.getAncestors is not a function 32 | "@next/next/no-duplicate-head": "off", 33 | }, 34 | }, 35 | ]; 36 | -------------------------------------------------------------------------------- /lib/formatDate.ts: -------------------------------------------------------------------------------- 1 | const defaultFormat: Intl.DateTimeFormatOptions = { 2 | year: "numeric", 3 | month: "long", 4 | day: "numeric", 5 | }; 6 | 7 | export function formatDate( 8 | date: string, 9 | format: Intl.DateTimeFormatOptions = defaultFormat, 10 | ): string { 11 | return new Intl.DateTimeFormat("en-GB", format).format(new Date(date)); 12 | } 13 | -------------------------------------------------------------------------------- /lib/sanity/client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "next-sanity"; 2 | import { config } from "./config"; 3 | import { draftMode } from "next/headers"; 4 | 5 | export const sanityClient = createClient(config); 6 | 7 | export async function createSanityClientWithDraftMode() { 8 | const client = createClient(config); 9 | 10 | const { isEnabled } = await draftMode(); 11 | 12 | if (!isEnabled) { 13 | return client; 14 | } 15 | 16 | if (!process.env.SANITY_READ_TOKEN) { 17 | console.log("No read token found, not using draft mode"); 18 | return client; 19 | } 20 | 21 | return client.withConfig({ 22 | useCdn: false, 23 | ignoreBrowserTokenWarning: true, 24 | token: process.env.SANITY_READ_TOKEN, 25 | perspective: "previewDrafts", 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /lib/sanity/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET, 3 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, 4 | apiVersion: "2023-05-03", 5 | useCdn: true, 6 | }; 7 | -------------------------------------------------------------------------------- /lib/sanity/studio.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "sanity"; 2 | import { deskTool } from "sanity/desk"; 3 | import { codeInput } from "@sanity/code-input"; 4 | 5 | import { config } from "./config"; 6 | 7 | export const studioConfig = defineConfig({ 8 | basePath: "/studio", 9 | projectId: config.projectId, 10 | dataset: config.dataset, 11 | apiVersion: config.apiVersion, 12 | title: process.env.NEXT_PUBLIC_SANITY_PROJECT_TITLE || "alvar.dev", 13 | plugins: [deskTool(), codeInput()], 14 | env: { 15 | development: { 16 | plugins: ["@sanity/vision"], 17 | }, 18 | }, 19 | schema: { 20 | types: [ 21 | { 22 | name: "post", 23 | title: "Blog post", 24 | type: "document", 25 | fields: [ 26 | { 27 | name: "title", 28 | title: "Title", 29 | type: "string", 30 | }, 31 | { 32 | name: "slug", 33 | title: "Slug", 34 | type: "slug", 35 | options: { 36 | source: "title", 37 | maxLength: 96, 38 | }, 39 | }, 40 | { 41 | name: "description", 42 | title: "Description", 43 | type: "text", 44 | rows: 2, 45 | }, 46 | { 47 | title: "Dates", 48 | name: "date", 49 | type: "object", 50 | options: { 51 | collapsible: false, 52 | collapsed: false, 53 | columns: 2, 54 | }, 55 | fields: [ 56 | { name: "published", type: "date", title: "Published" }, 57 | { name: "updated", type: "date", title: "Updated" }, 58 | ], 59 | }, 60 | { 61 | title: "Body", 62 | name: "body", 63 | type: "array", 64 | of: [ 65 | { 66 | type: "block", 67 | marks: { 68 | annotations: [ 69 | { 70 | name: "link", 71 | type: "object", 72 | title: "link", 73 | fields: [ 74 | { 75 | name: "href", 76 | type: "url", 77 | }, 78 | ], 79 | }, 80 | { 81 | name: "internalLink", 82 | type: "object", 83 | title: "Internal link", 84 | fields: [ 85 | { 86 | name: "reference", 87 | type: "reference", 88 | title: "Reference", 89 | to: [{ type: "post" }], 90 | }, 91 | ], 92 | }, 93 | ], 94 | }, 95 | }, 96 | { 97 | type: "code", 98 | }, 99 | { 100 | type: "image", 101 | fields: [ 102 | { 103 | name: "caption", 104 | type: "string", 105 | title: "Caption", 106 | options: { 107 | isHighlighted: true, // <-- make this field easily accessible 108 | }, 109 | }, 110 | ], 111 | }, 112 | ], 113 | }, 114 | ], 115 | }, 116 | { 117 | name: "experience", 118 | title: "Experience", 119 | type: "document", 120 | fields: [ 121 | { 122 | name: "company", 123 | title: "Company", 124 | type: "string", 125 | }, 126 | { 127 | name: "jobTitle", 128 | title: "Job title", 129 | type: "string", 130 | }, 131 | 132 | { 133 | name: "employmentType", 134 | title: "Employment type", 135 | type: "string", 136 | }, 137 | { 138 | title: "Dates", 139 | name: "date", 140 | type: "object", 141 | options: { 142 | collapsible: false, 143 | collapsed: false, 144 | columns: 2, 145 | }, 146 | fields: [ 147 | { name: "start", type: "date", title: "Start" }, 148 | { name: "end", type: "date", title: "End" }, 149 | ], 150 | }, 151 | { 152 | title: "Body", 153 | name: "body", 154 | type: "array", 155 | of: [{ type: "block" }], 156 | }, 157 | { 158 | name: "link", 159 | title: "Link", 160 | type: "url", 161 | }, 162 | ], 163 | }, 164 | { 165 | name: "project", 166 | title: "Project", 167 | type: "document", 168 | fields: [ 169 | { 170 | name: "name", 171 | title: "Name", 172 | type: "string", 173 | }, 174 | { 175 | name: "description", 176 | title: "Description", 177 | type: "string", 178 | }, 179 | { 180 | name: "link", 181 | title: "Link", 182 | type: "url", 183 | }, 184 | { 185 | name: "featured", 186 | title: "Featured", 187 | type: "boolean", 188 | }, 189 | { 190 | name: "banner", 191 | title: "Banner image", 192 | type: "image", 193 | fields: [ 194 | { 195 | name: "caption", 196 | type: "string", 197 | title: "Caption", 198 | options: { 199 | isHighlighted: true, // <-- make this field easily accessible 200 | }, 201 | }, 202 | ], 203 | }, 204 | ], 205 | }, 206 | { 207 | name: "social", 208 | title: "Social", 209 | type: "document", 210 | fields: [ 211 | { 212 | name: "networkName", 213 | title: "Network name", 214 | type: "string", 215 | }, 216 | { 217 | name: "userName", 218 | title: "User name", 219 | type: "string", 220 | }, 221 | { 222 | name: "link", 223 | title: "Link", 224 | type: "url", 225 | validation: Rule => 226 | Rule.uri({ 227 | scheme: ["https", "mailto", "tel"], 228 | }), 229 | }, 230 | { 231 | name: "icon", 232 | title: "Icon", 233 | type: "image", 234 | fields: [ 235 | { 236 | name: "caption", 237 | type: "string", 238 | title: "Caption", 239 | options: { 240 | isHighlighted: true, // <-- make this field easily accessible 241 | }, 242 | }, 243 | ], 244 | }, 245 | ], 246 | }, 247 | ], 248 | }, 249 | }); 250 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | siteUrl: "https://alvar.dev", 3 | }; 4 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("next").NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ["cdn.sanity.io"], 5 | }, 6 | experimental: { 7 | ppr: true, 8 | }, 9 | serverExternalPackages: ["sanity", "next-sanity"], 10 | async rewrites() { 11 | return [ 12 | { 13 | source: "/feed.xml", 14 | destination: "/api/rss", 15 | }, 16 | { 17 | source: "/js/script.outbound-links.js", 18 | destination: "https://plausible.io/js/script.outbound-links.js", 19 | }, 20 | { 21 | source: "/api/event", 22 | destination: "https://plausible.io/api/event", 23 | }, 24 | ]; 25 | }, 26 | }; 27 | 28 | export default nextConfig; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portfolio", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "eslint", 11 | "format": "prettier . --check" 12 | }, 13 | "dependencies": { 14 | "@portabletext/react": "3.2.1", 15 | "@sanity/code-input": "5.1.2", 16 | "@tailwindcss/typography": "0.5.16", 17 | "@vercel/og": "0.6.8", 18 | "bright": "1.0.0", 19 | "feed": "5.1.0", 20 | "next": "15.3.3", 21 | "next-sanity": "9.12.0", 22 | "next-sanity-image": "6.2.0", 23 | "react": "19.1.0", 24 | "react-dom": "19.1.0", 25 | "sanity": "3.90.0", 26 | "styled-components": "6.1.18" 27 | }, 28 | "devDependencies": { 29 | "@portabletext/to-html": "1.0.4", 30 | "@portabletext/types": "2.0.13", 31 | "@rsc-parser/embedded": "1.1.2", 32 | "@tailwindcss/postcss": "4.1.8", 33 | "@types/react": "19.1.6", 34 | "@types/react-dom": "19.1.5", 35 | "eslint": "9.28.0", 36 | "eslint-config-next": "15.3.3", 37 | "htm": "3.1.1", 38 | "postcss": "8.5.4", 39 | "prettier": "3.5.3", 40 | "tailwindcss": "4.1.8", 41 | "typescript": "5.8.3", 42 | "typescript-eslint": "8.33.0", 43 | "vhtml": "2.2.0" 44 | }, 45 | "packageManager": "yarn@4.9.1", 46 | "resolutions": { 47 | "@types/react": "19.1.6", 48 | "@types/react-dom": "19.1.5" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/public/favicons/favicon-192x192.png -------------------------------------------------------------------------------- /public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/public/favicons/favicon.ico -------------------------------------------------------------------------------- /public/name.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/public/name.flac -------------------------------------------------------------------------------- /public/name.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/public/name.mp3 -------------------------------------------------------------------------------- /public/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alvarlagerlof/portfolio/f955c9e9871af07e771367f1df171e942e30d2a7/public/profile.png -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import colors from "tailwindcss/colors"; 2 | import typographyPlugin from "@tailwindcss/typography"; 3 | 4 | const inflatePlugin = function ({ addComponents, theme }) { 5 | const spacing = theme("spacing", {}); 6 | 7 | Object.entries(spacing).forEach(([name, padding]) => { 8 | addComponents({ 9 | [`.${`inflate-${name}`} > *`]: { padding }, 10 | [`.${`inflate-x-${name}`} > *`]: { 11 | paddingLeft: padding, 12 | paddingRight: padding, 13 | "&:first-child": { paddingLeft: 0 }, 14 | "&:last-child": { paddingRight: 0 }, 15 | }, 16 | [`.${`inflate-y-${name}`} > *`]: { 17 | paddingTop: padding, 18 | paddingBottom: padding, 19 | "&:first-child": { paddingTop: 0 }, 20 | "&:last-child": { paddingBottom: 0 }, 21 | }, 22 | }); 23 | }); 24 | }; 25 | 26 | module.exports = { 27 | content: ["./app/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"], 28 | darkMode: "media", 29 | theme: { 30 | extend: { 31 | typography: { 32 | DEFAULT: { 33 | css: { 34 | pre: false, 35 | code: false, 36 | "pre code": false, 37 | "code::before": false, 38 | "code::after": false, 39 | }, 40 | }, 41 | }, 42 | colors: { 43 | primary: colors.green[700], 44 | separator: colors.gray[400], 45 | skeleton: colors.slate[200], 46 | imgborder: colors.gray[400], 47 | }, 48 | fontFamily: { 49 | heading: ["var(--font-made-dillan)"], 50 | subheading: ["var(--font-space-text)"], 51 | }, 52 | }, 53 | }, 54 | plugins: [ 55 | inflatePlugin, 56 | // SEp 57 | typographyPlugin, 58 | ], 59 | }; 60 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ES2020", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "allowSyntheticDefaultImports": true, 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "strictNullChecks": true 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx", 30 | "next.config.mjs", 31 | "postcss.config.js", 32 | "tailwind.config.js", 33 | "next-sitemap.js", 34 | ".next/types/**/*.ts", 35 | "environment.d.ts" 36 | ], 37 | "exclude": ["node_modules"] 38 | } 39 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import { PortableTextBlock } from "@portabletext/types"; 2 | 3 | interface SanityDefaults { 4 | _id: string; 5 | } 6 | 7 | export interface SanitySlug { 8 | _type: "slug"; 9 | current: string; 10 | } 11 | 12 | export interface SanityImage { 13 | _type: "image"; 14 | asset: { 15 | _ref: string; 16 | _type: "reference"; 17 | metadata: { 18 | lqip: string; 19 | }; 20 | }; 21 | caption: string; 22 | } 23 | 24 | export type Project = SanityDefaults & { 25 | name: string; 26 | description: string; 27 | link: string; 28 | banner: SanityImage; 29 | featured: boolean; 30 | }; 31 | 32 | export type Post = SanityDefaults & { 33 | slug: SanitySlug; 34 | title: string; 35 | description: string; 36 | date: { 37 | published: string; 38 | updated: string; 39 | } | null; 40 | body: PortableTextBlock[]; 41 | }; 42 | 43 | export type PostPreview = Exclude; 44 | 45 | export interface Sections { 46 | [year: number]: Post[]; 47 | } 48 | 49 | export type Experience = SanityDefaults & { 50 | company: string; 51 | jobTitle: string; 52 | employmentType: string; 53 | date: { 54 | start: string; 55 | end: string; 56 | }; 57 | link?: string; 58 | body: PortableTextBlock[]; 59 | }; 60 | 61 | export type Social = SanityDefaults & { 62 | networkName: string; 63 | link: string; 64 | icon: SanityImage; 65 | }; 66 | 67 | export interface Children { 68 | children: React.ReactNode | string; 69 | } 70 | 71 | export type WithChildren = Type & Children; 72 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://openapi.vercel.sh/vercel.json", 3 | "buildCommand": "yarn format && yarn lint && yarn build" 4 | } 5 | --------------------------------------------------------------------------------