diff --git a/DONE.md b/DONE.md index b71601f..e467143 100644 --- a/DONE.md +++ b/DONE.md @@ -97,6 +97,11 @@ - [x] Genres - [x] Platforms - [x] Categories +- [x] Make error pages + - [x] Add ambience music + - [x] Add error code + - [x] Add flickering effect for background image + - [x] Add flickering effect on text ### Post MVP diff --git a/TODO.md b/TODO.md index bea98ee..5ad178b 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ - [x] Create page to forgot password and reset - [ ] Set up user registration and login - [ ] Implement OAuth for Google and Facebook - - [ ] Create a user profile page with editable settings + - [x] Create a user profile page with editable settings - [ ] Community forum - [ ] Create a basic forum structure with categories and threads - [ ] Allow users to create, edit, and delete posts @@ -32,9 +32,7 @@ ### Post-MVP - [ ] Advanced features - - [ ] Add dark mode toggle - - [x] Add theme switcher - - [ ] Adjust all site with light mode + - [x] Add dark mode toggle - [ ] Implement game recommendations based on user activity - [ ] Create a personalized dashboard for logged-in users - [ ] Testing & QA @@ -49,6 +47,9 @@ - [x] Write detailed README.md with project overview - [ ] Document API endpoints - [x] Create a contribution guide for open source contributors +- [ ] Adjust all keyframes to tailwind +- [ ] Review entire responsivity +- [ ] Review entire dark and light mode ### Future Ideas diff --git a/src/Routes.tsx b/src/Routes.tsx index fb82e2b..a30586f 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -9,6 +9,7 @@ export const Routes = () => { const Home = L(lazy(() => import('./pages/Home'))) const Login = L(lazy(() => import('./pages/Login'))) const Search = L(lazy(() => import('./pages/Search'))) + const Blog = L(lazy(() => import('./pages/Blog/List'))) const Profile = L(lazy(() => import('./pages/Profile'))) const Register = L(lazy(() => import('./pages/Register'))) const Calendar = L(lazy(() => import('./pages/Calendar'))) @@ -16,6 +17,7 @@ export const Routes = () => { const Reset = L(lazy(() => import('./pages/Password/Reset'))) const Forgot = L(lazy(() => import('./pages/Password/Forgot'))) const GameGenres = L(lazy(() => import('./pages/Game/Genres'))) + const BlogDetails = L(lazy(() => import('./pages/Blog/Details'))) const GameDetails = L(lazy(() => import('./pages/Game/Details'))) const GamePlatforms = L(lazy(() => import('./pages/Game/Platforms'))) const GameCategories = L(lazy(() => import('./pages/Game/Categories'))) @@ -51,6 +53,16 @@ export const Routes = () => { { path: ':slug', element: }, ], }, + { + path: 'blogs', + children: [ + { index: true, element: }, + { + path: ':slug', + element: , + }, + ], + }, { path: '/tags/:tag', element: }, { path: '/genres/:genre', element: }, { path: '/platforms/:platform', element: }, diff --git a/src/assets/RE4_Serenity_Theme.mp3 b/src/assets/RE4_Serenity_Theme.mp3 new file mode 100644 index 0000000..84a4005 Binary files /dev/null and b/src/assets/RE4_Serenity_Theme.mp3 differ diff --git a/src/components/GameCard/GameCard.tsx b/src/components/GameCard/GameCard.tsx index ced6a7f..df17455 100644 --- a/src/components/GameCard/GameCard.tsx +++ b/src/components/GameCard/GameCard.tsx @@ -6,6 +6,7 @@ import { Stack, Typography, } from '@mui/material' +import { useState } from 'react' import { IoEyeOutline, IoHeartOutline, @@ -14,6 +15,8 @@ import { import { GameList } from '@/types' +import { HeartsUp } from '..' + interface GameCardProps { game: GameList view: 'list' | 'grid' @@ -22,145 +25,177 @@ interface GameCardProps { function GameCard(props: GameCardProps) { const { game, view } = props + const [hearts, setHearts] = useState(game.hearts_count) + const [isHearted, setIsHearted] = useState(false) + const [heartPops, setHeartPops] = useState([]) + + const handleHeartClick = () => { + setIsHearted((prev) => !prev) + + setHearts(hearts + (isHearted ? -1 : 1)) + + if (!isHearted) { + const newHearts = Array.from({ length: 10 }, (_, i) => i * 10) + + setHeartPops((prev) => [...prev, ...newHearts]) + } + } + return ( - - - {game.badge && ( - - )} - - {game.title} + + {heartPops.map((delay, index) => ( + - - - - {game.title} - - - - - - - - - {game.hearts_count} - - - - - - {game.views_count} - - - - - - - - + ))} - - - - Release Date: {game.release} - - - {game.platforms.map((platform) => ( - + + + {game.badge && ( + + )} + + {game.title} + + - {platform.name} + variant="h6" + className="text-lg font-bold text-white hover:text-theme-red-900 transition duration-500"> + {game.title} - ))} + + + + + + {hearts} + + + + + {game.views_count} + + + + + + + + - - - {game.sale ? ( -
- - {(game.commom_price / 100).toLocaleString('en-US', { - currency: 'USD', - style: 'currency', - })} - - - {(game.best_price / 100).toLocaleString('en-US', { - currency: 'USD', - style: 'currency', - })} - -
- ) : ( - (game.best_price / 100).toLocaleString('en-US', { - currency: 'USD', - style: 'currency', - }) - )} + + + + Release Date: {game.release} + + {game.platforms.map((platform) => ( + + + {platform.name} + + + ))} + + + + + {game.sale ? ( +
+ + {(game.commom_price / 100).toLocaleString('en-US', { + currency: 'USD', + style: 'currency', + })} + + + {(game.best_price / 100).toLocaleString('en-US', { + currency: 'USD', + style: 'currency', + })} + +
+ ) : ( + (game.best_price / 100).toLocaleString('en-US', { + currency: 'USD', + style: 'currency', + }) + )} +
+
-
- - {game.genres.map((genre) => ( - - - - ))} + + {game.genres.map((genre) => ( + + + + ))} + -
- -
+ +
+ ) } diff --git a/src/components/Header/modules/HeaderCarousel.tsx b/src/components/Header/modules/HeaderCarousel.tsx index f152a07..07f806e 100644 --- a/src/components/Header/modules/HeaderCarousel.tsx +++ b/src/components/Header/modules/HeaderCarousel.tsx @@ -54,22 +54,22 @@ function HeaderCarousel(props: HeaderCarouselProps) { Available on: - {related.platforms.map(({ id, name }) => ( + {related.platforms.map(({ id, slug, name }) => ( - {name} + {name} ))} - {related.tags.map(({ id, name }) => ( + {related.tags.map(({ id, slug, name }) => ( - {name} + {name} ))} diff --git a/src/components/Header/modules/Navbar.tsx b/src/components/Header/modules/Navbar.tsx index ca675ce..92d1954 100644 --- a/src/components/Header/modules/Navbar.tsx +++ b/src/components/Header/modules/Navbar.tsx @@ -75,7 +75,9 @@ function Navbar(props: NavbarProps) { @@ -162,6 +164,11 @@ function Navbar(props: NavbarProps) { className="block py-2 px-4 dark:hover:bg-zinc-800 hover:bg-gray-100 rounded-lg transition duration-200 dark:text-gray-300 text-zinc-800"> Home + + Blog + {user ? ( <> > +} + +function HeartsUp(props: HeartsUpProps) { + const { delay, setHeartPops } = props + + const randomLeftPosition = Math.random() * 100 + + useEffect(() => { + const interval = setInterval(() => { + setHeartPops((prev) => prev.filter((_, index) => index < 10)) + }, 10000) + + return () => clearInterval(interval) + }, []) + + return ( + + + + ) +} + +const styles = ` + @keyframes heart-float { + 0% { + transform: translateY(0) scale(0.5); + opacity: 1; + } + 50% { + transform: translateY(-50vh) scale(1); + opacity: 1; + } + 100% { + transform: translateY(-50vh) scale(1); + opacity: 0; + } + } + + .animate-heart-float { + animation: heart-float 2s ease-in-out forwards; + } + + @keyframes heart-scale { + 0% { + transform: scale(0); + opacity: 1; + } + 50% { + transform: scale(2); + opacity: 1; + } + 100% { + transform: scale(3); + opacity: 0; + } + } + + .animate-heart-scale { + animation: heart-scale 1s ease-in-out forwards; + } +` + +document.head.insertAdjacentHTML('beforeend', ``) + +export default HeartsUp diff --git a/src/components/HeartsUp/index.ts b/src/components/HeartsUp/index.ts new file mode 100644 index 0000000..a679ccd --- /dev/null +++ b/src/components/HeartsUp/index.ts @@ -0,0 +1 @@ +export { default } from './HeartsUp' diff --git a/src/components/NewPassword/NewPassword.tsx b/src/components/NewPassword/NewPassword.tsx index b2bbfc1..9d779c8 100644 --- a/src/components/NewPassword/NewPassword.tsx +++ b/src/components/NewPassword/NewPassword.tsx @@ -106,11 +106,9 @@ function PasswordInput(props: PasswordInputProps) { + sx={{ + color: req.test(passwordValue) ? 'green' : 'red', + }}> {req.label} diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 7f91528..730cd58 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -1,5 +1,13 @@ -import { Box } from '@mui/material' -import { ChangeEvent, useState } from 'react' +import { + Box, + Checkbox, + InputLabel, + ListItemText, + MenuItem, + Select as MuiSelect, + SelectChangeEvent, +} from '@mui/material' +import { useState } from 'react' interface OptionTypes { label: string | JSX.Element @@ -11,18 +19,29 @@ interface SelectProps { label?: string options: OptionTypes[] defaultValue?: any - onChange?: (event: ChangeEvent) => void + onChange?: (value: any) => void disabled?: boolean + multiple?: boolean + renderValue?: (selected: any) => any } function Select(props: SelectProps) { - const { label, defaultValue, options, onChange, disabled, isFull } = - props - const [selected, setSelected] = useState( - defaultValue !== undefined ? defaultValue : '', + const { + label, + defaultValue, + options, + onChange, + disabled, + isFull, + renderValue, + multiple = false, + } = props + + const [selected, setSelected] = useState( + multiple ? defaultValue || [] : defaultValue || '', ) - const handleChange = (event: ChangeEvent) => { + const handleChange = (event: SelectChangeEvent) => { setSelected(event.target.value) if (onChange) { @@ -32,24 +51,39 @@ function Select(props: SelectProps) { return ( - - + ) } diff --git a/src/components/index.ts b/src/components/index.ts index e9aed1d..466f646 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,6 +6,7 @@ export { default as AuthBg } from './AuthBg' export { default as Select } from './Select' export { default as Header } from './Header' export { default as Footer } from './Footer' +export { default as HeartsUp } from './HeartsUp' export { default as Backdrop } from './Backdrop' export { default as GameCard } from './GameCard' export { default as Loadable } from './Loadable' diff --git a/src/mocks/blogs.ts b/src/mocks/blogs.ts new file mode 100644 index 0000000..683d950 --- /dev/null +++ b/src/mocks/blogs.ts @@ -0,0 +1,94 @@ +import { faker } from '@faker-js/faker' + +import { Blog, BlogDetails } from '@/types' + +import { MOCK_CATEGORIES, MOCK_TAGS } from '.' + +export const MOCK_BLOG_LIST: Blog[] = [] + +for (let i = 0; i < 100; i++) { + const base: Blog = { + id: 1, + cover: faker.image.urlPicsumPhotos(), + title: faker.lorem.words({ min: 1, max: 3 }), + body: faker.lorem.text(), + slug: faker.lorem.slug(), + categories: faker.helpers.arrayElements(MOCK_CATEGORIES), + tags: faker.helpers.arrayElements(MOCK_TAGS), + views_count: faker.number.int({ min: 1, max: 99999 }), + hearts_count: faker.number.int({ min: 1, max: 99999 }), + comments_count: faker.number.int({ min: 1, max: 99999 }), + created_at: faker.date.anytime().toISOString(), + updated_at: faker.date.anytime().toISOString(), + user: { + id: 1, + name: faker.person.fullName(), + email: faker.internet.email(), + nickname: faker.internet.userName(), + birthdate: faker.date.past().toDateString(), + created_at: faker.date.anytime().toISOString(), + updated_at: faker.date.anytime().toISOString(), + profile: { + photo: faker.image.avatar(), + share: faker.datatype.boolean(), + }, + }, + } + + const newBlogPost: Blog = { + ...base, + id: i + 1, + } + + MOCK_BLOG_LIST.push(newBlogPost) +} + +export const MOCK_BLOG_DETAILS: BlogDetails = { + id: 1, + cover: faker.image.urlPicsumPhotos(), + title: faker.lorem.words({ min: 1, max: 3 }), + body: faker.lorem.text(), + slug: faker.lorem.slug(), + categories: faker.helpers.arrayElements(MOCK_CATEGORIES), + tags: faker.helpers.arrayElements(MOCK_TAGS), + views_count: faker.number.int({ min: 1, max: 99999 }), + hearts_count: faker.number.int({ min: 1, max: 99999 }), + comments_count: faker.number.int({ min: 1, max: 99999 }), + created_at: faker.date.anytime().toISOString(), + updated_at: faker.date.anytime().toISOString(), + user: { + id: 1, + name: faker.person.fullName(), + email: faker.internet.email(), + nickname: faker.internet.userName(), + birthdate: faker.date.past().toDateString(), + created_at: faker.date.anytime().toISOString(), + updated_at: faker.date.anytime().toISOString(), + profile: { + photo: faker.image.avatar(), + share: faker.datatype.boolean(), + }, + }, + comments: [ + { + id: 1, + message: faker.lorem.text(), + created_at: faker.date.anytime().toISOString(), + updated_at: faker.date.anytime().toISOString(), + by: { + id: 1, + name: faker.person.fullName(), + email: faker.internet.email(), + nickname: faker.internet.userName(), + birthdate: faker.date.past().toDateString(), + created_at: faker.date.anytime().toISOString(), + updated_at: faker.date.anytime().toISOString(), + profile: { + photo: faker.image.avatar(), + share: faker.datatype.boolean(), + }, + }, + replies: [], + }, + ], +} diff --git a/src/mocks/game.ts b/src/mocks/game.ts index 7668251..094606b 100644 --- a/src/mocks/game.ts +++ b/src/mocks/game.ts @@ -42,6 +42,7 @@ export const MOCK_GAME_DETAILS: GameDetails = { message: 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Libero, recusandae repudiandae quia itaque, animi quasi iste atque porro nisi sit suscipit consequatur autem deserunt vel deleniti quae harum aliquam aliquid?', created_at: '2024-09-01T14:52:00.000Z', + updated_at: '2024-09-01T14:52:00.000Z', by: { id: 1, name: 'Player 1', @@ -62,6 +63,7 @@ export const MOCK_GAME_DETAILS: GameDetails = { message: 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Libero, recusandae repudiandae quia itaque, animi quasi iste atque porro nisi sit suscipit consequatur autem deserunt vel deleniti quae harum aliquam aliquid?', created_at: '2024-09-01T15:00:00.000Z', + updated_at: '2024-09-01T15:00:00.000Z', by: { id: 1, name: 'Player 2', diff --git a/src/mocks/index.ts b/src/mocks/index.ts index 76ce7d6..e97b860 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -12,3 +12,4 @@ export * from './transactions' export * from './orders' export * from './missions' export * from './titles' +export * from './blogs' diff --git a/src/pages/Blog/Details.tsx b/src/pages/Blog/Details.tsx new file mode 100644 index 0000000..6155ece --- /dev/null +++ b/src/pages/Blog/Details.tsx @@ -0,0 +1,155 @@ +import { Box, Button, Container, Tooltip, Typography } from '@mui/material' +import { formatRelative } from 'date-fns' +import { useState } from 'react' +import { + IoChatbubbleOutline, + IoEyeOutline, + IoHeart, + IoHeartOutline, +} from 'react-icons/io5' + +import { HeartsUp } from '@/components' +import { MOCK_BLOG_DETAILS } from '@/mocks' + +import { CommentsSection } from './modules' + +function Details() { + const post = MOCK_BLOG_DETAILS + + const [hearts, setHearts] = useState(post.hearts_count) + const [isHearted, setIsHearted] = useState(false) + const [heartPops, setHeartPops] = useState([]) + + const handleHeartClick = () => { + setIsHearted((prev) => !prev) + + setHearts(hearts + (isHearted ? -1 : 1)) + + if (!isHearted) { + const newHearts = Array.from({ length: 10 }, (_, i) => i * 10) + + setHeartPops((prev) => [...prev, ...newHearts]) + } + } + + return ( + + + {heartPops.map((delay, index) => ( + + ))} + + + + + {post.title} + + + + {post.title} + + + + + {formatRelative(new Date(post.created_at), new Date())} by{' '} + + {post.user.name} + + + + + + + + {hearts} + + + + + + + + {post.views_count} + + + + + + + + + {post.comments_count} + + + + + + + + + + + + + Categories: + {post.categories.map((category) => ( + + {category.name} + + ))} + + + + Tags: + {post.tags.map((tag) => ( + + #{tag.name} + + ))} + + + + + + + + + + + ) +} + +export default Details diff --git a/src/pages/Blog/List.tsx b/src/pages/Blog/List.tsx new file mode 100644 index 0000000..7a1b8d7 --- /dev/null +++ b/src/pages/Blog/List.tsx @@ -0,0 +1,175 @@ +import { + Box, + Container, + IconButton, + Pagination, + Stack, + Typography, +} from '@mui/material' +import { ChangeEvent, useState } from 'react' +import { MdViewList, MdViewModule } from 'react-icons/md' + +import { MOCK_BLOG_LIST, MOCK_CATEGORIES, MOCK_TAGS } from '@/mocks' +import { Blog } from '@/types' +import { removeDiacritics as rd } from '@/utils' + +import { Aside, PostCard } from './modules' + +function List() { + const [searchTerm, setSearchTerm] = useState('') + const [selectedTags, setSelectedTags] = useState([]) + const [sortBy, setSortBy] = useState('views_count') + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [currentPage, setCurrentPage] = useState(1) + const [isAnimating, setIsAnimating] = useState(false) + const [perPage, setPerPage] = useState(6) + const [selectedCategories, setSelectedCategories] = useState( + [], + ) + + const handleViewChange = (newView: 'grid' | 'list') => { + setIsAnimating(true) + setTimeout(() => { + setViewMode(newView) + setIsAnimating(false) + }, 500) + } + + const blogs = MOCK_BLOG_LIST + const categories = MOCK_CATEGORIES + const tags = MOCK_TAGS + + const handlePageChange = (_: ChangeEvent, page: number) => { + setCurrentPage(page) + } + + const filteredBlogs = blogs + .filter(({ title }) => { + if (searchTerm && searchTerm.trim() !== '') { + const searchWords = rd(searchTerm).toLowerCase().split(' ') + + const contentName = rd(title).toLowerCase() + + return searchWords.every((word) => contentName.includes(word)) + } + + return true + }) + .filter(({ categories }) => + selectedCategories.length > 0 + ? selectedCategories.every((catId) => + categories.some(({ id }) => id === catId), + ) + : true, + ) + .filter(({ tags }) => + selectedTags.length > 0 + ? selectedTags.every((tagId) => + tags.some(({ id }) => id === tagId), + ) + : true, + ) + .sort((a, b) => + a[sortBy as keyof Blog] > b[sortBy as keyof Blog] ? -1 : 1, + ) + + const indexOfLastPost = currentPage * perPage + const indexOfFirstPost = indexOfLastPost - perPage + const currentBlogs = filteredBlogs.slice( + indexOfFirstPost, + indexOfLastPost, + ) + + return ( + + +