├── .gitignore ├── README.md ├── app ├── (more) │ ├── anime │ │ ├── anime.ts │ │ └── page.tsx │ ├── books │ │ ├── books.ts │ │ └── page.tsx │ ├── faqs │ │ ├── faqs.ts │ │ └── page.tsx │ ├── layout.tsx │ ├── music │ │ └── page.tsx │ ├── nsfw │ │ └── page.tsx │ ├── papers │ │ └── page.tsx │ ├── stats │ │ └── page.tsx │ ├── uses │ │ └── page.tsx │ └── work │ │ └── page.tsx ├── api │ ├── discord │ │ └── route.ts │ ├── lastfm │ │ └── latest │ │ │ ├── get-album-cover.ts │ │ │ ├── get-latest-song.ts │ │ │ └── route.ts │ ├── letterboxd │ │ └── latest │ │ │ ├── get-latest-film.ts │ │ │ └── route.ts │ └── og │ │ └── route.tsx ├── env.ts ├── globals.css ├── layout.tsx ├── notes │ ├── [slug] │ │ └── page.tsx │ └── page.tsx ├── page.tsx ├── robots.ts ├── rss.xml │ └── route.ts └── sitemap.ts ├── biome.json ├── bun.lockb ├── components.json ├── components ├── icons │ ├── archlinux.tsx │ ├── cloudflare.tsx │ ├── cpp.tsx │ ├── discord.tsx │ ├── docker.tsx │ ├── elixir.tsx │ ├── figma.tsx │ ├── firebase.tsx │ ├── git.tsx │ ├── godot.tsx │ ├── golang.tsx │ ├── graphql.tsx │ ├── index.ts │ ├── jest.tsx │ ├── lastfm.tsx │ ├── music.tsx │ ├── mysql.tsx │ ├── nextjs.tsx │ ├── nodejs.tsx │ ├── postgres.tsx │ ├── prisma.tsx │ ├── python.tsx │ ├── react.tsx │ ├── tailwindcss.tsx │ ├── twitter.tsx │ ├── typescript.tsx │ ├── vite.tsx │ └── vscode.tsx ├── layouts │ ├── footer.tsx │ ├── header.tsx │ ├── mdx.tsx │ ├── tweet.css │ └── tweet.tsx ├── misc │ ├── (anime) │ │ └── anime-card.tsx │ ├── (book) │ │ └── latest-book.tsx │ ├── (home) │ │ ├── cards │ │ │ ├── animelink-card.tsx │ │ │ ├── books-card.tsx │ │ │ ├── contact-card.tsx │ │ │ ├── current-time.tsx │ │ │ ├── dc-status.tsx │ │ │ ├── gh-link.tsx │ │ │ ├── gh-stats.tsx │ │ │ ├── images-card.tsx │ │ │ ├── index.ts │ │ │ ├── links-card.tsx │ │ │ ├── locate-me.tsx │ │ │ ├── music-card.tsx │ │ │ ├── stacks-card.tsx │ │ │ └── wakatime-stats.tsx │ │ ├── grid-cards.tsx │ │ └── intro.tsx │ ├── (music) │ │ ├── album-card.tsx │ │ ├── latest-song.tsx │ │ └── top-albums.tsx │ ├── (theme) │ │ ├── theme-provider.tsx │ │ └── theme-switch.tsx │ ├── (uses) │ │ ├── colophon.tsx │ │ ├── design-colors.tsx │ │ ├── my-logo.tsx │ │ ├── typography.tsx │ │ ├── uses-tab.tsx │ │ └── uses-tabs-comps.tsx │ ├── analytics.tsx │ └── emoji.tsx ├── nav │ ├── nav-drawer.tsx │ ├── navbar.tsx │ └── navmenu.tsx ├── ui │ ├── accordion.tsx │ ├── background-gradient.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── carousel.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input.tsx │ ├── label.tsx │ ├── navigation-menu.tsx │ ├── sonner.tsx │ ├── table.tsx │ ├── tabs.tsx │ └── textarea.tsx └── utils │ ├── characters.tsx │ ├── relative-date.tsx │ └── skeleton.tsx ├── content ├── demo.mdx └── ext_uses.mdx ├── hooks ├── use-has-mounted.ts └── use-latest-song.ts ├── lib ├── blog.ts ├── format-date.ts ├── get-gh-stats.ts ├── octokit.ts ├── report.ts ├── submit-form.tsx └── utils.ts ├── next.config.mjs ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── favicons │ ├── favicon-dark.ico │ └── favicon-light.ico ├── fonts │ └── kaisei-tokumin-bold.ttf ├── images │ ├── (anime) │ │ ├── angel.webp │ │ ├── aot.webp │ │ ├── attack.webp │ │ ├── coe.webp │ │ ├── darling.webp │ │ ├── game.webp │ │ ├── jujutsu.webp │ │ ├── korosensei.webp │ │ ├── lain.webp │ │ ├── promised.webp │ │ ├── rezero.webp │ │ └── sao.webp │ ├── (home) │ │ ├── 0001.jpg │ │ ├── 0002.jpg │ │ ├── 0003.jpg │ │ ├── 0004.jpg │ │ ├── 0005.jpg │ │ ├── 0006.jpg │ │ ├── 0007.jpg │ │ ├── books.jpg │ │ └── fallbackMusicCover.jpg │ ├── (misc) │ │ └── kolkata.png │ ├── (nav) │ │ ├── anime.webp │ │ ├── books.webp │ │ ├── faqs.webp │ │ ├── music.webp │ │ ├── uses.webp │ │ └── work.webp │ ├── (uses) │ │ ├── bspwm.png │ │ ├── bspwm.webp │ │ └── dwm.png │ ├── gradient.webp │ └── webp.sh ├── meta │ ├── meta.png │ └── og.png ├── public.asc └── vids │ └── cars.mp4 ├── tailwind.config.ts └── tsconfig.json /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 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 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | #self 40 | TODO.md 41 | 42 | # turbopack 43 | .turbo/ 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | frontend is boring. -------------------------------------------------------------------------------- /app/(more)/anime/anime.ts: -------------------------------------------------------------------------------- 1 | export interface Anime { 2 | title: string; 3 | altTitle?: string; 4 | href: string; 5 | imgName: string; 6 | starred?: boolean; 7 | } 8 | 9 | export const AnimeData: Anime[] = [ 10 | { 11 | title: "Serial Experiments Lain", 12 | href: "https://myanimelist.net/anime/339/Serial_Experiments_Lain", 13 | imgName: "lain", 14 | starred: true, 15 | }, 16 | { 17 | title: "Classroom of the Elite", 18 | href: "https://myanimelist.net/anime/35507/Youkoso_Jitsuryoku_Shijou_Shugi_no_Kyoushitsu_e", 19 | imgName: "coe", 20 | starred: true, 21 | }, 22 | { 23 | title: "Assassination Classroom", 24 | href: "https://myanimelist.net/anime/24833/Ansatsu_Kyoushitsu", 25 | imgName: "korosensei", 26 | starred: true, 27 | }, 28 | { 29 | title: "Darling in the FranXX", 30 | href: "https://myanimelist.net/anime/35849/Darling_in_the_FranXX", 31 | imgName: "darling", 32 | starred: true, 33 | }, 34 | { 35 | title: "Angel Beats!", 36 | href: "https://myanimelist.net/anime/6547/Angel_Beats", 37 | imgName: "angel", 38 | }, 39 | { 40 | title: "No Game No Life", 41 | href: "https://myanimelist.net/anime/19815/No_Game_No_Life", 42 | imgName: "game", 43 | }, 44 | { 45 | title: "Re:Zero kara Hajimeru Isekai Seikatsu", 46 | altTitle: "Re:Zero − Starting Life in Another World", 47 | href: "https://myanimelist.net/anime/31240/Re_Zero_kara_Hajimeru_Isekai_Seikatsu", 48 | imgName: "rezero", 49 | starred: true, 50 | }, 51 | { 52 | title: "Shingeki no Kyojin", 53 | altTitle: "Attack on Titan", 54 | href: "https://myanimelist.net/anime/16498/Shingeki_no_Kyojin", 55 | imgName: "aot", 56 | }, 57 | { 58 | title: "Sword Art Online", 59 | href: "https://myanimelist.net/anime/11757/Sword_Art_Online", 60 | imgName: "sao", 61 | }, 62 | { 63 | title: "Yakusoku no Neverland", 64 | altTitle: "The Promised Neverland", 65 | href: "https://myanimelist.net/anime/37779/Yakusoku_no_Neverland", 66 | imgName: "promised", 67 | starred: true, 68 | }, 69 | ]; 70 | -------------------------------------------------------------------------------- /app/(more)/anime/page.tsx: -------------------------------------------------------------------------------- 1 | import { AnimeCard } from "@/components/misc/(anime)/anime-card"; 2 | import type { Metadata } from "next"; 3 | import { type Anime, AnimeData } from "./anime"; 4 | 5 | export const metadata: Metadata = { 6 | title: "vimfn // anime", 7 | description: "Find a list of my fav and currently watching anime.", 8 | }; 9 | 10 | const animePage = () => { 11 | return ( 12 |
13 |
14 |

