├── styles ├── index.module.scss ├── footer.module.scss ├── layout.module.scss ├── chips.module.scss ├── global.css ├── keyword.module.scss ├── publisher.module.scss ├── vars.scss ├── bookmark-count.module.scss ├── order.module.scss ├── header.module.scss ├── period-group.module.scss ├── entry-list.module.scss ├── search.module.scss ├── period.module.scss └── entry.module.scss ├── public ├── icon.png ├── favicon.ico ├── noimage.png └── sitemap.xml ├── .env.template ├── next-env.d.ts ├── .prettierrc ├── next.config.js ├── README.md ├── .editorconfig ├── components ├── footer.tsx ├── layout.tsx ├── search │ ├── chips.tsx │ ├── keyword.tsx │ ├── order.tsx │ ├── search.tsx │ ├── publisher.tsx │ ├── period-group.tsx │ ├── period.tsx │ └── bookmark-count.tsx ├── header.tsx ├── util │ └── accordion.tsx ├── entry-list.tsx ├── menu │ ├── announcement.tsx │ └── help.tsx └── entry.tsx ├── pages ├── api │ ├── publisher.ts │ └── search.ts ├── _document.js ├── _app.js └── index.tsx ├── .gitignore ├── tsconfig.json ├── src └── lib │ └── gtag.js ├── package.json ├── helpers └── util.ts └── models └── model.ts /styles/index.module.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktny/bukumanga/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktny/bukumanga/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/noimage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktny/bukumanga/HEAD/public/noimage.png -------------------------------------------------------------------------------- /styles/footer.module.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | text-align: center; 3 | padding: 24px; 4 | } 5 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=http://localhost:5000 2 | NEXT_PUBLIC_GOOGLE_ANALYTICS_ID= 3 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /styles/layout.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 1608px; 3 | margin: 0 auto; 4 | padding: 0 24px; 5 | } 6 | -------------------------------------------------------------------------------- /styles/chips.module.scss: -------------------------------------------------------------------------------- 1 | @import "./vars.scss"; 2 | 3 | .chip { 4 | background-color: $primary !important; 5 | color: $white; 6 | } 7 | -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fafafa; 3 | } 4 | 5 | .active { 6 | color: #f62e36; 7 | } 8 | 9 | .bold { 10 | font-weight: bold; 11 | } 12 | -------------------------------------------------------------------------------- /styles/keyword.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | min-width: 120px; 3 | border-bottom: 1px solid #999; 4 | @media screen and (min-width: 481px) { 5 | flex-grow: 1; 6 | border-bottom: none; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /styles/publisher.module.scss: -------------------------------------------------------------------------------- 1 | .formControl { 2 | margin-top: 8px; 3 | min-width: 200px; 4 | } 5 | 6 | .chips { 7 | display: flex; 8 | flex-wrap: wrap; 9 | } 10 | 11 | .chip { 12 | margin: 2px; 13 | } 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "es5", 8 | "arrowParens": "avoid", 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | images: { 3 | domains: [ 4 | "cdn-ak-scissors.b.st-hatena.com", 5 | "cdn.profile-image.st-hatena.com", 6 | "bukumanga.vercel.app", 7 | "bukumanga.com", 8 | ], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /styles/vars.scss: -------------------------------------------------------------------------------- 1 | // color 2 | $primary: #f62e36; 3 | $secondary: #ffc6c7; 4 | $background: #fafafa; 5 | $white: #fafafa; 6 | $text: rgba(0, 0, 0, 0.87); 7 | 8 | // z-index 9 | $searchZIndex: 1000; 10 | $overlayZIndex: 900; 11 | $favZIndex: 800; 12 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://bukumanga.com/ 5 | 1.0 6 | hourly 7 | 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bukumanga 2 | 3 | ## About 4 | 5 | https://bukumanga.com/ 6 | 7 | はてなブックマークを元にwebマンガをまとめているサイトです。 8 | フロントエンドの機能のみ備えたリポジトリです。 9 | 実行にはAPIとデータが必要になります。 10 | APIのリポジトリはこちらです。 11 | https://github.com/ktny/bukumanga-api 12 | 13 | ## How to use 14 | 15 | ```sh 16 | npm run dev 17 | ``` 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /components/footer.tsx: -------------------------------------------------------------------------------- 1 | import classes from "../styles/footer.module.scss"; 2 | import { siteName } from "../pages/_app"; 3 | 4 | export default function Footer() { 5 | return ( 6 |
7 |
© 2021 {siteName.toLowerCase()}
8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /styles/bookmark-count.module.scss: -------------------------------------------------------------------------------- 1 | @import "./vars.scss"; 2 | 3 | .root { 4 | flex-basis: 30%; 5 | min-width: 270px; 6 | flex-grow: 2; 7 | } 8 | 9 | .gridItemSlider { 10 | flex-grow: 1; 11 | } 12 | 13 | .sliderRoot { 14 | color: $primary; 15 | } 16 | 17 | .sliderInput { 18 | width: 56px; 19 | } 20 | -------------------------------------------------------------------------------- /styles/order.module.scss: -------------------------------------------------------------------------------- 1 | @import "./vars.scss"; 2 | 3 | .root { 4 | display: flex; 5 | align-items: center; 6 | margin-bottom: -16px; 7 | } 8 | 9 | .count { 10 | margin-right: 1rem; 11 | font-size: 1rem; 12 | line-height: 1; 13 | } 14 | 15 | .countNum { 16 | margin-right: 4px; 17 | line-height: 1; 18 | color: $primary; 19 | } 20 | -------------------------------------------------------------------------------- /pages/api/publisher.ts: -------------------------------------------------------------------------------- 1 | import { GetPublishersResponse } from "../../models/model"; 2 | 3 | const baseUrl = `${process.env.NEXT_PUBLIC_API_URL}/publishers`; 4 | 5 | /** 6 | * 条件に合致するエントリを検索する 7 | * @return 条件に合致するEntryのリスト 8 | */ 9 | export default function getPublishers(): Promise { 10 | return fetch(`${baseUrl}`).then(res => res.json()); 11 | } 12 | -------------------------------------------------------------------------------- /styles/header.module.scss: -------------------------------------------------------------------------------- 1 | @import "./vars.scss"; 2 | 3 | .header { 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | margin-top: 24px; 8 | } 9 | 10 | .headerLeft { 11 | display: flex; 12 | align-items: center; 13 | } 14 | 15 | .title { 16 | margin-right: 40px; 17 | } 18 | 19 | .link { 20 | color: $text; 21 | text-decoration: none; 22 | } 23 | -------------------------------------------------------------------------------- /components/layout.tsx: -------------------------------------------------------------------------------- 1 | import Header from "./header"; 2 | import Footer from "./footer"; 3 | import classes from "../styles/layout.module.scss"; 4 | 5 | export default function Layout({ children, ...props }: { children: React.ReactNode }) { 6 | return ( 7 |
8 |
9 |
{children}
10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /styles/period-group.module.scss: -------------------------------------------------------------------------------- 1 | @import "./vars.scss"; 2 | 3 | .periods { 4 | margin-top: 8px; 5 | @media screen and (min-width: 481px) { 6 | margin-top: 0; 7 | } 8 | } 9 | 10 | .periodItem { 11 | padding: 0 16px; 12 | font-family: "Noto Sans JP", sans-serif; 13 | font-size: 16px; 14 | @media screen and (max-width: 375px) { 15 | font-size: 14px; 16 | } 17 | @media screen and (max-width: 320px) { 18 | padding: 0 12px; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "pages/_app.js"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/gtag.js: -------------------------------------------------------------------------------- 1 | export const GA_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID; 2 | 3 | // IDが取得できない場合を想定する 4 | export const existsGaId = GA_ID !== ""; 5 | 6 | // PVを測定する 7 | export const pageview = path => { 8 | window.gtag("config", GA_ID, { 9 | page_path: path, 10 | }); 11 | }; 12 | 13 | // GAイベントを発火させる 14 | export const event = ({ action, category, label, value = "" }) => { 15 | if (!existsGaId) { 16 | return; 17 | } 18 | 19 | window.gtag("event", action, { 20 | event_category: category, 21 | event_label: JSON.stringify(label), 22 | value, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /components/search/chips.tsx: -------------------------------------------------------------------------------- 1 | import { Chip } from "@material-ui/core"; 2 | import classes from "../../styles/chips.module.scss"; 3 | 4 | export interface IChip { 5 | label: string; 6 | value: T; 7 | } 8 | 9 | export default function Chips({ 10 | chips, 11 | clickHandler, 12 | }: { 13 | chips: IChip[]; 14 | clickHandler: (chip: IChip) => () => void; 15 | }) { 16 | return ( 17 | <> 18 | {chips.map((chip, i) => ( 19 | 20 | ))} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /styles/entry-list.module.scss: -------------------------------------------------------------------------------- 1 | @import "./vars.scss"; 2 | 3 | .root { 4 | margin-top: 32px; 5 | } 6 | 7 | .entryList { 8 | justify-content: space-between; 9 | @media screen and (max-width: 480px) { 10 | justify-content: center; 11 | } 12 | } 13 | 14 | .entryItem { 15 | margin-top: 24px; 16 | } 17 | 18 | .entryItemEmpty { 19 | width: 300px; 20 | } 21 | 22 | .progress { 23 | position: relative; 24 | left: calc(50% - 20px); 25 | top: 20px; 26 | } 27 | 28 | .fab { 29 | position: fixed; 30 | bottom: 16px; 31 | right: 16px; 32 | z-index: $favZIndex; 33 | background-color: $primary !important; 34 | color: $white; 35 | &:hover { 36 | opacity: 0.8; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /components/search/keyword.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from "react"; 2 | import { Box, InputBase } from "@material-ui/core"; 3 | import classes from "../../styles/keyword.module.scss"; 4 | 5 | export default function Keyword({ 6 | keyword, 7 | setKeyword, 8 | }: { 9 | keyword: string; 10 | setKeyword: Dispatch>; 11 | }) { 12 | const handleInputChange = (event: React.ChangeEvent) => { 13 | setKeyword(event.target.value); 14 | }; 15 | 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bukumanga", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@material-ui/core": "^4.11.4", 12 | "@material-ui/icons": "^4.11.2", 13 | "@material-ui/lab": "^4.0.0-alpha.58", 14 | "lodash": "^4.17.21", 15 | "next": "10.2.0", 16 | "node-sass": "^5.0.0", 17 | "react": "17.0.2", 18 | "react-dom": "17.0.2", 19 | "use-debounce": "^6.0.1", 20 | "use-media": "^1.4.0" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^15.3.0", 24 | "@types/react": "^17.0.5", 25 | "normalize.css": "^8.0.1", 26 | "prettier": "^2.3.0", 27 | "typescript": "^4.2.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /styles/search.module.scss: -------------------------------------------------------------------------------- 1 | @import "./vars.scss"; 2 | 3 | .root { 4 | margin-top: 32px; 5 | @media screen and (min-width: 481px) { 6 | background-color: $white; 7 | padding: 0 30px; 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | border: 1px solid #ccc; 12 | border-radius: 40px; 13 | box-shadow: 0 0 1px #ccc; 14 | } 15 | @media screen and (min-width: 481px) and (max-width: 1024px) { 16 | overflow-x: scroll; 17 | } 18 | } 19 | 20 | .divider { 21 | margin-top: 24px; 22 | @media screen and (min-width: 481px) { 23 | margin: 0 24px; 24 | } 25 | } 26 | 27 | .detailSearchSummary { 28 | display: flex; 29 | align-items: center; 30 | } 31 | 32 | .detailSearch { 33 | margin-left: 4px; 34 | font-size: 16px; 35 | } 36 | -------------------------------------------------------------------------------- /styles/period.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | @media screen and (min-width: 481px) { 3 | flex-basis: 340px; 4 | min-width: 340px; 5 | padding: 16px 0; 6 | } 7 | } 8 | 9 | .gridContainer { 10 | justify-content: space-between; 11 | align-items: center; 12 | flex-wrap: nowrap; 13 | overflow-x: scroll; 14 | @media screen and (min-width: 481px) { 15 | overflow-x: auto; 16 | } 17 | } 18 | 19 | .gridItem { 20 | min-width: 140px; 21 | @media screen and (min-width: 481px) { 22 | min-width: auto; 23 | } 24 | } 25 | 26 | .glue { 27 | font-size: 16px; 28 | margin: 0 12px; 29 | @media screen and (min-width: 481px) { 30 | font-size: 24px; 31 | // margin: 0; 32 | } 33 | } 34 | 35 | .input { 36 | width: 140px; 37 | @media screen and (min-width: 481px) { 38 | width: auto; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@material-ui/core"; 2 | import classes from "../styles/header.module.scss"; 3 | import { siteName } from "../pages/_app"; 4 | import PeriodGroup from "./search/period-group"; 5 | import Help from "./menu/help"; 6 | import Announcement from "./menu/announcement"; 7 | 8 | export default function Header(props) { 9 | return ( 10 |
11 |
12 | 13 | 14 | {siteName} 15 | 16 | 17 | {props.isSP ? <> : } 18 |
19 |
20 | 21 | 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /helpers/util.ts: -------------------------------------------------------------------------------- 1 | import { IPeriod } from "../models/model"; 2 | 3 | /** 4 | * Dateをstringに変換する 5 | * @param date 日付 6 | * @return 日付文字列 7 | */ 8 | export const date2str = (date: Date): string => { 9 | const year = date.getFullYear(); 10 | const month = (date.getMonth() + 1).toString().padStart(2, "0"); 11 | const day = date.getDate().toString().padStart(2, "0"); 12 | return `${year}-${month}-${day}`; 13 | }; 14 | 15 | /** 16 | * 指定の数値配列を作成する 17 | * @param start 開始数値 18 | * @param end 終了数値 19 | * @param step ステップ数 20 | * @return 開始から終了までの数値配列 21 | */ 22 | export const range = (start: number, end: number, step: number = 1): number[] => { 23 | return [...Array(Math.floor((end - start) / step) + 1)].map((_, i) => start + i * step); 24 | }; 25 | 26 | /** 27 | * すべてinactiveにした期間リストを返す 28 | * @param periods 期間リスト 29 | * @return すべてinactiveの期間リスト 30 | */ 31 | export const getInActivePeriods = (periods: IPeriod[]) => { 32 | return periods.map(period => { 33 | period.active = false; 34 | return period; 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /components/util/accordion.tsx: -------------------------------------------------------------------------------- 1 | import { withStyles } from "@material-ui/core/styles"; 2 | import MuiAccordion from "@material-ui/core/Accordion"; 3 | import MuiAccordionSummary from "@material-ui/core/AccordionSummary"; 4 | import MuiAccordionDetails from "@material-ui/core/AccordionDetails"; 5 | 6 | export const Accordion = withStyles({ 7 | root: { 8 | border: "none", 9 | boxShadow: "none", 10 | fontFamily: "Noto Sans JP", 11 | backgroundColor: "#fafafa", 12 | margin: 0, 13 | "&:not(:last-child)": { 14 | borderBottom: 0, 15 | }, 16 | "&:before": { 17 | display: "none", 18 | }, 19 | "&$expanded": { 20 | margin: 0, 21 | }, 22 | }, 23 | expanded: {}, 24 | })(MuiAccordion); 25 | 26 | export const AccordionSummary = withStyles({ 27 | root: { 28 | padding: "16px 0", 29 | minHeight: 0, 30 | maxWidth: "150px", 31 | "&$expanded": { 32 | minHeight: 0, 33 | }, 34 | }, 35 | content: { 36 | margin: 0, 37 | "&$expanded": { 38 | margin: 0, 39 | }, 40 | }, 41 | expanded: {}, 42 | })(MuiAccordionSummary); 43 | 44 | export const AccordionDetails = withStyles({ 45 | root: { 46 | padding: 0, 47 | }, 48 | })(MuiAccordionDetails); 49 | -------------------------------------------------------------------------------- /components/search/order.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from "react"; 2 | import { Box, IconButton, MenuItem, Select } from "@material-ui/core"; 3 | import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward"; 4 | import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward"; 5 | import classes from "../../styles/order.module.scss"; 6 | import { Props } from "../../models/model"; 7 | 8 | export default function Order(props: Props) { 9 | const handleOrderKeyChange = (event: React.ChangeEvent) => { 10 | props.setOrderKey(event.target.value); 11 | }; 12 | const handleOrderAscClick = (event: React.MouseEvent) => { 13 | props.setOrderAsc(!props.orderAsc); 14 | }; 15 | 16 | return ( 17 | 18 |
19 | {props.count} HIT 20 |
21 |
22 | 23 | {props.orderAsc ? : } 24 | 25 | 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/search/search.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from "@material-ui/core"; 2 | import { Accordion, AccordionDetails, AccordionSummary } from "../util/accordion"; 3 | import SearchIcon from "@material-ui/icons/Search"; 4 | import Period from "./period"; 5 | import PeriodGroup from "./period-group"; 6 | import Keyword from "./keyword"; 7 | import BookmarkCount from "./bookmark-count"; 8 | import Publisher from "./publisher"; 9 | import classes from "../../styles/search.module.scss"; 10 | 11 | export default function Search(props) { 12 | return ( 13 |
14 |
15 | 16 | 17 | 18 | {props.isSP ? : <>} 19 | 20 | 21 |
22 | 23 | 24 |
25 | 26 | 詳細検索 27 |
28 |
29 | 30 | 31 | 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /components/search/publisher.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from "react"; 2 | import { Chip, FormControl, Select, MenuItem } from "@material-ui/core"; 3 | import { IPublisher } from "../../models/model"; 4 | import classes from "../../styles/publisher.module.scss"; 5 | 6 | export default function Publisher({ 7 | publishers, 8 | publisherIds, 9 | setPublisherIds, 10 | }: { 11 | publishers: IPublisher[]; 12 | publisherIds: number[]; 13 | setPublisherIds: Dispatch>; 14 | }) { 15 | const options = publishers; 16 | 17 | const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => { 18 | setPublisherIds(event.target.value as number[]); 19 | }; 20 | 21 | return ( 22 |
23 |
配信サイト
24 | 25 | 47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /components/search/period-group.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction, useState } from "react"; 2 | import { Button, ButtonGroup } from "@material-ui/core"; 3 | import classes from "../../styles/period-group.module.scss"; 4 | import { IPeriod } from "../../models/model"; 5 | import { getInActivePeriods } from "../../helpers/util"; 6 | 7 | export default function PeriodGroup({ 8 | periods, 9 | setPeriods, 10 | setStartDate, 11 | setEndDate, 12 | }: { 13 | periods: IPeriod[]; 14 | setPeriods: Dispatch>; 15 | setStartDate: Dispatch>; 16 | setEndDate: Dispatch>; 17 | }) { 18 | const setPeriod = (days: number) => { 19 | return () => { 20 | const endDate = new Date(); 21 | let startDate = new Date(endDate.getTime()); 22 | if (days === Infinity) { 23 | startDate = new Date(2005, 0, 1); 24 | } else { 25 | startDate.setDate(startDate.getDate() - (days - 1)); 26 | } 27 | setStartDate(startDate); 28 | setEndDate(endDate); 29 | 30 | const newPeriods = getInActivePeriods(periods); 31 | newPeriods.find(period => period.days === days).active = true; 32 | setPeriods(newPeriods); 33 | }; 34 | }; 35 | 36 | return ( 37 | 38 | {periods.map(period => ( 39 | 46 | ))} 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /pages/api/search.ts: -------------------------------------------------------------------------------- 1 | import querystring from "querystring"; 2 | import { date2str } from "../../helpers/util"; 3 | import { SearchResponse } from "../../models/model"; 4 | 5 | const baseUrl = `${process.env.NEXT_PUBLIC_API_URL}/entries`; 6 | export const PER_PAGE = 20; 7 | 8 | /** 9 | * 条件に合致するエントリを検索する 10 | * @param startDate 開始日 11 | * @param endDate 終了日 12 | * @param keyword キーワード 13 | * @param bookmarkCount 最小ブックマーク数 14 | * @param bookmarkCountMax 最大ブックマーク数 15 | * @param publisherIds 配信元ID 16 | * @param orderKey 並び替えのキー列 17 | * @param orderAsc 昇順/降順 18 | * @param page ページ番号 19 | * @param perPage ページごとのエントリ数 20 | * @return 条件に合致するEntryのリスト 21 | */ 22 | export default function search( 23 | startDate: Date, 24 | endDate: Date, 25 | keyword: string, 26 | bookmarkCount: number, 27 | bookmarkCountMax: number, 28 | publisherIds: number[], 29 | orderKey: string, 30 | orderAsc: boolean, 31 | page: number = 0, 32 | perPage: number = PER_PAGE 33 | ): Promise { 34 | const params = { 35 | startDate: date2str(startDate), 36 | endDate: date2str(endDate), 37 | keyword: keyword, 38 | bookmarkCount: bookmarkCount, 39 | bookmarkCountMax: bookmarkCountMax, 40 | publisherIds: publisherIds, 41 | isTrend: true, 42 | order: makeOrderParam(orderKey, orderAsc), 43 | page: page, 44 | perPage: perPage, 45 | }; 46 | const queryString = querystring.stringify(params); 47 | const url = `${baseUrl}?${queryString}`; 48 | return fetch(url).then(res => res.json()); 49 | } 50 | 51 | /** 52 | * 並び替え用パラメータを作成する 53 | * @param orderKey 並び替えのキー列 54 | * @param orderAsc 昇順/降順 55 | * @return 並び替え用パラメータ 56 | */ 57 | function makeOrderParam(orderKey: string, orderAsc: boolean): string { 58 | const symbol = orderAsc ? "+" : "-"; 59 | return `${symbol}${orderKey}`; 60 | } 61 | -------------------------------------------------------------------------------- /components/entry-list.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction, useEffect } from "react"; 2 | import lodash from "lodash"; 3 | import { Box, CircularProgress, Fab, Grid } from "@material-ui/core"; 4 | import UpIcon from "@material-ui/icons/KeyboardArrowUp"; 5 | import { IEntry, Props } from "../models/model"; 6 | import Entry from "./entry"; 7 | import Order from "../components/search/order"; 8 | import classes from "../styles/entry-list.module.scss"; 9 | import { range } from "../helpers/util"; 10 | 11 | const threshold = 100; 12 | 13 | const EntryList = (props: Props) => { 14 | /** 15 | * ページを読み込むときのコールバック関数 16 | */ 17 | const handleScroll = lodash.throttle(() => { 18 | if (window.innerHeight + document.documentElement.scrollTop < document.documentElement.offsetHeight - threshold) { 19 | return; 20 | } 21 | props.setPage(page => page + 1); 22 | }, 200); 23 | 24 | useEffect(() => { 25 | window.addEventListener("scroll", handleScroll); 26 | return () => { 27 | window.removeEventListener("scroll", handleScroll); 28 | }; 29 | }, []); 30 | 31 | return ( 32 | 33 | 34 | 35 | {props.entries.map((entry, i) => ( 36 | 37 | 38 | 39 | ))} 40 | {/* 最終行のspace-betweenが崩れないように高さ0で幅は他のカードと同じダミーを用意する */} 41 | 42 | 43 | 44 | 45 | 46 | {props.hasMore ? : ""} 47 | window.scrollTo({ top: 0, behavior: "smooth" })}> 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default EntryList; 55 | -------------------------------------------------------------------------------- /models/model.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from "react"; 2 | 3 | export interface IEntry { 4 | id: number; 5 | title: string; 6 | url: string; 7 | domain: string; 8 | bookmark_count: number; 9 | image: { 10 | Valid: boolean; 11 | String: string; 12 | }; 13 | hotentried_at: string; 14 | published_at: string; 15 | comments: IComment[]; 16 | publisher: IPublisher; 17 | is_trend: boolean; 18 | created_at: string; 19 | updated_at: string; 20 | } 21 | 22 | export interface IPeriod { 23 | label: string; 24 | days: number; 25 | active: boolean; 26 | } 27 | 28 | export interface IComment { 29 | id: number; 30 | entry_id: string; 31 | rank: number; 32 | username: string; 33 | icon: string; 34 | content: string; 35 | commented_at: string; 36 | } 37 | 38 | export interface IPublisher { 39 | id: number; 40 | domain: string; 41 | name: string; 42 | // icon: string; 43 | } 44 | 45 | export interface SearchResponse { 46 | count: number; 47 | entries: IEntry[]; 48 | } 49 | 50 | export interface GetPublishersResponse { 51 | publishers: IPublisher[]; 52 | } 53 | 54 | export interface Props { 55 | entries: IEntry[]; 56 | setEntries: Dispatch>; 57 | startDate: Date; 58 | setStartDate: Dispatch>; 59 | endDate: Date; 60 | setEndDate: Dispatch>; 61 | periods: IPeriod[]; 62 | setPeriods: Dispatch>; 63 | keyword: string; 64 | setKeyword: Dispatch>; 65 | bookmarkCount: number; 66 | setBookmarkCount: Dispatch>; 67 | bookmarkCountMax: number; 68 | setBookmarkCountMax: Dispatch>; 69 | publisherIds: number[]; 70 | setPublisherIds: Dispatch>; 71 | orderKey: string; 72 | setOrderKey: Dispatch>; 73 | orderAsc: boolean; 74 | setOrderAsc: Dispatch>; 75 | page: number; 76 | setPage: Dispatch>; 77 | hasMore: boolean; 78 | setHasMore: Dispatch>; 79 | count: number; 80 | setCount: Dispatch>; 81 | publishers: IPublisher[]; 82 | setPublishers: Dispatch>; 83 | isSP: boolean; 84 | } 85 | -------------------------------------------------------------------------------- /components/search/period.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from "react"; 2 | import { Box, Grid, TextField } from "@material-ui/core"; 3 | import { date2str, getInActivePeriods } from "../../helpers/util"; 4 | import classes from "../../styles/period.module.scss"; 5 | import { IPeriod } from "../../models/model"; 6 | 7 | export default function Period({ 8 | startDate, 9 | setStartDate, 10 | endDate, 11 | setEndDate, 12 | periods, 13 | setPeriods, 14 | }: { 15 | startDate: Date; 16 | setStartDate: Dispatch>; 17 | endDate: Date; 18 | setEndDate: Dispatch>; 19 | periods: IPeriod[]; 20 | setPeriods: Dispatch>; 21 | }) { 22 | const handleInputChangeStartDate = (event: React.ChangeEvent) => { 23 | setStartDate(new Date(event.target.value)); 24 | if (periods.some(p => p.active)) { 25 | const newPeriods = getInActivePeriods(periods); 26 | setPeriods(newPeriods); 27 | } 28 | }; 29 | 30 | const handleInputChangeEndDate = (event: React.ChangeEvent) => { 31 | setEndDate(new Date(event.target.value)); 32 | if (periods.some(p => p.active)) { 33 | const newPeriods = getInActivePeriods(periods); 34 | setPeriods(newPeriods); 35 | } 36 | }; 37 | 38 | return ( 39 | 40 | 41 | 42 | 51 | 52 | ~ 53 | 54 | 63 | 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | // @see https://github.com/mui-org/material-ui/blob/master/examples/nextjs/pages/_app.js 2 | 3 | import React from "react"; 4 | import Document, { Html, Head, Main, NextScript } from "next/document"; 5 | import { ServerStyleSheets } from "@material-ui/core/styles"; 6 | import { existsGaId, GA_ID } from "../src/lib/gtag"; 7 | 8 | export default class MyDocument extends Document { 9 | render() { 10 | return ( 11 | 12 | 13 | {/* PWA primary color */} 14 | 15 | 19 | {/* Google Analytics */} 20 | {existsGaId && ( 21 | <> 22 |