├── 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 | 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 | 26 | ブックマーク数 27 | {/* Hot Entried Date */} 28 | 公開日 29 | 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 | ( 30 | 31 | {(selected as number[]).map(publisherId => ( 32 | option.id === publisherId).name} 35 | className={classes.chip} 36 | /> 37 | ))} 38 | 39 | )} 40 | > 41 | {options?.map(option => ( 42 | 43 | {option.name} 44 | 45 | ))} 46 | 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 | 44 | {period.label} 45 | 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 | 23 | 34 | > 35 | )} 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | } 45 | 46 | // `getInitialProps` belongs to `_document` (instead of `_app`), 47 | // it's compatible with server-side generation (SSG). 48 | MyDocument.getInitialProps = async ctx => { 49 | // Render app and page and get the context of the page with collected side effects. 50 | const sheets = new ServerStyleSheets(); 51 | const originalRenderPage = ctx.renderPage; 52 | 53 | ctx.renderPage = () => 54 | originalRenderPage({ 55 | enhanceApp: App => props => sheets.collect(), 56 | }); 57 | 58 | const initialProps = await Document.getInitialProps(ctx); 59 | 60 | return { 61 | ...initialProps, 62 | // Styles fragment is rendered after the app and page rendering finish. 63 | styles: [...React.Children.toArray(initialProps.styles), sheets.getStyleElement()], 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /components/menu/announcement.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Typography } from "@material-ui/core"; 3 | import AnnouncementOutlinedIcon from "@material-ui/icons/AnnouncementOutlined"; 4 | import { makeStyles } from "@material-ui/core/styles"; 5 | 6 | const useStyles = makeStyles({ 7 | heading: { 8 | marginTop: "32px", 9 | fontWeight: "bold", 10 | "&:first-of-type": { 11 | marginTop: 0, 12 | }, 13 | }, 14 | }); 15 | 16 | export default function Announcement() { 17 | const classes = useStyles(); 18 | const [open, setOpen] = useState(false); 19 | 20 | const handleClickOpen = () => { 21 | setOpen(true); 22 | }; 23 | 24 | const handleClose = () => { 25 | setOpen(false); 26 | }; 27 | 28 | return ( 29 | <> 30 | 31 | 32 | 33 | 34 | お知らせ 35 | 36 | 37 | 2021-07-17: 人気上昇中アイコンを追加 38 | 39 | 40 | 現在注目度の高い人気の漫画は各漫画の右上で炎アイコンを表示するようになりました。また、期間の初期値が直近(最近3日間)になりました。 41 | 42 | 43 | 2021-07-14: 配信サイトフィルター機能を追加 44 | 45 | 46 | 詳細検索から各配信サイトでフィルターする機能を追加しました。各漫画の下部の配信サイト名をクリックしてもフィルターされます。 47 | 48 | 49 | 2021-07-03: コメント表示機能を追加 50 | 51 | 各漫画のコメント表示機能を追加しました。最大10コメント表示されます。 52 | 53 | 2021-06-23: 期間ショートカット機能を追加 54 | 55 | 56 | 期間でよく使用する、直近(最近3日間)/週間(7日間)/月間(30日間)/年間(365日間)/歴代のショートカットが用意されました。 57 | 58 | 59 | 2021-06-20: サイトがリリースされました。 60 | 61 | 当サイト(BUKUMANGA)がリリースされました。 62 | 63 | 64 | 65 | 閉じる 66 | 67 | 68 | 69 | > 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | // @see https://github.com/mui-org/material-ui/blob/master/examples/nextjs/pages/_app.js 2 | 3 | import React, { useEffect } from "react"; 4 | import PropTypes from "prop-types"; 5 | import Head from "next/head"; 6 | import { StylesProvider } from "@material-ui/core/styles"; 7 | import CssBaseline from "@material-ui/core/CssBaseline"; 8 | import "../styles/global.css"; 9 | import * as gtag from "../src/lib/gtag"; 10 | 11 | export const siteName = "BUKUMANGA"; 12 | export const title = "BUKUMANGA - 人気・おすすめのweb漫画が見つかるサイト"; 13 | export const description = 14 | "BUKUMANGAは主にはてなブックマークで人気・おすすめのweb漫画が見つかるサイトです。無料で読めるものが多く、ブックマーク数などで話題になったwebマンガのランキングを見ることもできます。"; 15 | const url = "https://bukumanga.com"; 16 | const imgUrl = "https://bukumanga.com/icon.png"; 17 | 18 | export default function MyApp(props) { 19 | const { Component, pageProps } = props; 20 | 21 | useEffect(() => { 22 | // Remove the server-side injected CSS. 23 | const jssStyles = document.querySelector("#jss-server-side"); 24 | if (jssStyles) { 25 | jssStyles.parentElement.removeChild(jssStyles); 26 | } 27 | }, []); 28 | 29 | // useEffect(() => { 30 | // if (!gtag.existsGaId) { 31 | // return; 32 | // } 33 | // const handleRouteChange = path => { 34 | // gtag.pageview(path); 35 | // }; 36 | // router.events.on("routeChangeComplete", handleRouteChange); 37 | // return () => { 38 | // router.events.off("routeChangeComplete", handleRouteChange); 39 | // }; 40 | // }, [router.events]); 41 | 42 | return ( 43 | 44 | 45 | {title} 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | MyApp.propTypes = { 68 | Component: PropTypes.elementType.isRequired, 69 | pageProps: PropTypes.object.isRequired, 70 | }; 71 | -------------------------------------------------------------------------------- /components/search/bookmark-count.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from "react"; 2 | import { Box, Grid, Input, Slider } from "@material-ui/core"; 3 | import classes from "../../styles/bookmark-count.module.scss"; 4 | 5 | export default function BookmarkCount({ 6 | bookmarkCount, 7 | setBookmarkCount, 8 | bookmarkCountMax, 9 | setBookmarkCountMax, 10 | }: { 11 | bookmarkCount: number; 12 | setBookmarkCount: Dispatch>; 13 | bookmarkCountMax: number; 14 | setBookmarkCountMax: Dispatch>; 15 | }) { 16 | const MIN = 0; 17 | const MAX = 4000; 18 | const STEP = 10; 19 | 20 | const handleSliderChange = (event: any, newValue: number | number[]) => { 21 | if (Array.isArray(newValue)) { 22 | setBookmarkCount(newValue[0]); 23 | setBookmarkCountMax(newValue[1]); 24 | } else { 25 | setBookmarkCount(newValue); 26 | setBookmarkCountMax(newValue); 27 | } 28 | }; 29 | 30 | const handleInputChangeMin = (event: React.ChangeEvent) => { 31 | setBookmarkCount(event.target.value === "" ? MIN : Number(event.target.value)); 32 | }; 33 | 34 | const handleInputChangeMax = (event: React.ChangeEvent) => { 35 | setBookmarkCountMax(event.target.value === "" ? MAX : Number(event.target.value)); 36 | }; 37 | 38 | const handleBlur = () => { 39 | if (bookmarkCount < MIN) { 40 | setBookmarkCount(MIN); 41 | } else if (bookmarkCountMax > MAX) { 42 | setBookmarkCountMax(MAX); 43 | } 44 | }; 45 | 46 | const valuetext = (value: number) => { 47 | return `${value} users`; 48 | }; 49 | 50 | return ( 51 | 52 | 53 | 54 | 62 | 63 | 64 | 74 | 75 | 76 | 84 | 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /components/menu/help.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Typography } from "@material-ui/core"; 3 | import HelpOutlineIcon from "@material-ui/icons/HelpOutline"; 4 | import { makeStyles } from "@material-ui/core/styles"; 5 | 6 | const useStyles = makeStyles({ 7 | heading: { 8 | marginTop: "32px", 9 | fontWeight: "bold", 10 | "&:first-of-type": { 11 | marginTop: 0, 12 | }, 13 | }, 14 | }); 15 | 16 | export default function Help() { 17 | const classes = useStyles(); 18 | const [open, setOpen] = useState(false); 19 | 20 | const handleClickOpen = () => { 21 | setOpen(true); 22 | }; 23 | 24 | const handleClose = () => { 25 | setOpen(false); 26 | }; 27 | 28 | return ( 29 | <> 30 | 31 | 32 | 33 | 34 | About 35 | 36 | 37 | 当サイトについて 38 | 39 | 40 | 当サイトでは、はてな社様のサービス「 41 | 42 | はてなブックマーク 43 | 44 | 」を元にwebマンガをまとめています。はてな社様への負荷軽減のため一定の間隔を空けて情報を更新しています。 45 | 46 | 47 | アクセス解析ツールについて 48 | 49 | 50 | 当サイトでは、Googleによるアクセス解析ツール「Googleアナリティクス」を利用しています。このGoogleアナリティクスはトラフィックデータの収集のためにクッキー(Cookie)を使用しております。トラフィックデータは匿名で収集されており、個人を特定するものではありません。 51 | 52 | 53 | お問い合わせ 54 | 55 | 56 | 57 | こちら 58 | 59 | のGoogleFormsからお問い合わせお願いします。 60 | 61 | 62 | 開発者について 63 | 64 | 65 | 66 | Twitter: 67 | 68 | @kattsu_3 69 | 70 | 71 | 72 | GitHub: 73 | 74 | bukumanga 75 | 76 | 77 | 78 | 79 | 80 | 81 | 閉じる 82 | 83 | 84 | 85 | > 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /styles/entry.module.scss: -------------------------------------------------------------------------------- 1 | @import "../styles/vars"; 2 | 3 | .root { 4 | width: 300px; 5 | position: relative; 6 | background-color: $white; 7 | @media screen and (max-width: 480px) { 8 | width: 88vw; 9 | } 10 | } 11 | 12 | .bookmarkLink { 13 | color: $text; 14 | text-decoration: none; 15 | } 16 | 17 | .headerRoot { 18 | display: flex; 19 | align-items: center; 20 | justify-content: space-between; 21 | padding: 8px 16px; 22 | background-color: $secondary; 23 | cursor: pointer; 24 | &:hover { 25 | .headerTitle { 26 | text-decoration: underline; 27 | text-decoration-color: $primary; 28 | text-decoration-thickness: 2px; 29 | text-underline-offset: 1px; 30 | } 31 | } 32 | } 33 | 34 | .headerAvatar { 35 | display: flex; 36 | align-items: center; 37 | cursor: pointer; 38 | } 39 | 40 | .headerAvatarIcon { 41 | width: 44px; 42 | height: 44px; 43 | margin-right: 8px; 44 | background-color: $primary; 45 | } 46 | 47 | .headerTitle { 48 | font-size: 18px; 49 | } 50 | 51 | .body { 52 | position: relative; 53 | height: calc(100% - 56px); 54 | } 55 | 56 | .imageLink { 57 | display: block; 58 | cursor: pointer; 59 | } 60 | 61 | .image { 62 | display: block; 63 | width: 100%; 64 | height: auto; 65 | object-fit: cover; 66 | max-height: 260px; 67 | @media screen and (max-width: 320px) { 68 | max-height: 210px; 69 | } 70 | @media screen and (min-width: 481px) { 71 | max-height: 210px; 72 | } 73 | } 74 | 75 | .content { 76 | padding-bottom: 16px !important; 77 | } 78 | 79 | .titleLink { 80 | display: block; 81 | color: $text; 82 | text-decoration: none; 83 | cursor: pointer; 84 | &:hover { 85 | text-decoration: underline; 86 | text-decoration-color: $primary; 87 | text-decoration-thickness: 2px; 88 | text-underline-offset: 1px; 89 | } 90 | } 91 | 92 | .title { 93 | display: -webkit-box; 94 | -webkit-box-orient: vertical; 95 | -webkit-line-clamp: 3; 96 | overflow: hidden; 97 | font-size: 15px; 98 | font-family: "Noto Sans JP", sans-serif; 99 | font-weight: 700; 100 | line-height: 1.5; 101 | @media screen and (min-width: 481px) { 102 | height: calc(1.5em * 3); 103 | } 104 | } 105 | 106 | .captions { 107 | margin-top: 24px; 108 | } 109 | 110 | .caption { 111 | font-size: 0.68rem; 112 | word-break: break-all; 113 | } 114 | 115 | .comments { 116 | max-height: 200px; 117 | overflow-y: scroll; 118 | @media screen and (min-width: 481px) { 119 | &::-webkit-scrollbar { 120 | width: 4px; 121 | } 122 | &::-webkit-scrollbar-thumb { 123 | background-color: rgba(0, 0, 50, 0.5); 124 | border-radius: 4px; 125 | } 126 | } 127 | } 128 | 129 | .comment { 130 | margin: 8px auto; 131 | } 132 | 133 | .commentIcon { 134 | display: inline-block; 135 | vertical-align: middle; 136 | width: 20px; 137 | height: 20px; 138 | margin-right: 8px; 139 | } 140 | 141 | .commentContent { 142 | margin-right: 4px; 143 | font-size: 13px; 144 | font-family: "Noto Sans JP", sans-serif; 145 | } 146 | 147 | .commentUsername { 148 | font-size: 11px; 149 | } 150 | 151 | .more { 152 | display: block; 153 | margin-top: 8px; 154 | text-align: right; 155 | } 156 | 157 | .commentOpen { 158 | display: flex; 159 | align-items: center; 160 | } 161 | 162 | .commentBlockTitle { 163 | margin-left: 4px; 164 | } 165 | 166 | .link { 167 | display: inline-block; 168 | cursor: pointer; 169 | padding: 8px; 170 | border: 1px solid; 171 | border-radius: 4px; 172 | } 173 | 174 | .is_trend { 175 | color: $primary; 176 | font-size: 28px; 177 | } 178 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useDebounce } from "use-debounce"; 3 | import useMedia from "use-media"; 4 | import Layout from "../components/layout"; 5 | import Search from "../components/search/search"; 6 | import EntryList from "../components/entry-list"; 7 | import search, { PER_PAGE } from "./api/search"; 8 | import getPublishers from "./api/publisher"; 9 | import { IEntry, IPeriod, IPublisher, Props } from "../models/model"; 10 | 11 | export const defaultEndDate = new Date(); 12 | export const defaultStartDate = new Date(); 13 | defaultStartDate.setDate(defaultStartDate.getDate() - 2); // デフォルトを今週にする 14 | const defaultPeriods: IPeriod[] = [ 15 | { label: "直近", days: 3, active: true }, 16 | { label: "週間", days: 7, active: false }, 17 | { label: "月間", days: 30, active: false }, 18 | { label: "年間", days: 365, active: false }, 19 | { label: "歴代", days: Infinity, active: false }, 20 | ]; 21 | 22 | export const defaultKeyword = ""; 23 | export const defaultBookmarkCount = 0; 24 | export const defaultBookmarkCountMax = 4000; 25 | 26 | export default function Home() { 27 | // 各種state 28 | const [entries, setEntries] = useState([]); 29 | const [publishers, setPublishers] = useState([]); 30 | const [startDate, setStartDate] = useState(defaultStartDate); 31 | const [periods, setPeriods] = useState(defaultPeriods); 32 | const [endDate, setEndDate] = useState(defaultEndDate); 33 | const [keyword, setKeyword] = useState(defaultKeyword); 34 | const [bookmarkCount, setBookmarkCount] = useState(defaultBookmarkCount); 35 | const [bookmarkCountMax, setBookmarkCountMax] = useState(defaultBookmarkCountMax); 36 | const [publisherIds, setPublisherIds] = useState([]); 37 | const [orderKey, setOrderKey] = useState("bookmark_count"); 38 | const [orderAsc, setOrderAsc] = useState(false); 39 | const [page, setPage] = useState(0); 40 | const [hasMore, setHasMore] = useState(true); 41 | const [count, setCount] = useState(0); 42 | 43 | // イベントを間引くためにdebounce変数をトリガーにする 44 | const [debounceStartDate] = useDebounce(startDate, 500); 45 | const [debounceEndDate] = useDebounce(endDate, 500); 46 | const [debounceKeyword] = useDebounce(keyword, 500); 47 | const [debounceBookmarkCount] = useDebounce(bookmarkCount, 500); 48 | const [debounceBookmarkCountMax] = useDebounce(bookmarkCountMax, 500); 49 | const [debouncePublisherIds] = useDebounce(publisherIds, 500); 50 | 51 | // SPモードとの境界 52 | const isSP = useMedia({ maxWidth: "480px" }); 53 | 54 | // 配信サイト一覧を取得 55 | useEffect(() => { 56 | getPublishers().then(res => setPublishers(res.publishers)); 57 | }, []); 58 | 59 | // 検索条件変更時のeffect 60 | useEffect(() => { 61 | setPage(0); 62 | setHasMore(true); 63 | search( 64 | debounceStartDate, 65 | debounceEndDate, 66 | debounceKeyword, 67 | debounceBookmarkCount, 68 | debounceBookmarkCountMax, 69 | debouncePublisherIds, 70 | orderKey, 71 | orderAsc 72 | ).then(res => { 73 | setEntries(res.entries); 74 | setCount(res.count); 75 | if (res.entries.length < PER_PAGE) { 76 | setHasMore(false); 77 | } 78 | }); 79 | }, [ 80 | debounceStartDate, 81 | debounceEndDate, 82 | debounceKeyword, 83 | debounceBookmarkCount, 84 | debounceBookmarkCountMax, 85 | debouncePublisherIds, 86 | orderKey, 87 | orderAsc, 88 | ]); 89 | 90 | // 無限スクロールのeffect 91 | useEffect(() => { 92 | // これ以上エントリがない、または、初回読み込み時は終了 93 | if (!hasMore || page === 0) { 94 | return; 95 | } 96 | search( 97 | debounceStartDate, 98 | debounceEndDate, 99 | debounceKeyword, 100 | debounceBookmarkCount, 101 | debounceBookmarkCountMax, 102 | debouncePublisherIds, 103 | orderKey, 104 | orderAsc, 105 | page 106 | ).then(res => { 107 | setEntries(entries => [...entries, ...res.entries]); 108 | if (res.entries.length < PER_PAGE) { 109 | setHasMore(false); 110 | } 111 | }); 112 | }, [page]); 113 | 114 | const props: Props = { 115 | entries, 116 | setEntries, 117 | startDate, 118 | setStartDate, 119 | endDate, 120 | setEndDate, 121 | periods, 122 | setPeriods, 123 | keyword, 124 | setKeyword, 125 | bookmarkCount, 126 | setBookmarkCount, 127 | bookmarkCountMax, 128 | setBookmarkCountMax, 129 | orderKey, 130 | setOrderKey, 131 | orderAsc, 132 | setOrderAsc, 133 | publisherIds, 134 | setPublisherIds, 135 | page, 136 | setPage, 137 | hasMore, 138 | setHasMore, 139 | count, 140 | setCount, 141 | publishers, 142 | setPublishers, 143 | isSP, 144 | }; 145 | 146 | return ( 147 | 148 | 149 | 150 | 151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /components/entry.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from "react"; 2 | import { Accordion, AccordionDetails, AccordionSummary } from "./util/accordion"; 3 | import { IComment, IEntry } from "../models/model"; 4 | import { Avatar, Box, Chip, Card, CardContent, Divider, Typography } from "@material-ui/core"; 5 | import ChatOutlinedIcon from "@material-ui/icons/ChatOutlined"; 6 | import WhatshotIcon from "@material-ui/icons/Whatshot"; 7 | import classes from "../styles/entry.module.scss"; 8 | 9 | const Entry = React.memo( 10 | ({ entry, setPublisherIds }: { entry: IEntry; setPublisherIds: Dispatch> }) => { 11 | const is_https = entry.url.startsWith("https"); 12 | 13 | /** 14 | * ブックマークページのURLを返す 15 | * @param e マウスイベント 16 | */ 17 | const bookMarkUrl = () => { 18 | const s = is_https ? "s/" : ""; 19 | const protocol = is_https ? "https" : "http"; 20 | return `https://b.hatena.ne.jp/entry/${s}${entry.url.replace(`${protocol}://`, "")}`; 21 | }; 22 | 23 | /** 24 | * エントリページのURLを返す 25 | * @param e マウスイベント 26 | */ 27 | const entryUrl = () => { 28 | if (entry.url.startsWith("http://neetsha.com")) { 29 | return entry.url.replace("http://neetsha.com", "https://neetsha.jp"); 30 | } 31 | return entry.url; 32 | }; 33 | 34 | const dummyImg = "./noimage.png"; 35 | 36 | const createdAt = new Date(entry.created_at); 37 | const diff = new Date().getTime() - createdAt.getTime(); 38 | const diffHours = Math.floor(diff / (1000 * 60 * 60)); 39 | const diffDays = Math.floor(diff / (1000 * 60 * 60 * 24)); 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | {entry.bookmark_count} 47 | users 48 | 49 | {entry.is_trend ? : <>>} 50 | 51 | 52 | 53 | 54 | 55 | 62 | 63 | 64 | 65 | 66 | 67 | {entry.title} 68 | 69 | 70 | {entry.comments?.length ? ( 71 | 72 | 73 | 74 | 75 | コメントを見る 76 | 77 | 78 | 79 | 80 | 81 | {entry.comments.map(comment => ( 82 | 83 | 84 | 85 | 86 | 90 | ({comment.username}) 91 | 92 | 93 | 94 | 95 | ))} 96 | 97 | 98 | もっと見る 99 | 100 | 101 | 102 | 103 | ) : ( 104 | <>> 105 | )} 106 | 107 | {diffDays < 7 ? ( 108 | 109 | {diffHours === 0 ? "新着" : diffHours < 24 ? `${diffHours}時間前` : `${diffDays}日前`} 110 | 111 | ) : ( 112 | <>> 113 | )} 114 | 115 | {entry.published_at.slice(0, 10)} 116 | 117 | setPublisherIds([entry.publisher.id])} 120 | > 121 | 122 | 123 | 124 | 125 | ); 126 | } 127 | ); 128 | 129 | export default Entry; 130 | --------------------------------------------------------------------------------