-
-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
23 changed files
with
594 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,7 @@ | ||
{ | ||
"name": "dinhanhthi.com", | ||
"description": "My personal website for taking notes.", | ||
"version": "6.4.0", | ||
"version": "6.4.1", | ||
"author": "Anh-Thi Dinh", | ||
"packageManager": "[email protected]", | ||
"license": "MIT", | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,227 @@ | ||
'use client' | ||
|
||
import FiSearch from '@notion-x/src/icons/FiSearch' | ||
import IoCloseCircle from '@notion-x/src/icons/IoCloseCircle' | ||
import { makeSlugText } from '@notion-x/src/lib/helpers' | ||
import cn from 'classnames' | ||
import Fuse from 'fuse.js' | ||
import { useRouter, useSearchParams } from 'next/navigation' | ||
import { ChangeEvent, createElement, useEffect, useRef, useState } from 'react' | ||
|
||
import { Book, Game, Tool } from '../../../interface' | ||
import { StarIcon } from '../../icons/StarIcon' | ||
import { TagActionIcon } from '../../icons/TagActionIcon' | ||
import { TagAdventureIcon } from '../../icons/TagAdventureIcon' | ||
import TagAndroidIcon from '../../icons/TagAndroidIcon' | ||
import { TagFarmIcon } from '../../icons/TagFarmIcon' | ||
import { TagFightingIcon } from '../../icons/TagFightingIcon' | ||
import { TagFPSIcon } from '../../icons/TagFpsIcon' | ||
import TagIOSIcon from '../../icons/TagIOSIcon' | ||
import TagLinuxIcon from '../../icons/TagLinuxIcon' | ||
import TagMacOSIcon from '../../icons/TagMacOSIcon' | ||
import { TagSwitchIcon } from '../../icons/TagNintendoIcon' | ||
import { TagOpenWorldIcon } from '../../icons/TagOpenWorldIcon' | ||
import { TagPartyIcon } from '../../icons/TagPartyIcon' | ||
import { TagPuzzleIcon } from '../../icons/TagPuzzleIcon' | ||
import { TagRPGIcon } from '../../icons/TagRpgIcon' | ||
import { TagSportsIcon } from '../../icons/TagSportIcon' | ||
import { TagStealthStrategyIcon } from '../../icons/TagStealthIcon' | ||
import TagWindowsIcon from '../../icons/TagWindowsIcon' | ||
import ToolItem from '../tools/ToolItem' | ||
|
||
const iconTagList: { [x: string]: (props: React.SVGProps<SVGSVGElement>) => JSX.Element } = { | ||
favorite: StarIcon, | ||
adventure: TagAdventureIcon, | ||
action: TagActionIcon, | ||
android: TagAndroidIcon, | ||
farm: TagFarmIcon, | ||
fighting: TagFightingIcon, | ||
fps: TagFPSIcon, | ||
ios: TagIOSIcon, | ||
linux: TagLinuxIcon, | ||
macos: TagMacOSIcon, | ||
'open world': TagOpenWorldIcon, | ||
party: TagPartyIcon, | ||
puzzle: TagPuzzleIcon, | ||
rpg: TagRPGIcon, | ||
sports: TagSportsIcon, | ||
'stealth strategy': TagStealthStrategyIcon, | ||
switch: TagSwitchIcon, | ||
windows: TagWindowsIcon | ||
} | ||
|
||
export default function GamesPage(props: { games: Game[]; tags: string[] }) { | ||
const inputRef = useRef<HTMLInputElement>(null) | ||
const [searchResult, setSearchResult] = useState<Tool[]>(props.games) | ||
const [query, setQuery] = useState('') | ||
const [tagsToShow, setTagsToShow] = useState<string[]>([]) | ||
|
||
const router = useRouter() | ||
const searchParams = useSearchParams() | ||
const tag = searchParams.get('tag') | ||
|
||
useEffect(() => { | ||
if (tag && tag.length) { | ||
setTagsToShow(tag.split(',')) | ||
} | ||
}, [tag]) | ||
|
||
const toggleTypeToShow = (tag: string) => { | ||
if (tagsToShow.includes(tag)) { | ||
if (tagsToShow.length === 1) { | ||
router.push('/games', { scroll: false }) | ||
} else { | ||
router.push(`/games?tag=${tagsToShow.filter(item => item !== tag).join(',')}`, { | ||
scroll: false | ||
}) | ||
} | ||
setTagsToShow(tagsToShow.filter(item => item !== tag)) | ||
} else { | ||
setTagsToShow([...tagsToShow, tag]) | ||
router.push(`/games?tag=${[...tagsToShow, tag].join(',')}`, { scroll: false }) | ||
} | ||
} | ||
|
||
const toolsToShow = searchResult.filter( | ||
tool => tagsToShow.every(type => tool.tag.includes(type)) || tagsToShow.length === 0 | ||
) | ||
|
||
const fuseOptions = { | ||
includeScore: false, | ||
keys: ['name', 'description', 'tag', 'keySearch'] | ||
} | ||
|
||
const fuse = new Fuse(props.games, fuseOptions) | ||
|
||
function handleOnchangeInput(e: ChangeEvent<HTMLInputElement>) { | ||
const { value } = e.target | ||
setQuery(value) | ||
if (value.length) { | ||
const result = fuse.search(value) | ||
setSearchResult(result?.map(item => item.item)) | ||
} else { | ||
setSearchResult(props.games) | ||
} | ||
} | ||
|
||
function clearQuery() { | ||
setQuery('') | ||
setSearchResult(props.games) | ||
} | ||
|
||
return ( | ||
<div className="flex flex-col gap-6"> | ||
{/* Search */} | ||
<div className="flex items-center gap-3 p-4 bg-white rounded-xl"> | ||
<div className="grid place-items-center text-slate-500"> | ||
<FiSearch className="text-2xl" /> | ||
</div> | ||
<input | ||
ref={inputRef} | ||
className="peer h-full w-full text-ellipsis bg-transparent pr-2 outline-none m2it-hide-wscb" | ||
id="search" | ||
type="search" | ||
placeholder={'Search tools...'} | ||
autoComplete="off" | ||
value={query} | ||
onChange={e => handleOnchangeInput(e)} | ||
/> | ||
{query && ( | ||
<button onClick={() => clearQuery()}> | ||
<IoCloseCircle className="h-5 w-5 text-slate-500" /> | ||
</button> | ||
)} | ||
</div> | ||
|
||
{/* Tags */} | ||
<div className="flex items-center gap-3 flex-wrap md:flex-nowrap md:items-baseline justify-start sm:justify-start"> | ||
<div className="flex gap-2.5 flex-wrap items-center"> | ||
{props.tags?.map(tag => ( | ||
<button | ||
onClick={() => toggleTypeToShow(tag)} | ||
key={makeSlugText(tag)} | ||
className={cn( | ||
'border px-3 py-1.5 rounded-sm transition duration-200 ease-in-out flex flex-row gap-2 items-center text-slate-700', | ||
{ | ||
'bg-white hover:m2it-link-hover': !tagsToShow.includes(tag), | ||
'bg-sky-600 text-white': tagsToShow.includes(tag) | ||
} | ||
)} | ||
> | ||
{iconTagList[tag] && ( | ||
<> | ||
{createElement(iconTagList[tag], { | ||
className: 'h-5 w-5' | ||
})} | ||
</> | ||
)} | ||
<div className="whitespace-nowrap text-base">{tag}</div> | ||
</button> | ||
))} | ||
</div> | ||
</div> | ||
|
||
{/* Tool list */} | ||
<div className="flex flex-col gap-4"> | ||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> | ||
{toolsToShow?.map((tool: Tool) => ( | ||
<ToolItem key={tool.id} tool={tool as Tool & Book} showFavoriteStar={true} /> | ||
))} | ||
</div> | ||
{!toolsToShow.length && ( | ||
<div className="text-slate-500 flex gap-2 items-center justify-center w-full"> | ||
<TagSwitchIcon className="text-2xl" /> | ||
<div>No games found.</div> | ||
</div> | ||
)} | ||
</div> | ||
</div> | ||
) | ||
} | ||
|
||
export function SkeletonToolItem() { | ||
return ( | ||
<div className="p-2 bg-white rounded-lg border border-slate-150"> | ||
<div className="flex flex-row h-full"> | ||
<div className="w-[90px] h-full rounded-l-lg relative overflow-hidden shrink-0 border-[0.5px] border-slate-100"> | ||
<div className="relative w-full h-full overflow-hidden"> | ||
<div | ||
style={{ | ||
position: 'absolute', | ||
inset: '0px', | ||
backgroundImage: ` | ||
linear-gradient(#f0f0f0, #f0f0f0), | ||
linear-gradient(transparent, transparent), | ||
url()`, | ||
backgroundBlendMode: 'luminosity, overlay, normal', | ||
backgroundRepeat: 'no-repeat', | ||
backgroundPosition: 'center top', | ||
backgroundSize: '100% 100%', | ||
filter: 'blur(25px) saturate(1)', | ||
transform: 'var(1.5) translate3d(0, 0, 0)' | ||
}} | ||
></div> | ||
<div className="flex items-center justify-center p-8"> | ||
<div className="animate-pulse w-[60px] h-[60px] max-w-[60px] absolute inset-0 m-auto rounded-full bg-slate-200"></div> | ||
</div> | ||
</div> | ||
</div> | ||
<div className="min-w-0 flex-1 flex flex-col gap-4 p-3 pl-4 animate-pulse"> | ||
<div className="flex gap-1.5 flex-col"> | ||
<div className="font-semibold text-slate-700"> | ||
<div className="w-1/2 h-5 bg-slate-100 rounded-md"></div> | ||
</div> | ||
<div className="flex flex-wrap gap-x-1 gap-y-2 text-[0.75rem]"> | ||
<div className="w-8 h-3 bg-slate-100 rounded-md"></div> | ||
<div className="w-8 h-3 bg-slate-100 rounded-md"></div> | ||
</div> | ||
</div> | ||
<div className="text-[0.83rem] text-slate-700 break-words overflow"> | ||
<div className="w-full h-3 bg-slate-100 rounded-md"></div> | ||
<div className="w-4/5 h-3 bg-slate-100 rounded-md mt-1"></div> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import ToolsIcon from '@/public/tools.webp' | ||
import cn from 'classnames' | ||
import { Suspense } from 'react' | ||
|
||
import ScrollToTop from '@notion-x/src/components/ScrollToTop' | ||
import Container from '../../components/Container' | ||
import Footer from '../../components/Footer' | ||
import HeaderPage from '../../components/HeaderPage' | ||
import { SkeletonSearchBar } from '../../components/SkeletonSearchBar' | ||
import { bodyPadding, containerWide } from '../../lib/config' | ||
import { getUnofficialGames } from '../../lib/fetcher' | ||
import { getMetadata } from '../../lib/helpers' | ||
import GamesPage, { SkeletonToolItem } from './GamesPage' | ||
|
||
export const revalidate = 20 | ||
|
||
const title = 'Games I like' | ||
const description = | ||
'Games make me happy and my life more balanced. Here are some of my favorite games.' | ||
|
||
export const metadata = getMetadata({ | ||
title, | ||
description, | ||
images: [`/api/og?title=${encodeURI(title)}&description=${encodeURI(description)}`] | ||
}) | ||
|
||
export default async function GamesHomePage() { | ||
const { games } = await getUnofficialGames() | ||
|
||
// Get all unique tags from current games | ||
const tags: string[] = Array.from(new Set(games.flatMap(game => game.tag))) | ||
|
||
// Sort tags alphabetically | ||
tags.sort() | ||
|
||
// Make sure the 'favorite' tag is always at the beginning | ||
tags.sort((a, b) => (a === 'favorite' ? -1 : b === 'favorite' ? 1 : 0)) | ||
|
||
return ( | ||
<div className="thi-bg-stone flex flex-col"> | ||
<HeaderPage | ||
headerType="gray" | ||
title={title} | ||
subtitle={description} | ||
headerWidth="wide" | ||
icon={{ staticImageData: ToolsIcon }} | ||
iconClassName="h-12 w-12" | ||
number={games.length} | ||
/> | ||
<Container className={cn('basis-auto grow shrink-0', bodyPadding, containerWide)}> | ||
<Suspense fallback={<SkeletonToolContainer />}> | ||
<GamesPage games={games} tags={tags} /> | ||
</Suspense> | ||
</Container> | ||
<Footer footerType="gray" /> | ||
<ScrollToTop /> | ||
</div> | ||
) | ||
} | ||
|
||
function SkeletonToolContainer() { | ||
return ( | ||
<div className="flex flex-col gap-6"> | ||
<SkeletonSearchBar placeholder="Search tools..." /> | ||
<div className="flex items-center gap-x-4 gap-y-2 flex-wrap justify-center sm:justify-start"> | ||
<div className="flex gap-x-2 gap-y-3"> | ||
{Array.from({ length: 5 }).map((_, i) => ( | ||
<div key={i} className="h-6 w-20 bg-white rounded-md animate-pulse"></div> | ||
))} | ||
</div> | ||
</div> | ||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> | ||
{Array.from({ length: 6 }).map((_, i) => ( | ||
<SkeletonToolItem key={i} /> | ||
))} | ||
</div> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.