Anime

15 |

16 | I have loved watching anime since childhood. My first anime was Death 17 | Note, which is one of the most popular ones. It was suggested to me by 18 | a friend. Since then, I have watched many anime of different genres, 19 | but Isekai, Mecha, and Slice of Life are some of my favorites. 20 |

21 |
22 |
23 | {AnimeData.map((anime: Anime) => ( 24 | 32 | ))} 33 |
34 |
35 | ); 36 | }; 37 | 38 | export default animePage; 39 | -------------------------------------------------------------------------------- /app/(more)/books/books.ts: -------------------------------------------------------------------------------- 1 | export interface books { 2 | title: string; 3 | author: string; 4 | year: number; 5 | type: "audiobook" | "book"; 6 | poster: string; 7 | url: string; 8 | readingNow?: boolean; 9 | startDate?: string; 10 | } 11 | 12 | export const booksData: books[] = [ 13 | { 14 | title: "A Brief History of Time", 15 | author: "Stephen Hawking", 16 | year: 1998, 17 | type: "audiobook", 18 | poster: 19 | "https://assets.literal.club/4/ckiuwi808258410zk5mcgpjult.jpg?size=600", 20 | url: "https://literal.club/book/a-brief-history-of-time-rh9hz", 21 | readingNow: true, 22 | startDate: "2023-12-26", 23 | }, 24 | { 25 | title: "1984", 26 | author: "George Orwell", 27 | year: 1994, 28 | type: "book", 29 | poster: 30 | "https://assets.literal.club/2/cks96wdkv16083271k980gtmkfvs.jpg?size=600", 31 | url: "https://literal.club/book/bin-dokuz-yuz-seksen-dort-ofejk", 32 | }, 33 | { 34 | title: "The Great Gatsby", 35 | author: "F. Scott Fitzgerald", 36 | year: 2013, 37 | type: "book", 38 | poster: 39 | "https://assets.literal.club/cover/5/ckhkj670c04430zhwrp3k4n64.jpg?size=600", 40 | url: "https://literal.club/book/the-great-gatsby-mlbi0", 41 | }, 42 | { 43 | title: "The Lord of the Rings", 44 | author: "J.R.R. Tolkien", 45 | year: 2001, 46 | type: "book", 47 | poster: 48 | "https://assets.literal.club/4/ckiu5jcug456940z612u5mo3xp.jpg?size=600", 49 | url: "https://literal.club/book/the-lord-of-the-rings-n69jk", 50 | }, 51 | { 52 | title: "The Hound of the Baskervilles", 53 | author: "Arthur Conan Doyle", 54 | year: 2008, 55 | type: "book", 56 | poster: 57 | "https://assets.literal.club/4/cklgk3oh9147141ifbfwmmbysn.jpg?size=600", 58 | url: "https://literal.club/book/the-hound-of-the-baskervilles-jy7gm", 59 | }, 60 | { 61 | title: "The Da Vinci Code", 62 | author: "Dan Brown", 63 | year: 2010, 64 | type: "book", 65 | poster: 66 | "https://assets.literal.club/2/ckgexuagf38640y8qcxjon51e.jpg?size=600", 67 | url: "https://literal.club/book/the-da-vinci-code-npgdh", 68 | }, 69 | { 70 | title: "The Alchemist", 71 | author: "Paulo Coelho", 72 | year: 2014, 73 | type: "book", 74 | poster: 75 | "https://assets.literal.club/4/ckmbkav5b03041kjayv3n4jwh.jpg?size=600", 76 | url: "https://literal.club/book/the-alchemist-pjupi", 77 | }, 78 | { 79 | title: "The Diary of a Young Girl", 80 | author: "Anne Frank", 81 | year: 1982, 82 | type: "book", 83 | poster: 84 | "https://assets.literal.club/cover/5/ckr1u3exn17ju01crrsb5m7s6.jpg?size=600", 85 | url: "https://literal.club/book/anne-frankthe-diary-of-a-young-girl-kqfon", 86 | }, 87 | { 88 | title: "The Shadow of the Wind", 89 | author: "Carlos Ruiz Zafón", 90 | year: 2004, 91 | type: "book", 92 | poster: 93 | "https://assets.literal.club/4/ckn6dmh66108831i8djowf9emm.jpg?size=600", 94 | url: "https://literal.club/book/the-shadow-of-the-wind-idfkm", 95 | }, 96 | { 97 | title: "A Promised Land", 98 | author: "Barack Obama", 99 | year: 2020, 100 | type: "book", 101 | poster: 102 | "https://assets.literal.club/1/ckiuwfgtn111020zk55i06ub6z.jpg?size=600", 103 | url: "https://literal.club/book/a-promised-land-3fyay", 104 | }, 105 | { 106 | title: "The Girl with the Dragon Tattoo", 107 | author: "Stieg Larsson", 108 | year: 2010, 109 | type: "book", 110 | poster: 111 | "https://assets.literal.club/1/ckh0ih3ul88870z6kqd9psdir.jpg?size=600", 112 | url: "https://literal.club/book/the-girl-with-the-dragon-tattoo-0h52q", 113 | }, 114 | ]; 115 | -------------------------------------------------------------------------------- /app/(more)/books/page.tsx: -------------------------------------------------------------------------------- 1 | import { LatestBook } from "@/components/misc/(book)/latest-book"; 2 | import type { Metadata } from "next"; 3 | import { booksData } from "./books"; 4 | 5 | export const metadata: Metadata = { 6 | title: "vimfn // books", 7 | description: 8 | "Find some of my favorite book collections and the one currently reading.", 9 | }; 10 | 11 | const booksPage = () => { 12 | return ( 13 |
14 |
15 |

Books

16 |

17 | Aside from reading many lines of code, errors, and pages of 18 | documentation, when I find some time or feel like delving into a topic 19 | in depth, I often turn to books for their excellent in-depth 20 | explanations and often fun. 21 |

22 |

23 | If it is not something related to my work, or a mystery or sci-fi 24 | story book or novel, I opt for the audiobook version and enjoy it 25 | either while traveling or at bedtime. 26 |

27 | {booksData.map( 28 | (book) => book.readingNow && , 29 | )} 30 |

31 | Below are some of my favorite collections of various authors. 32 |

33 |
34 | {booksData.map( 35 | (book) => 36 | !book.readingNow && , 37 | )} 38 |
39 |
40 |
41 | ); 42 | }; 43 | 44 | export default booksPage; 45 | -------------------------------------------------------------------------------- /app/(more)/faqs/faqs.ts: -------------------------------------------------------------------------------- 1 | export interface FaqTypes { 2 | question: string; 3 | answer: string; 4 | } 5 | 6 | export const faqs: FaqTypes[] = [ 7 | { 8 | question: "When did you start coding?", 9 | answer: 10 | "Technically, I began coding around the age of 12, but I took it more seriously during the lockdown due to the COVID-19 pandemic when schools were closed.", 11 | }, 12 | { 13 | question: "Can I see your resume?", 14 | answer: 15 | "Certainly! If you're a recruiter, please contact me via email at hi@vimfn.in. You can also find my LinkedIn profile here.", 16 | }, 17 | { 18 | question: "How did you learn to code? Can you share some resources?", 19 | answer: 20 | "Mostly by playing around and building things. Documentation is the best place to learn about something, but if you are a beginner, you can start with the book Automate the Boring Stuff with Python or refer to freeCodeCamp videos on YouTube.", 21 | }, 22 | { 23 | question: "What is your favorite web framework?", 24 | answer: 25 | "Next.js is my favorite framework because of its server-side rendering, efficient routing, and seamless integration with React. Additionally, I'm keeping a close eye on Solidstart for its innovative approach, moving away from the virtual DOM and its reactive programming model.", 26 | }, 27 | { 28 | question: 29 | "What terminal emulator, code editor, theme, and font do you use?", 30 | answer: 31 | "I have written in detail about my setup on the uses page of this site, please read that.", 32 | }, 33 | { 34 | question: "Which university do you attend?", 35 | answer: "Please refrain from asking personal questions. Thank you.", 36 | }, 37 | { 38 | question: "What are some of your favorite YouTube channels?", 39 | answer: 40 | "Well, there are plenty of them, to be honest, but Fireship, Theo - t3.gg, and ThePrimeTime are some of my favorites.", 41 | }, 42 | { 43 | question: "How can I donate or sponsor you? Is there a way to do that?", 44 | answer: 45 | "I appreciate your willingness to support me. You can sponsor me on my GitHub page.", 46 | }, 47 | { 48 | question: "Where can I find your GPG keys?", 49 | answer: 50 | "If you're interested in sending me an encrypted message, you can find my GPG keys here.", 51 | }, 52 | // { 53 | // question: "", 54 | // answer: "", 55 | // }, 56 | ]; 57 | -------------------------------------------------------------------------------- /app/(more)/faqs/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionContent, 4 | AccordionItem, 5 | AccordionTrigger, 6 | } from "@/components/ui/accordion"; 7 | import type { Metadata } from "next"; 8 | import { faqs } from "./faqs"; 9 | 10 | export const metadata: Metadata = { 11 | title: "vimfn // faqs", 12 | description: 13 | "Redirected here? It means you have asked me something that has already been asked many times. Please don't think I am rude.", 14 | }; 15 | 16 | const pinsPage = () => { 17 | return ( 18 |
19 |
20 |

FAQs

21 |

22 | I've gathered some commonly asked questions here to save time for both 23 | of us. If you have any other inquiries, feel free to reach out to me 24 | at{" "} 25 | 31 | x.com 32 | 33 | {""}. Thanks! 34 |

35 |
36 | 37 | {faqs.map((faq, i) => ( 38 | 39 | 40 | 41 | {faq.question} 42 | 43 | 44 |
45 | 46 | 47 | 48 | ))} 49 |
50 | ); 51 | }; 52 | 53 | export default pinsPage; 54 | -------------------------------------------------------------------------------- /app/(more)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function MoreLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode; 5 | }) { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/(more)/music/page.tsx: -------------------------------------------------------------------------------- 1 | import { LatestSong } from "@/components/misc/(music)/latest-song"; 2 | import TopAblums from "@/components/misc/(music)/top-albums"; 3 | import type { Metadata } from "next"; 4 | 5 | export const metadata: Metadata = { 6 | title: "vimfn // music", 7 | description: "Get an Idea of my music taste.", 8 | }; 9 | 10 | const MusicPage = () => { 11 | return ( 12 |
13 |
14 |

Music

15 |

16 | Music has always been something near to my heart. Whether it's a 17 | happy day or a sad one, there is a memory linked with it, and a song 18 | that accompanies the moment. 19 |

20 | {/*

21 | You can find a screencast my 2023 Spotify wrapped{""} 22 | 27 | here 28 | 29 | {""}. 30 |

31 | */} 32 |
33 | 34 |

Fav Tracks

35 |

36 | I listen to a lot of Spotify, Over the last 12 months, I've played 37 | the song イザベラの唄 by Takahiro Obata exactly 146 times! Below you can 38 | find an up-to-date collection of my favourite songs from the past ~4 39 | weeks. 40 |

41 | 42 |
43 | ); 44 | }; 45 | 46 | export default MusicPage; 47 | -------------------------------------------------------------------------------- /app/(more)/nsfw/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | export const metadata: Metadata = { 4 | title: "vimfn // nsfw", 5 | description: "🥵", 6 | }; 7 | 8 | const nsfwPage = () => { 9 | return ( 10 |
11 |

NSFW

12 | NSFW 16 |
17 | ); 18 | }; 19 | 20 | export default nsfwPage; 21 | -------------------------------------------------------------------------------- /app/(more)/papers/page.tsx: -------------------------------------------------------------------------------- 1 | export default function PapersPage() { 2 | return ( 3 |
4 |

Papers

5 | wip. 6 |
7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /app/(more)/stats/page.tsx: -------------------------------------------------------------------------------- 1 | const StatsPage = () => { 2 | return
WIP, Please check back later.
; 3 | }; 4 | 5 | export default StatsPage; 6 | -------------------------------------------------------------------------------- /app/(more)/uses/page.tsx: -------------------------------------------------------------------------------- 1 | import { UsesTabs } from "@/components/misc/(uses)/uses-tab"; 2 | import type { Metadata } from "next/types"; 3 | 4 | export const metadata: Metadata = { 5 | title: "vimfn // uses", 6 | description: "A list of software and hardware that I use.", 7 | }; 8 | 9 | const usesPage = () => { 10 | return ( 11 |
12 |
13 |

Uses

14 | Here's a list of software and hardware that I use on a regular basis. 15 |
16 |
17 |

18 | Every once in a while someone asks me about my development environment 19 | or has questions about certain hardware. I thought it would be fun to 20 | list out everything I use here. Keep in mind, I change things around 21 | quite a bit, but I will try to keep this page updated. If I missed 22 | anything, please let me know. 23 |

24 |
25 | 26 |
27 | ); 28 | }; 29 | 30 | export default usesPage; 31 | -------------------------------------------------------------------------------- /app/api/discord/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | enum status { 4 | online = 0, 5 | idle = 1, 6 | dnd = 2, 7 | // offline, 8 | } 9 | 10 | export type LanyardResponse = { 11 | data: { 12 | discord_user: { 13 | id: string; 14 | username: string; 15 | discriminator: string; 16 | avatar: string; 17 | }; 18 | discord_status: status; 19 | active_on_discord_web: boolean; 20 | active_on_discord_desktop: boolean; 21 | active_on_discord_mobile: boolean; 22 | listening_to_spotify: boolean; 23 | activities: { 24 | id: string; 25 | name: string; 26 | type: number; 27 | state: string; 28 | timestamps: { 29 | end: number; 30 | }; 31 | emoji: { 32 | name: string; 33 | }; 34 | created_at: number; 35 | }[]; 36 | success: boolean; 37 | }; 38 | }; 39 | 40 | export const dynamic = "force-dynamic"; 41 | 42 | export const GET = async () => { 43 | const res = await fetch( 44 | "https://api.lanyard.rest/v1/users/947145304757641216", 45 | { 46 | headers: { 47 | "Content-Type": "application/json", 48 | "cache-control": "public, s-maxage=60, stale-while-revalidate=30", 49 | }, 50 | }, 51 | ); 52 | 53 | return NextResponse.json(await res.json()); 54 | }; 55 | -------------------------------------------------------------------------------- /app/api/lastfm/latest/get-album-cover.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/app/env"; 2 | 3 | const ENDPOINT = "https://api.spotify.com/v1/search?q="; 4 | 5 | const CLIENT_ID = env.SPOTIFY_CLIENT_ID as string; 6 | const CLIENT_SECRET = env.SPOTIFY_CLIENT_SECRET as string; 7 | const REFRESH_TOKEN = env.SPOTIFY_REFRESH_TOKEN as string; 8 | 9 | const BASIC = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"); 10 | 11 | const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token"; 12 | 13 | const getAccessToken = async () => { 14 | const response = await fetch(TOKEN_ENDPOINT, { 15 | method: "POST", 16 | headers: { 17 | Authorization: `Basic ${BASIC}`, 18 | "Content-Type": "application/x-www-form-urlencoded", 19 | }, 20 | body: new URLSearchParams({ 21 | grant_type: "refresh_token", 22 | refresh_token: REFRESH_TOKEN, 23 | }), 24 | }); 25 | return await response.json(); 26 | }; 27 | 28 | export const getAlbumCover = async (track: string) => { 29 | const { access_token } = await getAccessToken(); 30 | const URL = ENDPOINT + encodeURI(track) + "&type=track&market=IN&limit=1"; 31 | const res = await fetch(URL, { 32 | headers: { 33 | Authorization: `Bearer ${access_token}`, 34 | }, 35 | }); 36 | const data = await res.json(); 37 | 38 | const { name, album, external_urls, preview_url } = data.tracks.items[0]; 39 | 40 | return { 41 | name, 42 | releaseDate: album.release_date, 43 | coverArt: album.images[1], 44 | external_urls, 45 | preview_url, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /app/api/lastfm/latest/get-latest-song.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/app/env"; 2 | import { getAlbumCover } from "./get-album-cover"; 3 | 4 | const LASTFM_API = "https://ws.audioscrobbler.com/2.0"; 5 | const LASTFM_USERNAME = "vimfnx"; 6 | const LASTFM_ENDPOINT = `${LASTFM_API}?method=user.getRecentTracks&api_key=${env.LASTFM_API_TOKEN}&format=json&user=${LASTFM_USERNAME}&limit=1`; 7 | 8 | type Boolean = "0" | "1"; 9 | 10 | interface Text { 11 | "#text": T; 12 | } 13 | 14 | interface MusicBrainzID extends Text { 15 | mbid: string; 16 | } 17 | 18 | interface Image extends Text { 19 | size: "extralarge" | "large" | "medium" | "small"; 20 | } 21 | 22 | interface TrackDate extends Text { 23 | uts: string; 24 | } 25 | 26 | interface RecentTrackAttributes { 27 | nowplaying: string; 28 | } 29 | 30 | interface RecentTrack { 31 | "@attr"?: RecentTrackAttributes; 32 | album: MusicBrainzID; 33 | artist: MusicBrainzID; 34 | date?: TrackDate; 35 | image: Image[]; 36 | mbid: string; 37 | name: string; 38 | streamable: Boolean; 39 | url: string; 40 | } 41 | 42 | interface RecentTracksAttributes { 43 | page: string; 44 | perPage: string; 45 | total: string; 46 | totalPages: string; 47 | } 48 | 49 | interface RecentTracks { 50 | "@attr": RecentTracksAttributes; 51 | 52 | track: RecentTrack[]; 53 | } 54 | 55 | interface LastFmResponse { 56 | recenttracks: RecentTracks; 57 | } 58 | 59 | export interface Response { 60 | artist: string; 61 | cover?: string; 62 | date?: number; 63 | playing: boolean; 64 | title: string; 65 | url: string; 66 | year?: number; 67 | } 68 | 69 | export async function getLatestSong(): Promise { 70 | try { 71 | const response: LastFmResponse = await fetch(LASTFM_ENDPOINT, { 72 | cache: "no-store", 73 | }).then((response) => { 74 | if (!response.ok) { 75 | throw new Error("There was an error while querying the Last.fm API."); 76 | } 77 | 78 | return response.json(); 79 | }); 80 | 81 | const song = response.recenttracks?.track?.[0]; 82 | const date = song.date?.uts ? Number(song.date?.uts) : undefined; 83 | // const cover = song.image[3]["#text"] ? song.image[3]["#text"] : undefined; 84 | 85 | const sp = await getAlbumCover(song.name); 86 | 87 | const year: number = sp.releaseDate.split("-")[0]; 88 | 89 | const data = { 90 | title: song.name, 91 | artist: song.artist["#text"], 92 | year, 93 | date, 94 | url: song.url, 95 | cover: sp.coverArt.url as string, 96 | playing: Boolean(song["@attr"]?.nowplaying) ?? !date, 97 | }; 98 | return data; 99 | } catch (error) { 100 | console.error(error); 101 | return; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /app/api/lastfm/latest/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { getLatestSong } from "./get-latest-song"; 3 | 4 | export const runtime = "edge"; 5 | /** 6 | * A Route Handler fetching the latest song I listened to from Last.fm. 7 | */ 8 | export async function GET() { 9 | const song = await getLatestSong(); 10 | 11 | return song 12 | ? NextResponse.json(song) 13 | : new Response(undefined, { status: 500 }); 14 | } 15 | 16 | export const dynamic = "force-dynamic"; 17 | -------------------------------------------------------------------------------- /app/api/letterboxd/latest/get-latest-film.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from "fast-xml-parser"; 2 | import { decode } from "html-entities"; 3 | 4 | const LETTERBOXD_USERNAME = "arnvgh"; 5 | const LETTERBOXD_URL = "https://letterboxd.com"; 6 | const LETTERBOXD_FEED = `${LETTERBOXD_URL}/${LETTERBOXD_USERNAME}/rss/`; 7 | const LETTERBOXD_FILM_URL = (film: string) => `${LETTERBOXD_URL}/film/${film}/`; 8 | 9 | interface XMLParserDocument { 10 | rss: T; 11 | } 12 | 13 | interface FilmEntry { 14 | description: string; 15 | guid: string; 16 | "letterboxd:filmTitle": string; 17 | "letterboxd:filmYear": number; 18 | "letterboxd:memberRating": number; 19 | "letterboxd:rewatch": "No" | "Yes"; 20 | "letterboxd:watchedDate": string; 21 | link: string; 22 | title: string; 23 | } 24 | 25 | interface LetterboxdResponse { 26 | channel: { 27 | description: string; 28 | 29 | item: FilmEntry[]; 30 | 31 | link: string; 32 | 33 | title: string; 34 | }; 35 | } 36 | 37 | export interface Response { 38 | date: string; 39 | poster?: string; 40 | 41 | rating?: number; 42 | title: string; 43 | url: string; 44 | year: number; 45 | } 46 | 47 | // TODO: refactor 48 | export async function getLatestFilm(): Promise { 49 | try { 50 | const response = await fetch(LETTERBOXD_FEED, { 51 | method: "GET", 52 | headers: { 53 | accept: 54 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", 55 | "accept-language": "en-US,en;q=0.9", 56 | "cache-control": "max-age=0", 57 | "sec-ch-ua": 58 | '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"', 59 | "sec-ch-ua-mobile": "?0", 60 | "sec-ch-ua-platform": '"Linux"', 61 | "sec-fetch-dest": "document", 62 | "sec-fetch-mode": "navigate", 63 | "sec-fetch-site": "none", 64 | "sec-fetch-user": "?1", 65 | "upgrade-insecure-requests": "1", 66 | }, 67 | referrerPolicy: "strict-origin-when-cross-origin", 68 | mode: "cors", 69 | credentials: "include", 70 | }).then((response) => { 71 | if (!response.ok) { 72 | throw new Error( 73 | "There was an error while fetching the Letterboxd feed.", 74 | ); 75 | } 76 | return response.text(); 77 | }); 78 | 79 | const parser = new XMLParser(); 80 | const { rss }: XMLParserDocument = 81 | parser.parse(response); 82 | 83 | const [film] = rss.channel.item.sort((a, b) => 84 | b["letterboxd:watchedDate"].localeCompare(a["letterboxd:watchedDate"]), 85 | ); 86 | const [poster] = 87 | film.description.match(/(http(s?):)([\s\w./|-])*\.jpg/) ?? []; 88 | const [, slug] = film.link.match(/film\/([^/]*)\/?/) ?? []; 89 | 90 | return { 91 | title: decode(film["letterboxd:filmTitle"]), 92 | year: film["letterboxd:filmYear"], 93 | rating: film["letterboxd:memberRating"], 94 | date: film["letterboxd:watchedDate"], 95 | poster, 96 | url: LETTERBOXD_FILM_URL(slug), 97 | }; 98 | } catch (error) { 99 | console.error(error); 100 | return; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /app/api/letterboxd/latest/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { getLatestFilm } from "./get-latest-film"; 3 | 4 | /** 5 | * A Route Handler fetching the latest film I watched from Letterboxd. 6 | */ 7 | export async function GET() { 8 | const film = await getLatestFilm(); 9 | 10 | return film 11 | ? NextResponse.json(film) 12 | : new Response(undefined, { status: 500 }); 13 | } 14 | 15 | export const runtime = "edge"; 16 | 17 | export const dynamic = "force-dynamic"; 18 | -------------------------------------------------------------------------------- /app/api/og/route.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/og"; 2 | import type { NextRequest } from "next/server"; 3 | 4 | export const runtime = "edge"; 5 | 6 | export async function GET(req: NextRequest) { 7 | const { searchParams } = req.nextUrl; 8 | const postTitle = searchParams.get("title"); 9 | const font = fetch( 10 | new URL("../../../public/fonts/kaisei-tokumin-bold.ttf", import.meta.url), 11 | ).then((res) => res.arrayBuffer()); 12 | const fontData = await font; 13 | 14 | return new ImageResponse( 15 |
26 |
40 | {postTitle} 41 |
42 |
, 43 | { 44 | width: 1920, 45 | height: 1080, 46 | fonts: [ 47 | { 48 | name: "Kaisei Tokumin", 49 | data: fontData, 50 | style: "normal", 51 | }, 52 | ], 53 | }, 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /app/env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | 4 | export const env = createEnv({ 5 | server: { 6 | LASTFM_API_TOKEN: z.string().length(32), 7 | GITHUB_TOKEN: z.string().startsWith("ghp_"), 8 | WAKATIME_API_KEY: z.string().min(1), 9 | SPOTIFY_CLIENT_ID: z.string().min(1), 10 | SPOTIFY_CLIENT_SECRET: z.string().min(1), 11 | SPOTIFY_REFRESH_TOKEN: z.string().min(1), 12 | DISCORD_WEBHOOK_URL: z.string().min(30), 13 | NODE_ENV: z 14 | .enum(["development", "test", "production"]) 15 | .default("development"), 16 | }, 17 | client: { 18 | NEXT_PUBLIC_UMAMI_URL: z.string().url(), 19 | NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().uuid(), 20 | }, 21 | experimental__runtimeEnv: { 22 | NEXT_PUBLIC_UMAMI_URL: process.env.NEXT_PUBLIC_UMAMI_URL, 23 | NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "@/app/globals.css"; 4 | import { Footer } from "@/components/layouts/footer"; 5 | import Header from "@/components/layouts/header"; 6 | import { ThemeProvider } from "@/components/misc/(theme)/theme-provider"; 7 | import { Analytics } from "@/components/misc/analytics"; 8 | import { Toaster } from "@/components/ui/sonner"; 9 | import gradientImg from "@/public/images/gradient.webp"; 10 | import Image from "next/image"; 11 | 12 | const inter = Inter({ subsets: ["latin"] }); 13 | 14 | const info = { 15 | name: "arunava", 16 | twitter: "@vimfnx", 17 | description: "19yo Software Engineer, India", 18 | url: "https://vimfn.in", 19 | image: "/meta/meta.png", 20 | }; 21 | 22 | export const metadata: Metadata = { 23 | metadataBase: new URL(info.url), 24 | title: info.name, 25 | description: info.description, 26 | authors: { 27 | name: info.name, 28 | url: info.url, 29 | }, 30 | creator: info.name, 31 | openGraph: { 32 | type: "website", 33 | url: info.url, 34 | title: info.name, 35 | description: info.description, 36 | images: info.image, 37 | }, 38 | twitter: { 39 | card: "summary_large_image", 40 | title: info.name, 41 | description: info.description, 42 | creator: info.twitter, 43 | images: info.image, 44 | }, 45 | }; 46 | 47 | interface ChildrenProps { 48 | readonly children: React.ReactNode; 49 | } 50 | 51 | export default function RootLayout({ children }: ChildrenProps) { 52 | return ( 53 | 54 | 55 | 61 |
62 |
63 |
64 | {children} 65 |
66 |
67 | Gradient background 74 |
75 | 76 |
77 | 78 | 79 | 84 | 89 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /app/notes/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { CustomMDX } from "@/components/layouts/mdx"; 2 | import { getBlogPosts } from "@/lib/blog"; 3 | import { formatDate } from "@/lib/format-date"; 4 | import type { Metadata } from "next"; 5 | import { notFound } from "next/navigation"; 6 | 7 | export async function generateMetadata({ 8 | params, 9 | }: { 10 | params: Promise<{ slug: string }>; 11 | }): Promise { 12 | const slug = (await params).slug; 13 | const post = getBlogPosts().find((post) => post.slug === slug); 14 | if (!post) { 15 | return; 16 | } 17 | 18 | const { 19 | title, 20 | publishedAt: publishedTime, 21 | summary: description, 22 | image, 23 | } = post.metadata; 24 | const ogImage = image 25 | ? `https://beta.vimfn.in/${image}` 26 | : `https://beta.vimfn.in/api/og?title=${title}`; 27 | 28 | return { 29 | title, 30 | description, 31 | openGraph: { 32 | title, 33 | description, 34 | type: "article", 35 | publishedTime, 36 | url: `https://beta.vimfn.in/notes/${post.slug}`, 37 | images: [ 38 | { 39 | url: ogImage, 40 | }, 41 | ], 42 | }, 43 | twitter: { 44 | card: "summary_large_image", 45 | title, 46 | description, 47 | images: [ogImage], 48 | }, 49 | }; 50 | } 51 | 52 | export default async function Blog({ 53 | params, 54 | }: { 55 | params: Promise<{ slug: string }>; 56 | }) { 57 | const slug = (await params).slug; 58 | const post = getBlogPosts().find((post) => post.slug === slug); 59 | 60 | if (!post) { 61 | notFound(); 62 | } 63 | 64 | return ( 65 |
66 |