diff --git a/.babelrc b/.babelrc index f26687df..ca0cb10d 100644 --- a/.babelrc +++ b/.babelrc @@ -10,5 +10,25 @@ } ] ], - "plugins": ["@emotion/babel-plugin"] + "plugins": [ + [ + "babel-plugin-import", + { + "libraryName": "@mui/material", + "libraryDirectory": "", + "camel2DashComponentName": false + }, + "core" + ], + [ + "babel-plugin-import", + { + "libraryName": "@mui/icons-material", + "libraryDirectory": "", + "camel2DashComponentName": false + }, + "icons" + ], + "@emotion/babel-plugin" + ] } \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 77efc207..ffdd3c2b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,13 +14,14 @@ module.exports = { node: true, }, ignorePatterns: ['.eslintrc.js'], - // settings: { - // 'import/resolver': { - // node: { - // extensions: ['.js', '.jsx', '.ts', '.tsx'], - // }, - // }, - // }, + settings: { + 'import/resolver': { + alias: { + extensions: ['.js', '.jsx'], + map: [['@', '.']], + }, + }, + }, rules: { 'react/no-unescaped-entities': 'off', '@next/next/no-page-custom-font': 'off', @@ -41,6 +42,7 @@ module.exports = { 'operator-linebreak': 0, 'function-paren-newline': 0, 'jsx-a11y/click-events-have-key-events': 0, + 'jsx-a11y/control-has-associated-label': 0, 'jsx-a11y/no-noninteractive-element-interactions': 0, 'react/jsx-one-expression-per-line': 0, 'no-confusing-arrow': 0, diff --git a/components/Group/AreaChips.jsx b/components/Group/AreaChips.jsx new file mode 100644 index 00000000..9b7e2aa3 --- /dev/null +++ b/components/Group/AreaChips.jsx @@ -0,0 +1,47 @@ +import { useCallback, useMemo } from 'react'; +import styled from '@emotion/styled'; +import { AREAS } from '@/constants/areas'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; +import Chip from '@/shared/components/Chip'; + +const StyledAreaChips = styled.ul` + display: flex; + flex-wrap: wrap; + margin-bottom: 16px; +`; + +const AreaChips = () => { + const [getSearchParams, pushState] = useSearchParamsManager(); + + const currentArea = useMemo( + () => + getSearchParams('area').filter((area) => + AREAS.find(({ name }) => name === area), + ), + [getSearchParams], + ); + + const handleClickArea = useCallback( + (event) => { + const targetArea = event.target.parentNode.textContent; + const areas = currentArea.filter((area) => area !== targetArea); + + pushState('area', areas.toString()); + }, + [pushState, currentArea], + ); + + return ( + currentArea.length > 0 && ( + + {currentArea.map((name) => ( +
  • + +
  • + ))} +
    + ) + ); +}; + +export default AreaChips; diff --git a/components/Group/Banner.jsx b/components/Group/Banner.jsx new file mode 100644 index 00000000..3c687c7b --- /dev/null +++ b/components/Group/Banner.jsx @@ -0,0 +1,74 @@ +import { useRouter } from 'next/router'; +import styled from '@emotion/styled'; +import Button from '@/shared/components/Button'; +import groupBannerImg from '@/public/assets/group-banner.png'; +import Image from '@/shared/components/Image'; + +const StyledBanner = styled.div` + position: relative; + + picture { + position: absolute; + width: 100%; + top: 0; + height: 398px; + img { + height: inherit; + } + } + + h1 { + margin-bottom: 8px; + font-weight: 700; + font-size: 36px; + line-height: 140%; + color: #536166; + } + + p { + font-weight: 400; + font-size: 14px; + line-height: 140%; + color: #536166; + } + + > div { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 100px; + } +`; + +const Banner = () => { + const router = useRouter(); + + const handleClick = () => { + // TODO: 判斷是否登入決定按鈕導向哪個頁面 + router.push('/login'); + }; + + return ( + + + 揪團封面 + +
    +

    揪團

    +

    想一起組織有趣的活動或學習小組嗎?

    +

    註冊並加入我們,然後創建你的活動,讓更多人一起參加!

    + +
    +
    + ); +}; + +export default Banner; diff --git a/components/Group/GroupList/GroupCard.jsx b/components/Group/GroupList/GroupCard.jsx new file mode 100644 index 00000000..c7d70b57 --- /dev/null +++ b/components/Group/GroupList/GroupCard.jsx @@ -0,0 +1,54 @@ +import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; +import Image from '@/shared/components/Image'; +import { + StyledAreas, + StyledContainer, + StyledFooter, + StyledGroupCard, + StyledInfo, + StyledLabel, + StyledText, + StyledTitle, +} from './GroupCard.styled'; + +function GroupCard({ + photoURL, + photoAlt, + title = '未定義主題', + category = [], + partnerEducationStep, + description, + area, +}) { + return ( + + {photoAlt} + + {title} + + + 學習領域 + {category.join('、')} + + + 適合階段 + {partnerEducationStep} + + + + {description} + + + + {area} + + + +
    揪團中
    +
    +
    +
    + ); +} + +export default GroupCard; diff --git a/components/Group/GroupList/GroupCard.styled.jsx b/components/Group/GroupList/GroupCard.styled.jsx new file mode 100644 index 00000000..d4fff45c --- /dev/null +++ b/components/Group/GroupList/GroupCard.styled.jsx @@ -0,0 +1,104 @@ +import styled from '@emotion/styled'; + +export const StyledLabel = styled.span` + flex-basis: 50px; + color: #293a3d; + font-size: 12px; + font-weight: bold; +`; + +export const StyledText = styled.div` + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: ${(props) => props.lineClamp || '1'}; + overflow: hidden; + color: ${(props) => props.color || '#536166'}; + font-size: ${(props) => props.fontSize || '12px'}; +`; + +export const StyledTitle = styled.h2` + font-size: 14px; + font-weight: bold; + line-height: 1.4; +`; + +export const StyledInfo = styled.div` + ${StyledLabel} { + margin-right: 5px; + padding-right: 5px; + border-right: 1px solid #536166; + } +`; + +export const StyledFooter = styled.footer` + display: flex; + justify-content: space-between; + align-items: center; + + time, + div { + font-size: 12px; + } + + time { + font-weight: 300; + color: #92989a; + } + + div { + --bg-color: #def5f5; + --color: #16b9b3; + display: flex; + align-items: center; + padding: 4px 10px; + background: var(--bg-color); + color: var(--color); + border-radius: 4px; + font-weight: 500; + gap: 4px; + + &::before { + content: ''; + display: block; + width: 8px; + height: 8px; + background: var(--color); + border-radius: 50%; + } + + &.end { + --bg-color: #f3f3f3; + --color: #92989a; + } + } +`; + +export const StyledContainer = styled.div` + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +export const StyledAreas = styled.div` + display: flex; + align-items: center; +`; + +export const StyledGroupCard = styled.div` + position: relative; + background: #fff; + padding: 0.5rem; + transition: transform 0.15s, box-shadow 0.15s; + border-radius: 4px; + + &:hover { + z-index: 1; + transform: scale(1.0125); + box-shadow: 0 0 6px 2px #0001; + } + + img { + vertical-align: middle; + } +`; diff --git a/components/Group/GroupList/index.jsx b/components/Group/GroupList/index.jsx new file mode 100644 index 00000000..be9c4ccf --- /dev/null +++ b/components/Group/GroupList/index.jsx @@ -0,0 +1,71 @@ +import styled from '@emotion/styled'; +import GroupCard from './GroupCard'; + +export const StyledGroupItem = styled.li` + position: relative; + margin-top: 1rem; + flex-basis: 33.33%; + border-bottom: 1px solid #dbdbdb; + + &:nth-of-type(1) { + margin-top: 0; + } + + &:nth-last-of-type(1) { + border-bottom: none; + } + + @media (max-width: 767px) { + flex-basis: calc(50% - 12px); + } + + @media (min-width: 767px) { + &:nth-of-type(3) { + margin-top: 0; + } + + &:nth-last-of-type(3) { + border-bottom: none; + } + } + + @media (max-width: 560px) { + flex-basis: calc(100% - 24px); + } + + @media (min-width: 560px) { + &:nth-of-type(2) { + margin-top: 0; + } + + &:nth-last-of-type(2) { + border-bottom: none; + } + } +`; + +const StyledGroupList = styled.ul` + display: flex; + flex-wrap: wrap; + justify-content: space-between; +`; + +function GroupList({ list, isLoading }) { + return ( + + {list?.length || isLoading ? ( + list.map((data) => ( + + + + )) + ) : ( +
  • + 哎呀!這裡好像沒有符合你條件的揪團,別失望!讓我們試試其他選項。 +
  • + )} +
    + ); +} + +export default GroupList; diff --git a/components/Group/SearchField/CheckboxGrouping.jsx b/components/Group/SearchField/CheckboxGrouping.jsx new file mode 100644 index 00000000..1b2c6b5e --- /dev/null +++ b/components/Group/SearchField/CheckboxGrouping.jsx @@ -0,0 +1,35 @@ +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +export default function CheckboxGrouping() { + const QUERY_KEY = 'grouping'; + const [getSearchParams, pushState] = useSearchParamsManager(); + + const handleClick = ({ target: { checked } }) => { + pushState(QUERY_KEY, checked || ''); + }; + + const checkbox = ( + + ); + + return ( + + ); +} diff --git a/components/Group/SearchField/SearchInput.jsx b/components/Group/SearchField/SearchInput.jsx new file mode 100644 index 00000000..1a70d086 --- /dev/null +++ b/components/Group/SearchField/SearchInput.jsx @@ -0,0 +1,99 @@ +import { useState, useEffect } from 'react'; +import dynamic from 'next/dynamic'; +import styled from '@emotion/styled'; +import InputBase from '@mui/material/InputBase'; +import Paper from '@mui/material/Paper'; +import IconButton from '@mui/material/IconButton'; +import MicIcon from '@mui/icons-material/Mic'; +import SearchIcon from '@mui/icons-material/Search'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +const Speech = dynamic(import('@/shared/components/Speech'), { + ssr: false, +}); + +const SearchInputWrapper = styled(Paper)` + width: 100%; + position: relative; + display: flex; + align-items: center; + border: 1px solid #dbdbdb; + border-radius: 30px; + padding-right: 4px; + box-shadow: none; + overflow: hidden; + + @media (max-width: 767px) { + border-radius: 20px; + width: 100%; + } +`; + +const IconButtonWrapper = styled(IconButton)` + color: #536166; + border-radius: 40px; + height: 40px; + width: 40px; +`; + +const InputBaseWrapper = styled(InputBase)(() => ({ + flex: 1, + '& .MuiInputBase-input': { + paddingTop: '14px', + paddingLeft: '20px', + paddingBottom: '14px', + background: 'white', + zIndex: 10, + borderRadius: '20px', + width: '100%', + fontSize: 14, + }, +})); + +const SearchInput = () => { + const [getSearchParams, pushState] = useSearchParamsManager(); + const [keyword, setKeyword] = useState(''); + const [isSpeechMode, setIsSpeechMode] = useState(false); + const currentKeyword = getSearchParams('q').toString(); + + useEffect(() => { + setKeyword(currentKeyword); + }, [currentKeyword]); + + const handleChange = ({ target }) => { + setKeyword(target.value); + }; + + /** @type {(event: SubmitEvent) => void} */ + const handleSubmit = (event) => { + event.preventDefault(); + pushState('q', keyword); + }; + + return ( + + + {isSpeechMode && ( + + )} + setIsSpeechMode(true)} + > + + + + + + + ); +}; + +export default SearchInput; diff --git a/components/Group/SearchField/SelectedAreas.jsx b/components/Group/SearchField/SelectedAreas.jsx new file mode 100644 index 00000000..25354144 --- /dev/null +++ b/components/Group/SearchField/SelectedAreas.jsx @@ -0,0 +1,29 @@ +import Select from '@/shared/components/Select'; +import { AREAS } from '@/constants/areas'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; + +export default function SelectedAreas() { + const QUERY_KEY = 'area'; + const [getSearchParams, pushState] = useSearchParamsManager(); + + const handleChange = ({ target: { value } }) => { + pushState(QUERY_KEY, value.toString()); + }; + + return ( + + selected.length === 0 ? '適合的教育階段' : selected.join('、') + } + sx={{ + '@media (max-width: 767px)': { + width: '100%', + }, + }} + /> + ); +} diff --git a/components/Group/SearchField/index.jsx b/components/Group/SearchField/index.jsx new file mode 100644 index 00000000..35f7a40e --- /dev/null +++ b/components/Group/SearchField/index.jsx @@ -0,0 +1,38 @@ +import styled from '@emotion/styled'; +import SearchInput from './SearchInput'; +import SelectedAreas from './SelectedAreas'; +import SelectedEducationStep from './SelectedEducationStep'; +import CheckboxGrouping from './CheckboxGrouping'; + +const StyledSearchField = styled.div` + margin-top: 8px; + width: 100%; + + .selects-wrapper { + margin-top: 12px; + display: flex; + align-items: center; + gap: 16px; + + @media (max-width: 767px) { + margin: 10px 0; + flex-direction: column; + align-items: stretch; + } + } +`; + +const SearchField = () => { + return ( + + +
    + + + +
    +
    + ); +}; + +export default SearchField; diff --git a/components/Group/SelectedCategory.jsx b/components/Group/SelectedCategory.jsx new file mode 100644 index 00000000..068835e6 --- /dev/null +++ b/components/Group/SelectedCategory.jsx @@ -0,0 +1,134 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import styled from '@emotion/styled'; +import { CATEGORIES } from '@/constants/category'; +import useSearchParamsManager from '@/hooks/useSearchParamsManager'; +import ScrollButton from '@/shared/components/ScrollButton'; +import Chip from '@/shared/components/Chip'; + +const StyledSelectedCategory = styled.div` + margin-top: 12px; + display: flex; + align-items: center; + + > p { + margin-right: 20px; + font-weight: 700; + font-size: 14px; + color: #536166; + flex-shrink: 0; + } + + > div { + position: relative; + max-width: calc(100% - 76px); + } + + ul { + display: flex; + overflow-x: scroll; + -ms-overflow-style: none; /* IE */ + scrollbar-width: none; /* Firefox */ + scroll-behavior: smooth; + + &::-webkit-scrollbar { + display: none; /* Chrome, Safari, Edge and Opera */ + } + + @media (max-width: 767px) { + margin: 10px 0; + } + } +`; + +const SelectedCategory = () => { + /** @type {React.MutableRefObject} */ + const categoryListRef = useRef(null); + const [getSearchParams, pushState] = useSearchParamsManager(); + const [isShowLeftScrollButton, setIsShowLeftScrollButton] = useState(false); + const [isShowRightScrollButton, setIsShowRightScrollButton] = useState(false); + + const currentCategories = useMemo( + () => getSearchParams('category'), + [getSearchParams], + ); + + const handleClickCategory = useCallback( + (event) => { + const targetCategory = event.target.textContent; + const hasCategory = currentCategories.includes(targetCategory); + const categories = hasCategory + ? currentCategories.filter((category) => category !== targetCategory) + : [...currentCategories, targetCategory]; + + pushState('category', categories.toString()); + }, + [pushState, currentCategories], + ); + + const updateScrollButtonVisibility = () => { + const { scrollLeft, scrollWidth, clientWidth } = categoryListRef.current; + const isStart = Math.floor(scrollLeft) <= 0; + const isEnd = Math.ceil(scrollLeft + clientWidth) >= scrollWidth; + + setIsShowLeftScrollButton(!isStart); + setIsShowRightScrollButton(!isEnd); + }; + + const resetScrollButtonVisibility = () => { + setIsShowLeftScrollButton(false); + setIsShowRightScrollButton(false); + }; + + const scrollButtonHandler = (type) => () => { + const delta = categoryListRef.current.offsetWidth + 100; + + if (type === 'left') { + categoryListRef.current.scrollLeft -= delta; + } else if (type === 'right') { + categoryListRef.current.scrollLeft += delta; + } + }; + + return ( + +

    學習領域

    +
    + +
      +
    • + pushState('category')} + /> +
    • + {CATEGORIES.map(({ key, value }) => ( +
    • + +
    • + ))} +
    + +
    +
    + ); +}; + +export default SelectedCategory; diff --git a/components/Group/index.jsx b/components/Group/index.jsx new file mode 100644 index 00000000..7489b305 --- /dev/null +++ b/components/Group/index.jsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react'; +import styled from '@emotion/styled'; +import { Box, Button } from '@mui/material'; +import AreaChips from './AreaChips'; +import Banner from './Banner'; +import SearchField from './SearchField'; +import SelectedCategory from './SelectedCategory'; +import GroupList from './GroupList'; + +const StyledGroup = styled.div` + position: relative; + margin: 70px auto 0; + width: 924px; + + @media (max-width: 1024px) { + width: 768px; + } + + @media (max-width: 800px) { + padding: 0 16px; + width: 100%; + } +`; + +const ContainerWrapper = styled(Box)` + padding: 32px; + border-radius: 20px; + box-shadow: 0px 4px 6px rgba(196, 194, 193, 0.2); + background: #fff; + z-index: 2; +`; + +const createTemplate = (_, id) => ({ + id, + title: '颱風天不衝浪要幹嘛', + photoURL: + 'https://images.unsplash.com/photo-1502680390469-be75c86b636f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8c3VyZnxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=800&q=60', + photoAlt: '封面圖', + category: ['語言與文學', '人文社會', '自然科學', '生活'], + partnerEducationStep: '高中', + description: + '希望能像朋友,一起讀有興趣的科目,每週1-2次見面練習這兩種,每次總時數2-3小時不限,希望你跟我一樣很想追求有效進步也不怕辛苦!一起讀日文也可以喔!', + area: '台北市', +}); + +const mockData = (length) => + new Promise((res) => + setTimeout(() => { + res(Array.from({ length }, createTemplate)); + }, 600), + ); + +function Group() { + const [total, setTotal] = useState(12); + const [list, setList] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const setDataAndLoaded = (data) => { + setList(data); + setIsLoading(false); + }; + + setIsLoading(true); + mockData(total).then(setDataAndLoaded); + }, [total]); + + return ( + + + + + + + + + + + {isLoading && ( + + 搜尋揪團中~ + + )} + + + + + + + ); +} + +export default Group; diff --git a/constants/areas.js b/constants/areas.js new file mode 100644 index 00000000..18384fa2 --- /dev/null +++ b/constants/areas.js @@ -0,0 +1,25 @@ +export const AREAS = [ + { name: '線上' }, + { name: '臺北市' }, + { name: '新北市' }, + { name: '基隆市' }, + { name: '桃園市' }, + { name: '新竹市' }, + { name: '新竹縣' }, + { name: '苗栗縣' }, + { name: '臺中市' }, + { name: '南投縣' }, + { name: '彰化縣' }, + { name: '雲林縣' }, + { name: '嘉義市' }, + { name: '嘉義縣' }, + { name: '臺南市' }, + { name: '高雄市' }, + { name: '屏東縣' }, + { name: '臺東縣' }, + { name: '花蓮縣' }, + { name: '宜蘭縣' }, + { name: '澎湖縣' }, + { name: '金門縣' }, + { name: '連江縣' }, +]; diff --git a/hooks/useSearchParamsManager.jsx b/hooks/useSearchParamsManager.jsx new file mode 100644 index 00000000..2e04a136 --- /dev/null +++ b/hooks/useSearchParamsManager.jsx @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useRouter } from 'next/router'; + +export default function useSearchParamsManager() { + const { push } = useRouter(); + const searchParams = useSearchParams(); + + const getSearchParams = useCallback( + (key) => + key + ? (searchParams.get(key) ?? '').split(',').filter(Boolean) + : Object.fromEntries(searchParams.entries()), + [searchParams], + ); + + const pushState = useCallback( + (key, value) => { + const query = Object.fromEntries(searchParams.entries()); + if (value) query[key] = value; + else delete query[key]; + push({ query }, undefined, { scroll: false }); + }, + [push, searchParams], + ); + + return [getSearchParams, pushState]; +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 00000000..2a2e4b3b --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/package.json b/package.json index f4ce5b13..12734547 100644 --- a/package.json +++ b/package.json @@ -70,9 +70,11 @@ "@emotion/babel-plugin": "^11.9.2", "@next/eslint-plugin-next": "^13.2.1", "@types/chrome": "^0.0.206", + "babel-plugin-import": "^1.13.8", "eslint": "^8.35.0", "eslint-config-airbnb": "^18.2.1", "eslint-config-prettier": "^8.6.0", + "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-html": "^6.1.2", "eslint-plugin-import": "^2.26.0", "eslint-plugin-jest": "^27.2.1", diff --git a/pages/group/index.jsx b/pages/group/index.jsx new file mode 100644 index 00000000..5b1f8265 --- /dev/null +++ b/pages/group/index.jsx @@ -0,0 +1,34 @@ +import React, { useMemo } from 'react'; +import { useRouter } from 'next/router'; +import SEOConfig from '@/shared/components/SEO'; +import Group from '@/components/Group'; +import Navigation from '@/shared/components/Navigation_v2'; +import Footer from '@/shared/components/Footer_v2'; + +function GroupPage() { + const router = useRouter(); + const SEOData = useMemo( + () => ({ + title: '揪團學習列表|島島阿學', + description: + '「島島阿學」盼能透過建立多元的學習資源網絡,讓自主學習者能找到合適的成長方法,進一步成為自己想成為的人,從中培養共好精神。目前正積極打造「可共編的學習資源平台」。', + keywords: '島島阿學', + author: '島島阿學', + copyright: '島島阿學', + imgLink: 'https://www.daoedu.tw/preview.webp', + link: `${process.env.HOSTNAME}${router?.asPath}`, + }), + [router?.asPath], + ); + + return ( + <> + + + +