diff --git a/package-lock.json b/package-lock.json index 45f9811..d58e2b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,17 @@ "@chakra-ui/react": "^2.1.1", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@heroicons/react": "^2.1.5", "firebase": "^11.0.1", "framer-motion": "^11.11.11", "next-themes": "^0.4.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", - "react-router-dom": "^6.27.0" + "react-masonry-css": "^1.0.16", + "react-responsive-carousel": "^3.2.23", + "react-router-dom": "^6.27.0", + "react-tsparticles": "^2.12.2" }, "devDependencies": { "@eslint/js": "^9.13.0", @@ -1983,6 +1987,15 @@ "node": ">=6" } }, + "node_modules/@heroicons/react": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.1.5.tgz", + "integrity": "sha512-FuzFN+BsHa+7OxbvAERtgBTNeZpUjgM/MIizfVkSCL2/edriN0Hx/DWRCR//aPYwO5QX/YlgLGXk+E3PcfZwjA==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4073,6 +4086,12 @@ "dev": true, "license": "MIT" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -8116,6 +8135,18 @@ "react": "^18.3.1" } }, + "node_modules/react-easy-swipe": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/react-easy-swipe/-/react-easy-swipe-0.0.21.tgz", + "integrity": "sha512-OeR2jAxdoqUMHIn/nS9fgreI5hSpgGoL5ezdal4+oO7YSSgJR8ga+PkYGJrSrJ9MKlPcQjMQXnketrD7WNmNsg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.8" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -8160,6 +8191,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-masonry-css": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/react-masonry-css/-/react-masonry-css-1.0.16.tgz", + "integrity": "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", @@ -8207,6 +8247,17 @@ } } }, + "node_modules/react-responsive-carousel": { + "version": "3.2.23", + "resolved": "https://registry.npmjs.org/react-responsive-carousel/-/react-responsive-carousel-3.2.23.tgz", + "integrity": "sha512-pqJLsBaKHWJhw/ItODgbVoziR2z4lpcJg+YwmRlSk4rKH32VE633mAtZZ9kDXjy4wFO+pgUZmDKPsPe1fPmHCg==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "prop-types": "^15.5.8", + "react-easy-swipe": "^0.0.21" + } + }, "node_modules/react-router": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", @@ -8262,6 +8313,34 @@ } } }, + "node_modules/react-tsparticles": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/react-tsparticles/-/react-tsparticles-2.12.2.tgz", + "integrity": "sha512-/nrEbyL8UROXKIMXe+f+LZN2ckvkwV2Qa+GGe/H26oEIc+wq/ybSG9REDwQiSt2OaDQGu0MwmA4BKmkL6wAWcA==", + "deprecated": "@tsparticles/react is the new version, please use that", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/matteobruni" + }, + { + "type": "github", + "url": "https://github.com/sponsors/tsparticles" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/matteobruni" + } + ], + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "tsparticles-engine": "^2.12.0" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -8954,6 +9033,28 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsparticles-engine": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/tsparticles-engine/-/tsparticles-engine-2.12.0.tgz", + "integrity": "sha512-ZjDIYex6jBJ4iMc9+z0uPe7SgBnmb6l+EJm83MPIsOny9lPpetMsnw/8YJ3xdxn8hV+S3myTpTN1CkOVmFv0QQ==", + "deprecated": "starting from tsparticles v3 the packages are now moved to @tsparticles/package-name instead of tsparticles-package-name", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/matteobruni" + }, + { + "type": "github", + "url": "https://github.com/sponsors/tsparticles" + }, + { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/matteobruni" + } + ], + "hasInstallScript": true, + "license": "MIT" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 6f405a1..826023b 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ "@chakra-ui/react": "^2.1.1", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@heroicons/react": "^2.1.5", "firebase": "^11.0.1", "framer-motion": "^11.11.11", "next-themes": "^0.4.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", + "react-masonry-css": "^1.0.16", + "react-responsive-carousel": "^3.2.23", "react-router-dom": "^6.27.0" }, "devDependencies": { diff --git a/src/App.jsx b/src/App.jsx index b6392d1..aa3811b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,8 @@ import Thread from './components/Thread'; import ThreadDetail from './components/ThreadDetail'; import CreateThread from './components/CreateThread'; import AdminPage from './components/AdminPage'; +import ProfileMasonry from './components/Profile/ProfileMasonry'; +import ProfileEdit from './components/ProfileEdit'; function App() { const location = useLocation(); @@ -25,6 +27,8 @@ function App() { } /> } /> } /> + } /> + } /> ); diff --git a/src/components/HeroCard.jsx b/src/components/HeroCard.jsx new file mode 100644 index 0000000..a3e97d3 --- /dev/null +++ b/src/components/HeroCard.jsx @@ -0,0 +1,35 @@ +import { motion } from 'framer-motion'; +import { PropTypes } from 'prop-types'; +import { Avatar, Text } from '@chakra-ui/react'; + +const HeroCard = ({ user }) => { + const cardVariants = { + hover: { + scale: 1.05, + transition: { duration: 0.2 }, + }, + }; + + HeroCard.propTypes = { + user: PropTypes.object.isRequired, + }; + + return ( + + + + {user.displayName || 'Anonymous'} + + {user.email} + + ); +}; + +export default HeroCard; diff --git a/src/components/Profile/AchievementCard.jsx b/src/components/Profile/AchievementCard.jsx new file mode 100644 index 0000000..22641ed --- /dev/null +++ b/src/components/Profile/AchievementCard.jsx @@ -0,0 +1,66 @@ +import { Box, Grid, HStack, Text, Icon, VStack } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { TrophyIcon } from '@heroicons/react/24/outline'; + +import { PropTypes } from 'prop-types'; + +const MotionBox = motion(Box); + +const AchievementBadge = ({ icon: IconComponent, name }) => ( + + + + {name} + + +); + +AchievementBadge.propTypes = { + icon: PropTypes.elementType.isRequired, + name: PropTypes.string.isRequired, +}; + +const AchievementCard = ({ achievements }) => ( + + + + + アチーブメント + + + + {achievements.map((achievement) => ( + + ))} + + +); + +AchievementCard.propTypes = { + achievements: PropTypes.array.isRequired, +}; + +export default AchievementCard; diff --git a/src/components/Profile/HallOfFameCard.jsx b/src/components/Profile/HallOfFameCard.jsx new file mode 100644 index 0000000..ca58c0d --- /dev/null +++ b/src/components/Profile/HallOfFameCard.jsx @@ -0,0 +1,49 @@ +import { Box, HStack, Text, Icon } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { HiOutlineStar } from 'react-icons/hi'; +import { PropTypes } from 'prop-types'; + +const MotionBox = motion(Box); + +const HallOfFameCard = ({ post }) => ( + + + + + 殿堂入り投稿 + + + + {post.content} + + + + {new Date(post.createdAt?.toDate()).toLocaleDateString()} + + + + {post.likes} + + + +); + +HallOfFameCard.propTypes = { + post: PropTypes.object.isRequired, +}; + +export default HallOfFameCard; diff --git a/src/components/Profile/ProfileMasonry.jsx b/src/components/Profile/ProfileMasonry.jsx new file mode 100644 index 0000000..1229bce --- /dev/null +++ b/src/components/Profile/ProfileMasonry.jsx @@ -0,0 +1,163 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Masonry from 'react-masonry-css'; +import { Box } from '@chakra-ui/react'; +// eslint-disable-next-line no-unused-vars +import { motion } from 'framer-motion'; +import { auth, db } from '../../config/firebase'; +import { collection, query, where, getDocs, orderBy } from 'firebase/firestore'; +import { + ChatBubbleBottomCenterTextIcon, + StarIcon, + FireIcon, + HeartIcon, + ChartBarIcon, + CheckBadgeIcon, +} from '@heroicons/react/24/outline'; +import UserInfoCard from './UserInfoCard'; +import StatCard from './StatCard'; +import AchievementCard from './AchievementCard'; +import HallOfFameCard from './HallOfFameCard'; +import '../ProfileStyles.css'; + +//const MotionBox = motion(Box); + +const ProfileMasonry = () => { + const navigate = useNavigate(); + const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + // eslint-disable-next-line no-unused-vars + const [userStats, setUserStats] = useState({ + totalPosts: 0, + totalHallOfFame: 0, + streak: 0, + }); + // eslint-disable-next-line no-unused-vars + const [achievements, setAchievements] = useState([]); + const [hallOfFamePosts, setHallOfFamePosts] = useState([]); + + const breakpointColumns = { + default: 3, + 1100: 2, + 700: 1, + }; + + const mockStats = { + totalPosts: 42, + totalHallOfFame: 10, + streak: 5, + }; + + const mockAchievements = [ + { id: 1, name: '初投稿', icon: ChatBubbleBottomCenterTextIcon }, + { id: 2, name: '殿堂入り', icon: StarIcon }, + { id: 3, name: '3日連続投稿', icon: FireIcon }, + { id: 4, name: '人気者', icon: HeartIcon }, + { id: 5, name: 'トレンド入り', icon: ChartBarIcon }, + { id: 6, name: '完璧な回答', icon: CheckBadgeIcon }, + ]; + + useEffect(() => { + const unsubscribe = auth.onAuthStateChanged((currentUser) => { + setUser(currentUser); + if (currentUser) { + fetchUserData(currentUser.uid); + } else { + setLoading(false); + navigate('/login'); + } + }); + + return () => unsubscribe(); + }, [navigate]); + + const fetchUserData = async (uid) => { + try { + // Stats取得 + const postsQuery = query( + collection(db, 'posts'), + where('userId', '==', uid), + orderBy('createdAt', 'desc') + ); + const hallOfFameQuery = query( + collection(db, 'posts'), + where('userId', '==', uid), + where('isHallOfFame', '==', true) + ); + const streakQuery = query( + collection(db, 'userStats'), + where('userId', '==', uid) + ); + + // Achievement取得 + const achievementsQuery = query( + collection(db, 'achievements'), + where('userId', '==', uid) + ); + + const [ + postsSnapshot, + hallOfFameSnapshot, + streakSnapshot, + achievementsSnapshot, + ] = await Promise.all([ + getDocs(postsQuery), + getDocs(hallOfFameQuery), + getDocs(streakQuery), + getDocs(achievementsQuery), + ]); + + setUserStats({ + totalPosts: postsSnapshot.size, + totalHallOfFame: hallOfFameSnapshot.size, + streak: streakSnapshot.docs[0]?.data()?.currentStreak || 0, + }); + + setAchievements( + achievementsSnapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) + ); + + setHallOfFamePosts( + hallOfFameSnapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) + ); + + setLoading(false); + } catch (error) { + console.error('Error fetching user data:', error); + setLoading(false); + } + }; + + if (loading) { + return ( + + Loading... + + ); + } + + return ( + + + + + + {hallOfFamePosts.map((post) => ( + + ))} + + + ); +}; + +export default ProfileMasonry; diff --git a/src/components/Profile/StatCard.jsx b/src/components/Profile/StatCard.jsx new file mode 100644 index 0000000..afffa63 --- /dev/null +++ b/src/components/Profile/StatCard.jsx @@ -0,0 +1,77 @@ +import { Box, Grid, VStack, Text, Icon } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { + ChatBubbleBottomCenterTextIcon, + StarIcon, + FireIcon, +} from '@heroicons/react/24/outline'; +import { PropTypes } from 'prop-types'; + +const MotionBox = motion(Box); + +const StatItem = ({ icon, value, label }) => ( + + + + {value} + + + {label} + + +); + +StatItem.propTypes = { + icon: PropTypes.elementType.isRequired, + value: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, +}; + +const StatCard = ({ stats }) => ( + + + + + + + +); + +StatCard.propTypes = { + stats: PropTypes.object.isRequired, +}; +export default StatCard; diff --git a/src/components/Profile/UserInfoCard.jsx b/src/components/Profile/UserInfoCard.jsx new file mode 100644 index 0000000..c9aa664 --- /dev/null +++ b/src/components/Profile/UserInfoCard.jsx @@ -0,0 +1,61 @@ +import { Link } from 'react-router-dom'; +import { Box, VStack, Text, IconButton, Avatar } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { HiPencil } from 'react-icons/hi'; +import { PropTypes } from 'prop-types'; + +const MotionBox = motion(Box); + +const UserInfoCard = ({ user }) => ( + + } + position="absolute" + top={4} + right={4} + colorScheme="purple" + variant="ghost" + borderRadius="full" + _hover={{ + bg: 'rgba(138, 43, 226, 0.2)', + }} + /> + + + + {user?.displayName} + + @{user?.uid?.slice(0, 8)} + + +); + +UserInfoCard.propTypes = { + user: PropTypes.object.isRequired, +}; + +export default UserInfoCard; diff --git a/src/components/ProfileEdit.jsx b/src/components/ProfileEdit.jsx new file mode 100644 index 0000000..30a9270 --- /dev/null +++ b/src/components/ProfileEdit.jsx @@ -0,0 +1,201 @@ +import { useState, useEffect } from 'react'; +import { auth, db } from '../config/firebase'; +import { updateProfile, updateEmail, updatePassword } from 'firebase/auth'; +import { doc, updateDoc } from 'firebase/firestore'; +import { + Box, + Button, + Input, + Flex, + Avatar, + Select, + FormControl, + FormLabel, + FormErrorMessage, + Text, +} from '@chakra-ui/react'; +import { motion } from 'framer-motion'; + +const MotionBox = motion(Box); +const MotionButton = motion(Button); +const MotionInput = motion(Input); +const MotionSelect = motion(Select); + +const ProfileEdit = () => { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [icon, setIcon] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const iconOptions = ['icon1.png', 'icon2.png', 'icon3.png']; // アイコンの選択肢 + + useEffect(() => { + const user = auth.currentUser; + if (user) { + setUsername(user.displayName || ''); + setEmail(user.email || ''); + setIcon(user.photoURL || ''); + } + }, []); + + const handleUpdateProfile = async () => { + try { + const user = auth.currentUser; + if (user) { + await updateProfile(user, { + displayName: username, + photoURL: icon, + }); + await updateEmail(user, email); + if (password) { + await updatePassword(user, password); + } + const userDoc = doc(db, 'users', user.uid); + await updateDoc(userDoc, { + username, + email, + photoURL: icon, + }); + setSuccess('プロフィールが更新されました'); + setError(''); // 成功時はエラーメッセージをクリア + } + } catch (err) { + console.error(err); + setError('プロフィールの更新に失敗しました'); + setSuccess(''); // 失敗時は成功メッセージをクリア + } + }; + + return ( + + + + + setIcon(e.target.value)} + mb={4} + bg="rgba(255, 255, 255, 0.06)" + color="white" + _hover={{ bg: 'rgba(255, 255, 255, 0.08)' }} // commonStyles.inputHoverBg を直接指定 + _focus={{ + bg: 'rgba(255, 255, 255, 0.08)', // commonStyles.inputFocusBg を直接指定 + boxShadow: '0 0 0 2px pink.400', // commonStyles.inputFocusBoxShadow を直接指定 + borderColor: 'transparent', + }} + borderRadius="2xl" // commonStyles.borderRadius を直接指定 + fontSize="lg" // commonStyles.fontSize を直接指定 + fontWeight="bold" // commonStyles.fontWeight を直接指定 + transition="all 0.3s" + border="1px solid rgba(255, 255, 255, 0.1)" // commonStyles.border を直接指定 + boxShadow="0 0 30px rgba(236, 72, 153, 0.3)" + whileHover={{ + scale: 1.05, + boxShadow: '0 0 25px rgba(236, 72, 153, 0.5)', + }} + whileTap={{ scale: 0.95 }} + > + {iconOptions.map((option) => ( + + ))} + + + + {' '} + {/* errorがnullでない場合にisInvalidをtrueにする */} + + パスワード + + setPassword(e.target.value)} + placeholder="新しいパスワードを入力してください" + bg="rgba(255, 255, 255, 0.06)" + color="white" + _placeholder={{ color: 'gray.400' }} + _hover={{ bg: 'rgba(255, 255, 255, 0.08)' }} // commonStyles.inputHoverBg を直接指定 + _focus={{ + bg: 'rgba(255, 255, 255, 0.08)', // commonStyles.inputFocusBg を直接指定 + boxShadow: '0 0 0 2px pink.400', // commonStyles.inputFocusBoxShadow を直接指定 + borderColor: 'transparent', + }} + borderRadius="2xl" // commonStyles.borderRadius を直接指定 + fontSize="lg" // commonStyles.fontSize を直接指定 + fontWeight="bold" // commonStyles.fontWeight を直接指定 + transition="all 0.3s" + border="1px solid rgba(255, 255, 255, 0.1)" // commonStyles.border を直接指定 + boxShadow="0 0 30px rgba(236, 72, 153, 0.3)" + whileHover={{ + scale: 1.05, + boxShadow: '0 0 25px rgba(236, 72, 153, 0.5)', + }} + whileTap={{ scale: 0.95 }} + /> + {error}{' '} + {/* エラーメッセージを表示 */} + + {/* ... existing code for email and password ... */} + + 更新 + + {error && ( + + {error} + + )}{' '} + {/* エラーメッセージ */} + {success && ( + + {success} + + )}{' '} + {/* 成功メッセージ */} + + + ); +}; + +export default ProfileEdit; diff --git a/src/components/ProfileStyles.css b/src/components/ProfileStyles.css new file mode 100644 index 0000000..f31cd9e --- /dev/null +++ b/src/components/ProfileStyles.css @@ -0,0 +1,24 @@ +.masonry-grid { + display: flex; + width: auto; + margin-left: -16px; +} + +.masonry-grid_column { + padding-left: 16px; + background-clip: padding-box; +} + +.masonry-grid_column>div { + margin-bottom: 16px; +} + +@media (max-width: 700px) { + .masonry-grid { + margin-left: -8px; + } + + .masonry-grid_column { + padding-left: 8px; + } +} \ No newline at end of file