diff --git a/.cursorrules b/.cursorrules index ca57249..9d4cfa6 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,3 +1,4 @@ * Use Remix V2 syntax * Use TSDoc specification for docstrings * Use TailwindCSS for styling +* Use DaisyUI for components diff --git a/app/components/atoms/Copyright.tsx b/app/components/atoms/Copyright.tsx index 3f8a732..fb01f9a 100644 --- a/app/components/atoms/Copyright.tsx +++ b/app/components/atoms/Copyright.tsx @@ -5,8 +5,8 @@ */ export const Copyright = () => { return ( -
-

© 2024 Gleb Khaykin

+
+

© 2025 Gleb Khaykin

); }; diff --git a/app/components/atoms/LoadingSpinner.tsx b/app/components/atoms/LoadingSpinner.tsx deleted file mode 100644 index f163542..0000000 --- a/app/components/atoms/LoadingSpinner.tsx +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Loading spinner component - * - * @returns Loading spinner component - */ -export const LoadingSpinner = () => { - return ( -
-
-
- ); -}; diff --git a/app/components/molecules/AsciiDonut.tsx b/app/components/molecules/AsciiDonut.tsx new file mode 100644 index 0000000..de1a377 --- /dev/null +++ b/app/components/molecules/AsciiDonut.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef } from "react"; + +/** + * A component that renders an animated ASCII art donut + * Based on the donut math by Andy Sloane + * @see https://www.a1k0n.net/2011/07/20/donut-math.html + * @returns The ASCII art donut component + */ +export function AsciiDonut() { + const canvasRef = useRef(null); + + useEffect(() => { + const theta_spacing = 0.07; + const phi_spacing = 0.02; + + const R1 = 1; + const R2 = 2; + const K2 = 5; + + /** + * Render a single frame of the ASCII art donut + * @param A - The angle for the torus rotation + * @param B - The angle for the torus rotation + * @returns The ASCII art string for the frame + */ + function renderFrame(A: number, B: number) { + const screenWidth = 100; + const screenHeight = 100; + const K1 = (screenWidth * K2 * 3) / (8 * (R1 + R2)); + + const cosA = Math.cos(A), + sinA = Math.sin(A); + const cosB = Math.cos(B), + sinB = Math.sin(B); + + const chars = ".,-~:;=!*#$@".split(""); + const output: string[] = new Array(screenWidth * screenHeight).fill(" "); + const zbuffer: number[] = new Array(screenWidth * screenHeight).fill(0); + + for (let theta = 0; theta < 2 * Math.PI; theta += theta_spacing) { + const costheta = Math.cos(theta), + sintheta = Math.sin(theta); + + for (let phi = 0; phi < 2 * Math.PI; phi += phi_spacing) { + const cosphi = Math.cos(phi), + sinphi = Math.sin(phi); + + const circlex = R2 + R1 * costheta; + const circley = R1 * sintheta; + + const x = + circlex * (cosB * cosphi + sinA * sinB * sinphi) - + circley * cosA * sinB; + const y = + circlex * (sinB * cosphi - sinA * cosB * sinphi) + + circley * cosA * cosB; + const z = K2 + cosA * circlex * sinphi + circley * sinA; + const ooz = 1 / z; + + const xp = Math.floor(screenWidth / 2 + K1 * ooz * x); + const yp = Math.floor(screenHeight / 2 - K1 * ooz * y); + + const L = + cosphi * costheta * sinB - + cosA * costheta * sinphi - + sinA * sintheta + + cosB * (cosA * sintheta - costheta * sinA * sinphi); + + if (L > 0) { + const idx = xp + yp * screenWidth; + if (xp >= 0 && xp < screenWidth && yp >= 0 && yp < screenHeight) { + if (ooz > zbuffer[idx]) { + zbuffer[idx] = ooz; + const luminance_index = Math.floor(L * 8); + output[idx] = + chars[ + Math.min(Math.max(luminance_index, 0), chars.length - 1) + ]; + } + } + } + } + } + + return output.reduce((acc, char, i) => { + if (i % screenWidth === 0) return acc + "\n" + char; + return acc + char; + }, ""); + } + + let A_val = 0; + let B_val = 0; + + const animate = () => { + if (canvasRef.current) { + canvasRef.current.textContent = renderFrame(A_val, B_val); + A_val += 0.005; + B_val += 0.003; + requestAnimationFrame(animate); + } + }; + + animate(); + }, []); + + return ( +
+  );
+}
diff --git a/app/components/molecules/MenuItems.tsx b/app/components/molecules/MenuItems.tsx
deleted file mode 100644
index b448085..0000000
--- a/app/components/molecules/MenuItems.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Link } from "@remix-run/react";
-
-/**
- * Menu items component
- *
- * @returns Menu items component
- */
-export const MenuItems = () => {
-  return (
-    
    -
  • - - Blog - -
  • -
  • - - CV - -
  • -
  • - - About - -
  • -
- ); -}; diff --git a/app/components/molecules/MobileMenuItems.tsx b/app/components/molecules/MobileMenuItems.tsx deleted file mode 100644 index b719521..0000000 --- a/app/components/molecules/MobileMenuItems.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Link } from "@remix-run/react"; -import { IoMdMenu } from "react-icons/io"; - -/** - * Mobile menu items component - * - * @returns Mobile menu items component - */ -export const MobileMenuItems = () => ( -
- - - -
    -
  • - - Blog - -
  • -
    -
  • - - CV - -
  • -
    -
  • - - About - -
  • -
-
-); diff --git a/app/components/molecules/SocialMedia.tsx b/app/components/molecules/SocialMedia.tsx index 35fed56..052a3e9 100644 --- a/app/components/molecules/SocialMedia.tsx +++ b/app/components/molecules/SocialMedia.tsx @@ -1,59 +1,60 @@ -import { AiFillInstagram } from "react-icons/ai"; import { FaGithub, FaLinkedin, FaTelegram } from "react-icons/fa"; import { FaSquareXTwitter } from "react-icons/fa6"; import { ImMail4 } from "react-icons/im"; +interface SocialMediaProps { + size?: number; + displayLabels?: boolean; +} + /** * Social media component * * @returns Social media component */ -export const SocialMedia = () => { +export const SocialMedia = ({ size = 24 }: SocialMediaProps) => { + const links = [ + { + href: "https://github.com/khaykingleb", + label: "khaykingleb", + icon: FaGithub, + }, + { + href: "https://linkedin.com/in/khaykingleb", + label: "khaykingleb", + icon: FaLinkedin, + }, + { + href: "https://twitter.com/khaykingleb", + label: "@khaykingleb", + icon: FaSquareXTwitter, + }, + { + href: "https://t.me/khaykingleb_blog", + label: "@khaykingleb_blog", + icon: FaTelegram, + }, + { + href: "mailto:khaykingleb@gmail.com", + label: "khaykingleb@gmail.com", + icon: ImMail4, + }, + ]; + return ( -
- - - - - - - - - - - - - - - - - - -
+ <> + {links.map((link) => ( + + + {link.label} + + ))} + ); }; diff --git a/app/components/molecules/TagSearchBar.tsx b/app/components/molecules/TagSearchBar.tsx deleted file mode 100644 index 54fa239..0000000 --- a/app/components/molecules/TagSearchBar.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { FaCheck, FaSearch, FaTimes } from "react-icons/fa"; - -interface TextInputProps { - value: string; - onChange: (e: React.ChangeEvent) => void; - onClear: () => void; - placeholder: string; - onFocus: () => void; -} - -/** - * Text input component - * - * @param onChange - The function to call when the input value changes - * @param onClear - The function to call to clear the input - * @param placeholder - The placeholder text for the input - * @param onFocus - The function to call when the input is focused - * @returns Text input component - */ -const TextInput = ({ - value, - onChange, - onClear, - placeholder, - onFocus, -}: TextInputProps) => { - return ( -
- - - {value && ( - - )} -
- ); -}; - -interface TagItemProps { - name: string; - onClick: () => void; - checked: boolean; - isFocused: boolean; -} - -/** - * Tag item component - * - * @param onClick - The function to call when the tag is clicked - * @param checked - Whether the tag is checked - * @param isFocused - Whether the tag is focused - * @returns Tag item component - */ -const TagItem = ({ name, onClick, checked, isFocused }: TagItemProps) => { - return ( -
) => { - if (e.key === "Enter" || e.key === "Space") onClick(); - }} - className={`flex cursor-pointer items-center px-4 py-2.5 transition-colors duration-150 ease-in-out hover:bg-gray-50 ${ - isFocused ? "bg-gray-100" : "" - } focus:outline-none`} - > - - {checked && } - - {name} -
- ); -}; - -export interface TagOption { - name: string; - selected: boolean; -} - -interface TagSearchBarProps { - tagOptions: TagOption[]; - setTagOptions: (tagOptions: TagOption[]) => void; -} - -/** - * Tag search bar component - * - * @param tagOptions - The list of tag options available for selection - * @param setTagOptions - The function to update the tag options - * @returns Tag search bar component - */ -export const TagSearchBar = ({ - tagOptions, - setTagOptions, -}: TagSearchBarProps) => { - const [filterText, setFilterText] = useState(""); - const [showOptions, setShowOptions] = useState(false); - const [focusedOptionIndex, setFocusedOptionIndex] = useState(-1); - const TagSearchBarRef = useRef(null); - const optionsRef = useRef(null); - - /** - * Filter the tag options based on the filter text - */ - const filteredTagOptions = useMemo(() => { - return tagOptions.filter((option: TagOption) => - option.name.toLowerCase().includes(filterText.toLowerCase()), - ); - }, [filterText, tagOptions]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (!showOptions) { - if (e.key === "ArrowDown") { - setShowOptions(true); - setFocusedOptionIndex(0); - } - return; - } - - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - setFocusedOptionIndex( - (prev) => (prev + 1) % filteredTagOptions.length, - ); - break; - case "ArrowUp": - e.preventDefault(); - setFocusedOptionIndex( - (prev) => - (prev - 1 + filteredTagOptions.length) % - filteredTagOptions.length, - ); - break; - case "Enter": - case " ": // Space key - e.preventDefault(); - if (focusedOptionIndex !== -1) { - const selectedTag = filteredTagOptions[focusedOptionIndex]; - setTagOptions( - tagOptions.map((tag) => - tag.name === selectedTag.name - ? { ...tag, selected: !tag.selected } - : tag, - ), - ); - } - break; - case "Escape": - setShowOptions(false); - setFocusedOptionIndex(-1); - break; - } - }, - [ - filteredTagOptions, - focusedOptionIndex, - showOptions, - setTagOptions, - tagOptions, - ], - ); - - useEffect(() => { - if (focusedOptionIndex !== -1 && optionsRef.current) { - const focusedElement = optionsRef.current.children[ - focusedOptionIndex - ] as HTMLElement; - focusedElement.scrollIntoView({ block: "nearest", behavior: "smooth" }); - } - }, [focusedOptionIndex]); - - useEffect(() => { - /** - * @param event - The mouse event that triggers the click outside handler - */ - function handleClickOutside(event: MouseEvent) { - if ( - TagSearchBarRef.current && - !TagSearchBarRef.current.contains(event.target as Node) - ) { - setShowOptions(false); - } - } - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); - - const clearFilter = () => { - setFilterText(""); - setFocusedOptionIndex(0); - }; - - const selectedTags = tagOptions.filter((tag) => tag.selected); - const hasSelectedTags = selectedTags.length > 0; - - return ( -
- { - setFilterText(e.target.value); - setFocusedOptionIndex(0); - setShowOptions(true); - }} - onClear={clearFilter} - placeholder="Filter posts by tags" - onFocus={() => setShowOptions(true)} - /> - {hasSelectedTags && ( -
- {selectedTags.map((tag) => ( - - {tag.name} - - - ))} -
- )} - {showOptions && ( -
- {filteredTagOptions.map((option: TagOption, index: number) => ( - { - setTagOptions( - tagOptions.map((tag) => - tag.name === option.name - ? { ...tag, selected: !tag.selected } - : tag, - ), - ); - }} - isFocused={index === focusedOptionIndex} - /> - ))} -
- )} -
- ); -}; diff --git a/app/components/molecules/TagSearchLoop.tsx b/app/components/molecules/TagSearchLoop.tsx new file mode 100644 index 0000000..42e1cae --- /dev/null +++ b/app/components/molecules/TagSearchLoop.tsx @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FaCheck, FaSearch, FaTimes } from "react-icons/fa"; + +import { Tables } from "~/integrations/supabase/database.types"; + +interface TagSearchLoopProps { + posts: Tables<"posts">[]; + setDisplayedPosts: (filteredPosts: Tables<"posts">[]) => void; +} + +export const TagSearchLoop = ({ + posts, + setDisplayedPosts, +}: TagSearchLoopProps) => { + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); + const searchRef = useRef(null); + + // Get unique tags from all posts + const allTags = useMemo(() => { + const tagSet = new Set(); + posts.forEach((post) => { + post.tags.forEach((tag) => tagSet.add(tag.toLowerCase())); + }); + return Array.from(tagSet).sort(); + }, [posts]); + + // Filter tags based on search query + const filteredTags = useMemo(() => { + return allTags.filter((tag) => + tag.toLowerCase().includes(searchQuery.toLowerCase()), + ); + }, [allTags, searchQuery]); + + // Filter posts based on selected tags + useEffect(() => { + const filteredPosts = + selectedTags.length === 0 + ? posts + : posts.filter((post) => + selectedTags.every((selectedTag) => + post.tags + .map((tag) => tag.toLowerCase()) + .includes(selectedTag.toLowerCase()), + ), + ); + setDisplayedPosts(filteredPosts); + }, [posts, selectedTags, setDisplayedPosts]); + + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + searchRef.current && + !searchRef.current.contains(event.target as Node) + ) { + setSearchOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Toggle tag selection logic + const toggleTag = useCallback((tag: string) => { + setSelectedTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ); + }, []); + + return ( +
+ {searchOpen ? ( +
+
+ setSearchQuery(e.target.value)} + placeholder="Search by tags..." + className="input input-bordered h-10 w-56 pr-8 text-base sm:w-64" + /> + +
+
    { + const current = document.activeElement; + if (!current?.parentElement) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + const currentLi = current.closest("li"); + const nextLi = currentLi?.nextElementSibling; + const nextButton = nextLi?.querySelector("button"); + if (nextButton instanceof HTMLElement) { + nextButton.focus(); + nextButton.scrollIntoView({ block: "nearest" }); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const currentLi = current.closest("li"); + const prevLi = currentLi?.previousElementSibling; + const prevButton = prevLi?.querySelector("button"); + if (prevButton instanceof HTMLElement) { + prevButton.focus(); + prevButton.scrollIntoView({ block: "nearest" }); + } + } + }} + > + {filteredTags.map((tag) => ( +
  • + +
  • + ))} +
+
+ ) : ( + + )} +
+ ); +}; diff --git a/app/components/molecules/ThemeToggle.tsx b/app/components/molecules/ThemeToggle.tsx new file mode 100644 index 0000000..3bdeba7 --- /dev/null +++ b/app/components/molecules/ThemeToggle.tsx @@ -0,0 +1,29 @@ +import { MdDarkMode, MdLightMode } from "react-icons/md"; + +import { useTheme } from "~/utils/theme"; + +/** + * Theme toggle component + * + * @param className - Optional class name for the button + * @returns Theme toggle button + */ +export function ThemeToggle({ className }: { className?: string }) { + const { theme, setTheme } = useTheme(); + + return ( + + ); +} diff --git a/app/components/organisms/Carousel.tsx b/app/components/organisms/Carousel.tsx index ec4e890..1f35fce 100644 --- a/app/components/organisms/Carousel.tsx +++ b/app/components/organisms/Carousel.tsx @@ -10,34 +10,31 @@ import { Tables } from "~/integrations/supabase/database.types"; */ export const Carousel = ({ posts }: { posts: Tables<"posts">[] }) => { return ( -
+
{posts.length > 0 ? ( -
+
{posts.map((post) => ( -
-
-

+
+
+

{post.title} -

-

+

+

{new Date(post.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", })}

-
+
{post.tags.map((tag) => ( - - {tag} + + #{tag} ))}
@@ -45,15 +42,15 @@ export const Carousel = ({ posts }: { posts: Tables<"posts">[] }) => { {post.title}
))}
) : ( -
-

No posts found

+
+

No posts found...

)}
diff --git a/app/components/organisms/Footer.tsx b/app/components/organisms/Footer.tsx index f3581e1..f070a5f 100644 --- a/app/components/organisms/Footer.tsx +++ b/app/components/organisms/Footer.tsx @@ -1,5 +1,5 @@ import { Copyright } from "../atoms/Copyright"; -import { SocialMedia } from "../molecules/SocialMedia"; +import { ThemeToggle } from "../molecules/ThemeToggle"; /** * Footer component @@ -8,9 +8,9 @@ import { SocialMedia } from "../molecules/SocialMedia"; */ export const Footer = () => { return ( -
+
- +
diff --git a/app/components/organisms/Header.tsx b/app/components/organisms/Header.tsx index 917f905..331fed6 100644 --- a/app/components/organisms/Header.tsx +++ b/app/components/organisms/Header.tsx @@ -1,40 +1,23 @@ -import { MenuItems } from "../molecules/MenuItems"; -import { MobileMenuItems } from "../molecules/MobileMenuItems"; +import { Link } from "@remix-run/react"; +import { ReactNode } from "react"; -interface HeaderProps { - backgroundImageUrl: string; -} - -/** - * Header component used to display the header of the application. - * - * @param backgroundImageUrl - The background image URL - * @returns Header component - */ -export const Header = ({ backgroundImageUrl }: HeaderProps) => { - return ( -
-
-
-
- - ~/khaykingleb - - - +export const Header = ({ + headerName, + children, +}: { + headerName: string; + children?: ReactNode; +}) => ( + <> +
+
+ + < + +

{headerName}

-
- ); -}; + {children} +
+
+ +); diff --git a/app/components/organisms/Pagination.tsx b/app/components/organisms/Pagination.tsx index cdc37eb..3e3976b 100644 --- a/app/components/organisms/Pagination.tsx +++ b/app/components/organisms/Pagination.tsx @@ -4,7 +4,7 @@ interface PaginationProps { onPageChange: (page: number) => void; } -const MAX_PAGES_PER_PAGE = 5; +const VISIBLE_PAGE_BUTTONS = 5; /** * Pagination component @@ -20,13 +20,13 @@ export const Pagination = ({ onPageChange, }: PaginationProps) => { const startPage = - Math.floor(currentPage / MAX_PAGES_PER_PAGE) * MAX_PAGES_PER_PAGE; - const endPage = Math.min(startPage + MAX_PAGES_PER_PAGE, pagesInTotal); + Math.floor(currentPage / VISIBLE_PAGE_BUTTONS) * VISIBLE_PAGE_BUTTONS; + const endPage = Math.min(startPage + VISIBLE_PAGE_BUTTONS, pagesInTotal); return ( -
+
))}
); diff --git a/app/routes/blog.$slug.tsx b/app/routes/blog.$slug.tsx index e3e6d43..bfb54a8 100644 --- a/app/routes/blog.$slug.tsx +++ b/app/routes/blog.$slug.tsx @@ -9,6 +9,7 @@ import { Await, ClientLoaderFunction, ClientLoaderFunctionArgs, + Link, useLoaderData, } from "@remix-run/react"; import { createClient } from "@supabase/supabase-js"; @@ -25,8 +26,8 @@ import { ExtendedRecordMap } from "vendor/react-notion-x/packages/notion-types/s import { NotionRenderer } from "vendor/react-notion-x/packages/react-notion-x"; import { Footer } from "~/components/organisms/Footer"; -import { Header } from "~/components/organisms/Header"; import { Tables } from "~/integrations/supabase/database.types"; +import { useTheme } from "~/utils/theme"; const Equation = React.lazy(() => import("react-notion-x/build/third-party/equation").then((module) => ({ @@ -74,12 +75,14 @@ const Collection = React.lazy(() => ); const NotionPage = ({ recordMap }: { recordMap: ExtendedRecordMap }) => { + const { theme } = useTheme(); + return ( // @ts-expect-error: NotionRenderer is a React component { - const cachedData = localStorage.getItem(`blogPosts-${params.slug}`); + const cachedData = localStorage.getItem(`${CACHE_KEY}-${params.slug}`); const cachedTimestamp = localStorage.getItem( - `blogPostsTimestamp-${params.slug}`, + `${CACHE_TIMESTAMP_KEY}-${params.slug}`, ); - - // Use cached data if it's valid if (cachedData && cachedTimestamp) { - const isExpired = Date.now() - Number(cachedTimestamp) > CACHE_DURATION; - const parsedData = JSON.parse(cachedData); - if (!isExpired && parsedData.post && parsedData.recordMap) { + if (Date.now() - Number(cachedTimestamp) < CACHE_DURATION) { + const parsedData: { + post: Tables<"posts">; + recordMap: Promise; + } = JSON.parse(cachedData); return { post: parsedData.post, recordMap: Promise.resolve(parsedData.recordMap), @@ -151,23 +156,20 @@ export const clientLoader: ClientLoaderFunction = async ({ } } - // Get fresh data from server const serverData = (await serverLoader()) as { post: Tables<"posts">; recordMap: Promise; }; - - // Cache the data Promise.resolve(serverData.recordMap).then((recordMap) => { localStorage.setItem( - `blogPosts-${params.slug}`, + `${CACHE_KEY}-${params.slug}`, JSON.stringify({ post: serverData.post, recordMap, }), ); localStorage.setItem( - `blogPostsTimestamp-${params.slug}`, + `${CACHE_TIMESTAMP_KEY}-${params.slug}`, Date.now().toString(), ); }); @@ -263,25 +265,32 @@ export const meta: MetaFunction = ({ data }) => { * @returns The route layout */ export default function BlogPostRoute() { - const { recordMap } = useLoaderData(); + const { post, recordMap } = useLoaderData(); return ( -
-
-
-
- - } - > - - {(resolvedRecordMap: ExtendedRecordMap) => { - return ; - }} - - +
+
+
+ < +

{post.title}

+
+
+
+ + } + > +

+ Table of Contents +

+ + {(resolvedRecordMap: ExtendedRecordMap) => ( + + )} + +
diff --git a/app/routes/blog._index.tsx b/app/routes/blog._index.tsx index 451c8a4..9712cfd 100644 --- a/app/routes/blog._index.tsx +++ b/app/routes/blog._index.tsx @@ -6,15 +6,10 @@ import { useLoaderData, } from "@remix-run/react"; import { createClient } from "@supabase/supabase-js"; -import React, { - Suspense, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; - -import { TagOption, TagSearchBar } from "~/components/molecules/TagSearchBar"; +import { Suspense, useCallback, useEffect, useState } from "react"; +import { FaSearch } from "react-icons/fa"; + +import { TagSearchLoop } from "~/components/molecules/TagSearchLoop"; import { Carousel } from "~/components/organisms/Carousel"; import { Footer } from "~/components/organisms/Footer"; import { Header } from "~/components/organisms/Header"; @@ -39,6 +34,12 @@ export const handle: SEOHandle = { */ export const meta: MetaFunction = () => { return [ + { title: "Blog" }, + { description: "Blog posts by Gleb Khaykin" }, + { + property: "og:title", + content: "Blog", + }, { property: "og:description", content: "Blog posts by Gleb Khaykin", @@ -68,88 +69,87 @@ export const loader = async () => { .from("posts") .select("*") .returns[]>() - .then(({ data, error }) => { + .then(async ({ data, error }) => { if (error) throw new Response("Failed to load posts", { status: 500 }); - return data; + return data.reverse(); }); - return defer({ - posts: postsPromise as Promise[]>, - }); + return defer({ posts: postsPromise as Promise[]> }); }; -const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds +const CACHE_KEY = "blogPosts"; +const CACHE_TIMESTAMP_KEY = "blogPostsTimestamp"; +const CACHE_DURATION = 60 * 60 * 1000; export const clientLoader = async ({ serverLoader, }: ClientLoaderFunctionArgs) => { - const cachedData = localStorage.getItem("blogPosts"); - const cachedTimestamp = localStorage.getItem("blogPostsTimestamp"); - - // Use cached data if it's valid + const cachedData = localStorage.getItem(CACHE_KEY); + const cachedTimestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY); if (cachedData && cachedTimestamp) { - const isExpired = Date.now() - Number(cachedTimestamp) > CACHE_DURATION; - const parsedData = JSON.parse(cachedData); - if (!isExpired && parsedData.posts?.length > 0) { - return { posts: Promise.resolve(parsedData.posts) }; + if (Date.now() - Number(cachedTimestamp) < CACHE_DURATION) { + const parsedData: { posts: Tables<"posts">[] } = JSON.parse(cachedData); + return { posts: parsedData.posts }; } } - // Get fresh data from server and cache it const serverData = (await serverLoader()) as { posts: Promise[]>; }; serverData.posts.then((posts) => { - localStorage.setItem("blogPosts", JSON.stringify({ posts })); - localStorage.setItem("blogPostsTimestamp", Date.now().toString()); + localStorage.setItem(CACHE_KEY, JSON.stringify({ posts })); + localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString()); }); - return serverData; }; // Tell Remix to use the client loader during hydration clientLoader.hydrate = true; -const MAX_POSTS_PER_PAGE_DESKTOP = 4; -const MAX_POSTS_PER_PAGE_MOBILE = 3; +const CAROUSEL_ITEM_HEIGHTS = { + xs: 100, + sm: 116, + md: 132, + lg: 160, +} as const; const PostsContent = ({ posts }: { posts: Tables<"posts">[] }) => { - const [tagOptions, setTagOptions] = useState( - Array.from(new Set(posts.flatMap((post) => post.tags))) - .sort() - .map((tag) => ({ name: tag, selected: false })), - ); - const selectedTags = useMemo( - () => - tagOptions - .filter((option: TagOption) => option.selected) - .map((option: TagOption) => option.name), - [tagOptions], - ); - const filteredPosts = useMemo( - () => - posts.filter((post) => - selectedTags.every((tag: string) => post.tags.includes(tag)), - ), - [selectedTags, posts], - ); + const [displayedPosts, setDisplayedPosts] = useState(posts); + const [postsPerPage, setPostsPerPage] = useState(4); + const [currentPage, setCurrentPage] = useState(0); - const [postsPerPage, setPostsPerPage] = useState(MAX_POSTS_PER_PAGE_DESKTOP); const updatePostsPerPage = useCallback(() => { - if (window.matchMedia("(min-height: 800px)").matches) { - setPostsPerPage(MAX_POSTS_PER_PAGE_DESKTOP); - } else { - setPostsPerPage(MAX_POSTS_PER_PAGE_MOBILE); - } + const LAYOUT_HEIGHTS = { + header: 60, + pagination: 40, + footer: 48, + spacing: 20, + }; + + const totalFixedHeight = Object.values(LAYOUT_HEIGHTS).reduce( + (a, b) => a + b, + 0, + ); + const availableHeight = window.innerHeight - totalFixedHeight; + + const itemHeight = window.matchMedia("(min-width: 1024px)").matches + ? CAROUSEL_ITEM_HEIGHTS.lg + : window.matchMedia("(min-width: 768px)").matches + ? CAROUSEL_ITEM_HEIGHTS.md + : window.matchMedia("(min-width: 640px)").matches + ? CAROUSEL_ITEM_HEIGHTS.sm + : CAROUSEL_ITEM_HEIGHTS.xs; + + setPostsPerPage(Math.max(2, Math.floor(availableHeight / itemHeight))); }, []); + // Update the number of posts per page when the window is resized useEffect(() => { updatePostsPerPage(); window.addEventListener("resize", updatePostsPerPage); return () => window.removeEventListener("resize", updatePostsPerPage); }, [updatePostsPerPage]); - const [currentPage, setCurrentPage] = useState(0); - const pagesInTotal = Math.ceil(posts.length / postsPerPage); + const pagesInTotal = Math.ceil(displayedPosts.length / postsPerPage); const updateCurrentPage = useCallback( (pageIndex: number) => { if (pageIndex >= 0 && pageIndex < pagesInTotal) { @@ -159,37 +159,39 @@ const PostsContent = ({ posts }: { posts: Tables<"posts">[] }) => { [pagesInTotal], ); + const visiblePosts = displayedPosts.slice( + currentPage * postsPerPage, + (currentPage + 1) * postsPerPage, + ); + return (
- -
- -
-
- -
+
+ +
+ +
); }; -const LoadingState = () => ( +const LoadingFallback = () => (
- {}} /> -
-
-
-
- {}} /> -
+
+
+ +
+
+
+
+
+ {}} />
); @@ -202,20 +204,15 @@ export default function BlogRoute() { const { posts } = useLoaderData(); return ( -
-
-
-
- }> - - {(resolvedPosts: Tables<"posts">[]) => ( - - )} - - -
-
-