├── frontend ├── .dockerignore ├── common │ ├── fixme.ts │ └── result.ts ├── env │ ├── .gitignore │ ├── .env.sample │ └── index.ts ├── model │ ├── index.ts │ ├── user │ │ ├── index.ts │ │ ├── user.ts │ │ ├── others.ts │ │ └── me.ts │ ├── work.ts │ └── original.ts ├── components │ ├── lv3 │ │ ├── index.tsx │ │ ├── WorkList.tsx │ │ └── WorkOriginalForm.tsx │ ├── seo │ │ ├── index.ts │ │ ├── DefaultSeo.tsx │ │ └── WorkDetailSeo.tsx │ ├── lv1 │ │ ├── Center.tsx │ │ ├── SectionDescription.tsx │ │ ├── SectionTitle.tsx │ │ ├── AmazonButton.tsx │ │ ├── index.tsx │ │ ├── Button.tsx │ │ ├── TextInput.tsx │ │ ├── Image.tsx │ │ ├── LabelButton.tsx │ │ └── LoadingIndicator.tsx │ ├── lv2 │ │ ├── index.tsx │ │ ├── SectionContainer.tsx │ │ ├── WorkOriginalEmpty.tsx │ │ ├── Header.tsx │ │ ├── WorkImage.tsx │ │ ├── WorkCard.tsx │ │ ├── Footer.tsx │ │ ├── SearchBar.tsx │ │ └── OriginalCard.tsx │ └── lv4 │ │ ├── Top.tsx │ │ └── WorkDetail.tsx ├── next-env.d.ts ├── public │ ├── favicon.png │ └── assets │ │ ├── noimage.png │ │ ├── logo │ │ ├── logo.png │ │ └── header_logo.png │ │ └── ogimage │ │ └── default.png ├── .gcloudignore ├── repository │ ├── index.ts │ ├── algolia.ts │ ├── firestore.ts │ ├── search.ts │ ├── work.ts │ ├── trend.ts │ └── original.ts ├── Dockerfile ├── .babelrc ├── lib │ ├── const.ts │ ├── result.ts │ └── firebase │ │ ├── server.ts │ │ └── client.ts ├── .gitignore ├── .prettierrc ├── pages │ ├── index.tsx │ ├── 404.tsx │ ├── admin │ │ ├── index.tsx │ │ ├── auth │ │ │ └── sign_in.tsx │ │ └── works │ │ │ └── index.tsx │ ├── _error.tsx │ ├── search │ │ └── index.tsx │ ├── works │ │ └── [workID].tsx │ ├── _document.tsx │ ├── _app.tsx │ ├── about │ │ └── index.tsx │ ├── privacy_policy │ │ └── index.tsx │ └── terms │ │ └── index.tsx ├── helper │ └── array.ts ├── types │ └── nextjs-progressbar │ │ └── index.d.ts ├── middleware │ └── admin.tsx ├── enum │ └── season.ts ├── next.config.js ├── hooks │ ├── work │ │ ├── useTrendWorks.ts │ │ ├── useRelatedWorks.ts │ │ ├── useWorkOriginals.ts │ │ └── useLatestWorks.ts │ └── useAuth.tsx ├── .eslintrc.json ├── tsconfig.json ├── styles │ ├── const.ts │ ├── Global.tsx │ ├── global.tsx │ ├── Reset.tsx │ └── reset.tsx ├── README.md └── package.json ├── functions ├── env │ ├── .gitignore │ └── sample.json ├── src │ ├── model │ │ ├── index.ts │ │ ├── model.ts │ │ ├── work.ts │ │ └── original.ts │ ├── service │ │ ├── index.ts │ │ └── setWork.ts │ ├── script │ │ ├── serviceAccount │ │ │ └── .gitignore │ │ ├── setupAlgolia.ts │ │ ├── sample │ │ │ ├── searchOriginal.ts │ │ │ └── searchVideo.ts │ │ ├── makeUserAdmin.ts │ │ ├── createWorkListPerYear.ts │ │ ├── copyWorksDevToProd.ts │ │ ├── registerToAlgolia.ts │ │ ├── removeOldAnimeFromDev.ts │ │ ├── helper.ts │ │ ├── getWorksFromAnnict.ts │ │ ├── setImageToWork.ts │ │ ├── setEnvVars.ts │ │ └── httpToHttps.ts │ ├── eventHandler │ │ ├── callable │ │ │ ├── index.ts │ │ │ └── deleteWork.ts │ │ ├── firestore │ │ │ ├── index.ts │ │ │ └── work │ │ │ │ ├── index.ts │ │ │ │ └── setAffiriateLinkToOriginal.ts │ │ ├── index.ts │ │ └── function.ts │ ├── repository │ │ ├── index.ts │ │ ├── base.ts │ │ ├── original.ts │ │ ├── search.ts │ │ ├── work.ts │ │ ├── annict.ts │ │ └── amazon.ts │ ├── common │ │ ├── sleep.ts │ │ ├── result.ts │ │ └── logger.ts │ ├── enum │ │ ├── season.ts │ │ └── workMedia.ts │ ├── index.ts │ ├── query │ │ ├── query.ts │ │ └── fetchWorks.ts │ ├── test │ │ └── repository │ │ │ └── annict.test.ts │ ├── helper │ │ ├── array.ts │ │ └── firestore.ts │ └── env.ts ├── tsconfig.prod.json ├── .gitignore ├── .prettierrc ├── tsconfig.json ├── .eslintrc.json └── package.json ├── firestore.indexes.json ├── .gitignore ├── .firebaserc ├── package.json ├── schema.yml ├── composition.md ├── firestore.rules ├── firebase.json └── public └── index.html /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /frontend/common/fixme.ts: -------------------------------------------------------------------------------- 1 | export type FixMe = any 2 | -------------------------------------------------------------------------------- /frontend/env/.gitignore: -------------------------------------------------------------------------------- 1 | .env.* 2 | !.env.sample 3 | -------------------------------------------------------------------------------- /functions/env/.gitignore: -------------------------------------------------------------------------------- 1 | dev.json 2 | prod.json 3 | -------------------------------------------------------------------------------- /functions/src/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from './work' 2 | -------------------------------------------------------------------------------- /functions/src/service/index.ts: -------------------------------------------------------------------------------- 1 | export * from './setWork' 2 | -------------------------------------------------------------------------------- /functions/src/script/serviceAccount/.gitignore: -------------------------------------------------------------------------------- 1 | dev.json 2 | prod.json 3 | -------------------------------------------------------------------------------- /functions/src/eventHandler/callable/index.ts: -------------------------------------------------------------------------------- 1 | export * from './deleteWork' 2 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /frontend/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from './work' 2 | export * from './original' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | **/node_modules/ 4 | 5 | # firebase 6 | *.log 7 | .firebase 8 | -------------------------------------------------------------------------------- /functions/src/model/model.ts: -------------------------------------------------------------------------------- 1 | export interface Model { 2 | readonly id: string 3 | } 4 | -------------------------------------------------------------------------------- /frontend/components/lv3/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './WorkList' 2 | export * from './WorkOriginalForm' 3 | -------------------------------------------------------------------------------- /frontend/components/seo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DefaultSeo' 2 | export * from './WorkDetailSeo' 3 | -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /frontend/model/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user' 2 | export * from './me' 3 | export * from './others' 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mogaming217/anime-next/HEAD/frontend/public/favicon.png -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "dev": "animenextdev", 4 | "prod": "animenextprod" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/model/user/user.ts: -------------------------------------------------------------------------------- 1 | export class User { 2 | constructor(readonly id: string, readonly isMe: boolean) {} 3 | } 4 | -------------------------------------------------------------------------------- /functions/src/eventHandler/firestore/index.ts: -------------------------------------------------------------------------------- 1 | import * as Work from './work' 2 | 3 | export const work = { ...Work } 4 | -------------------------------------------------------------------------------- /frontend/public/assets/noimage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mogaming217/anime-next/HEAD/frontend/public/assets/noimage.png -------------------------------------------------------------------------------- /frontend/.gcloudignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !package.json 4 | !package-lock.json 5 | !Dockerfile 6 | !.next/** 7 | !dist/** 8 | !env/** 9 | -------------------------------------------------------------------------------- /frontend/public/assets/logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mogaming217/anime-next/HEAD/frontend/public/assets/logo/logo.png -------------------------------------------------------------------------------- /functions/tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["src/**/*.test.ts", "src/script/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /frontend/public/assets/ogimage/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mogaming217/anime-next/HEAD/frontend/public/assets/ogimage/default.png -------------------------------------------------------------------------------- /frontend/repository/index.ts: -------------------------------------------------------------------------------- 1 | export * from './work' 2 | export * from './original' 3 | export * from './search' 4 | export * from './trend' 5 | -------------------------------------------------------------------------------- /frontend/public/assets/logo/header_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mogaming217/anime-next/HEAD/frontend/public/assets/logo/header_logo.png -------------------------------------------------------------------------------- /functions/src/repository/index.ts: -------------------------------------------------------------------------------- 1 | export * from './annict' 2 | export * from './work' 3 | export * from './amazon' 4 | export * from './search' 5 | -------------------------------------------------------------------------------- /frontend/components/lv1/Center.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Center = styled.div` 4 | text-align: center; 5 | ` 6 | -------------------------------------------------------------------------------- /functions/src/eventHandler/firestore/work/index.ts: -------------------------------------------------------------------------------- 1 | export const workPath = '/works/{workID}' 2 | 3 | export * from './setAffiriateLinkToOriginal' 4 | -------------------------------------------------------------------------------- /functions/src/common/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (milliseconds: number) => { 2 | return new Promise(resolve => setTimeout(resolve, milliseconds)) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine 2 | WORKDIR /usr/src/app 3 | COPY . . 4 | RUN npm install --production 5 | 6 | ENV HOST 0.0.0.0 7 | 8 | CMD ["npm", "run", "start"] 9 | -------------------------------------------------------------------------------- /functions/src/enum/season.ts: -------------------------------------------------------------------------------- 1 | export type Season = 'spring' | 'summer' | 'autumn' | 'winter' 2 | export const allSeasons: Season[] = ['spring', 'summer', 'autumn', 'winter'] 3 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["styled-components", { "ssr": true, "displayName": true, "preprocess": false } ], 4 | ], 5 | "presets": ["next/babel"] 6 | } 7 | -------------------------------------------------------------------------------- /frontend/model/user/others.ts: -------------------------------------------------------------------------------- 1 | import { User } from '.' 2 | 3 | export class Others extends User { 4 | constructor(readonly id: string) { 5 | super(id, false) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/lib/const.ts: -------------------------------------------------------------------------------- 1 | import { publicEnv } from 'env' 2 | 3 | export const Const = { 4 | SERVICE_NAME: 'アニメノツヅキ', 5 | DEFAULT_OG_IMAGE: `${publicEnv.host}/assets/ogimage/default.png`, 6 | } 7 | -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'firebase-functions' 2 | import { initializeApp } from 'firebase-admin' 3 | initializeApp(config().firebase) 4 | 5 | export * from './eventHandler' 6 | -------------------------------------------------------------------------------- /frontend/model/user/me.ts: -------------------------------------------------------------------------------- 1 | import { User } from '.' 2 | 3 | export class Me extends User { 4 | constructor(readonly id: string, readonly isAdmin: boolean) { 5 | super(id, true) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | ## Compiled JavaScript files 2 | **/*.js 3 | **/*.js.map 4 | 5 | # Typescript v1 declaration files 6 | typings/ 7 | 8 | node_modules/ 9 | 10 | .runtimeconfig.json 11 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # next.js build output 5 | .next 6 | 7 | # production server output 8 | dist 9 | 10 | # Firebase 11 | .firebase 12 | *.log 13 | -------------------------------------------------------------------------------- /functions/src/eventHandler/index.ts: -------------------------------------------------------------------------------- 1 | import * as Firestore from './firestore' 2 | export const firestore = { 3 | ...Firestore, 4 | } 5 | 6 | import * as Callable from './callable' 7 | export const c = { ...Callable } 8 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": false, 6 | "htmlWhitespaceSensitivity": "ignore", 7 | "printWidth": 140, 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /functions/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": false, 6 | "htmlWhitespaceSensitivity": "ignore", 7 | "printWidth": 140, 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /functions/src/enum/workMedia.ts: -------------------------------------------------------------------------------- 1 | export const WorkMedia = { 2 | TV: 'TV', 3 | OVA: 'OVA', 4 | MOVIE: 'MOVIE', 5 | WEB: 'WEB', 6 | OTHER: 'OTHER', 7 | } as const 8 | export type WorkMedia = typeof WorkMedia[keyof typeof WorkMedia] 9 | -------------------------------------------------------------------------------- /frontend/components/lv1/SectionDescription.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { StyleConst } from 'styles/const' 3 | 4 | export const SectionDescription = styled.div` 5 | font-size: ${StyleConst.FONT.MEDIUM}; 6 | margin-top: 6px; 7 | ` 8 | -------------------------------------------------------------------------------- /frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next' 2 | import { Top } from 'components/lv4/Top' 3 | 4 | const RootPage: NextPage = () => { 5 | return ( 6 | <> 7 | 8 | 9 | ) 10 | } 11 | 12 | export default RootPage 13 | -------------------------------------------------------------------------------- /functions/env/sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "annict": { 3 | "token": "xxx" 4 | }, 5 | "amazon": { 6 | "access": "xxx", 7 | "secret": "xxx" 8 | }, 9 | "algolia": { 10 | "indexprefix": "dev_", 11 | "id": "xxx", 12 | "key": "xxx" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/components/lv1/SectionTitle.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { StyleConst } from 'styles/const' 3 | 4 | export const SectionTitle = styled.div` 5 | font-size: ${StyleConst.FONT.MEDIUM}; 6 | font-weight: ${StyleConst.FONT_WEIGHT.BOLD}; 7 | margin: 16px 0px; 8 | ` 9 | -------------------------------------------------------------------------------- /frontend/components/lv2/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './SearchBar' 2 | export * from './WorkCard' 3 | export * from './SectionContainer' 4 | export * from './WorkImage' 5 | export * from './WorkOriginalEmpty' 6 | export * from './OriginalCard' 7 | export * from './Header' 8 | export * from './Footer' 9 | -------------------------------------------------------------------------------- /frontend/helper/array.ts: -------------------------------------------------------------------------------- 1 | export function compactMap(array: T[], handle: (data: T) => R | null | undefined) { 2 | const list: R[] = [] 3 | array.forEach(data => { 4 | const result = handle(data) 5 | if (result) { 6 | list.push(result) 7 | } 8 | }) 9 | return list 10 | } 11 | -------------------------------------------------------------------------------- /frontend/lib/result.ts: -------------------------------------------------------------------------------- 1 | export class Success { 2 | readonly isFailure = false 3 | constructor(readonly value: T) {} 4 | } 5 | 6 | export class Failure { 7 | readonly isFailure = true 8 | constructor(readonly error: E) {} 9 | } 10 | 11 | export type Result = Success | Failure 12 | -------------------------------------------------------------------------------- /frontend/common/result.ts: -------------------------------------------------------------------------------- 1 | export class Success { 2 | readonly isFailure = false 3 | constructor(readonly value: T) {} 4 | } 5 | 6 | export class Failure { 7 | readonly isFailure = true 8 | constructor(readonly error: E) {} 9 | } 10 | 11 | export type Result = Success | Failure 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anime-next", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "deploy:rules:dev": "firebase deploy -P dev --only firestore:rules", 7 | "deploy:rules:prod": "firebase deploy -P prod --only firestore:rules" 8 | }, 9 | "dependencies": {} 10 | } 11 | -------------------------------------------------------------------------------- /functions/src/common/result.ts: -------------------------------------------------------------------------------- 1 | export class Success { 2 | readonly isFailure = false 3 | constructor(readonly value: T) {} 4 | } 5 | 6 | export class Failure { 7 | readonly isFailure = true 8 | constructor(readonly error: E) {} 9 | } 10 | 11 | export type Result = Success | Failure 12 | -------------------------------------------------------------------------------- /functions/src/query/query.ts: -------------------------------------------------------------------------------- 1 | export interface GraphQLQuery { 2 | body: string 3 | variables?: { [key: string]: any } 4 | parse: (data: any) => T | null 5 | } 6 | 7 | export interface GraphQLResponse { 8 | data: T 9 | errors?: GraphQLError[] 10 | } 11 | 12 | interface GraphQLError { 13 | message: string 14 | } 15 | -------------------------------------------------------------------------------- /frontend/components/lv1/AmazonButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Button } from './Button' 3 | import { StyleConst } from 'styles/const' 4 | 5 | export const AmazonButton = styled(Button)` 6 | background-color: '#ed9220'; 7 | border-radius: ${StyleConst.CORNER_RADIUS.DEFAULT}px; 8 | border-width: 0px; 9 | ` 10 | -------------------------------------------------------------------------------- /frontend/components/lv1/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Image' 2 | 3 | export * from './Button' 4 | export * from './AmazonButton' 5 | export * from './LabelButton' 6 | 7 | export * from './LoadingIndicator' 8 | export * from './TextInput' 9 | export * from './SectionTitle' 10 | export * from './SectionDescription' 11 | export * from './Center' 12 | -------------------------------------------------------------------------------- /frontend/types/nextjs-progressbar/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'nextjs-progressbar' { 2 | import { FC } from 'react' 3 | 4 | type Props = { 5 | color?: string 6 | height?: string 7 | options?: { 8 | showSpinner?: boolean 9 | } 10 | } 11 | 12 | const ProgressBar: FC 13 | export default ProgressBar 14 | } 15 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "build", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es6" 10 | }, 11 | "compileOnSave": true, 12 | "include": [ 13 | "src" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /frontend/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NextPage } from 'next' 3 | import Link from 'next/link' 4 | 5 | const NotFoundPage: NextPage = () => { 6 | return ( 7 | <> 8 |
Not Found
9 | 10 | TOP 11 | 12 | 13 | ) 14 | } 15 | 16 | export default NotFoundPage 17 | -------------------------------------------------------------------------------- /frontend/repository/algolia.ts: -------------------------------------------------------------------------------- 1 | import algolia from 'algoliasearch/lite' 2 | import { publicEnv } from 'env' 3 | 4 | export class AlgoliaRepository { 5 | private client = algolia(publicEnv.algolia.appID, publicEnv.algolia.searchKey) 6 | 7 | get workIndex() { 8 | return this.client.initIndex(`${publicEnv.algolia.indexPrefix}works`) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/model/work.ts: -------------------------------------------------------------------------------- 1 | import { Season } from 'enum/season' 2 | 3 | export class Work { 4 | readonly id: string 5 | constructor( 6 | readonly annictID: string, 7 | readonly title: string, 8 | readonly imageURL: string | null, 9 | readonly season: Season, 10 | readonly year: number 11 | ) { 12 | this.id = annictID 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /functions/src/script/setupAlgolia.ts: -------------------------------------------------------------------------------- 1 | import { initializeProject } from './helper' 2 | import { SearchRepository } from '../repository' 3 | const app = initializeProject('prod') 4 | 5 | const main = async () => { 6 | const searchRepo = new SearchRepository(app.firestore()) 7 | await searchRepo.setWorkSettings() 8 | process.exit(0) 9 | } 10 | 11 | main() 12 | -------------------------------------------------------------------------------- /functions/src/repository/base.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from 'firebase-admin' 2 | 3 | export class Repository { 4 | constructor(readonly db: firestore.Firestore = firestore()) {} 5 | 6 | get worksRef() { 7 | return this.db.collection('works') 8 | } 9 | 10 | originalsRef(workID: string) { 11 | return this.worksRef.doc(workID).collection('originals') 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /functions/src/script/sample/searchOriginal.ts: -------------------------------------------------------------------------------- 1 | import { initializeProject } from '../helper' 2 | initializeProject('dev') 3 | 4 | import { AmazonRepository } from '../../repository' 5 | 6 | const main = async () => { 7 | const repo = new AmazonRepository() 8 | const item = await repo.fetchItemInfo('鬼滅の刃 マンガ 8') 9 | console.log(item) 10 | process.exit(0) 11 | } 12 | 13 | main() 14 | -------------------------------------------------------------------------------- /frontend/middleware/admin.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingIndicator } from 'components/lv1' 2 | import { useAuth } from 'hooks/useAuth' 3 | import { FC } from 'react' 4 | 5 | export const WithAdmin: FC = ({ children }) => { 6 | const authState = useAuth() 7 | if (authState.loading) return 8 | if (authState.user?.isAdmin !== true) return
権限が足りねぇ!
9 | return <>{children} 10 | } 11 | -------------------------------------------------------------------------------- /frontend/components/lv1/Button.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { StyleConst } from 'styles/const' 3 | 4 | export const Button = styled.button` 5 | padding: 12px 24px; 6 | font-weight: ${StyleConst.FONT_WEIGHT.BOLD}; 7 | font-size: ${StyleConst.FONT.MEDIUM}; 8 | background-color: ${StyleConst.COLOR.PRIMARY}; 9 | color: white; 10 | border-radius: ${StyleConst.CORNER_RADIUS.DEFAULT}px; 11 | border-width: 0px; 12 | ` 13 | -------------------------------------------------------------------------------- /frontend/enum/season.ts: -------------------------------------------------------------------------------- 1 | export type Season = 'spring' | 'summer' | 'autumn' | 'winter' 2 | 3 | export const allSeasons: Season[] = ['spring', 'summer', 'autumn', 'winter'] 4 | 5 | export const seasonLabel = (season: Season) => { 6 | switch (season) { 7 | case 'spring': 8 | return '春' 9 | case 'summer': 10 | return '夏' 11 | case 'autumn': 12 | return '秋' 13 | case 'winter': 14 | return '冬' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/components/lv1/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { StyleConst } from 'styles/const' 3 | 4 | export const TextInput = styled.input` 5 | border-radius: ${StyleConst.CORNER_RADIUS.DEFAULT}px; 6 | border: solid 1px ${StyleConst.COLOR.TEXT_INPUT_BORDER}; 7 | outline-color: ${StyleConst.COLOR.TEXT_INPUT_FOCUS_BORDER}; 8 | padding: 6px 8px; 9 | width: 100%; 10 | font-size: 16px; 11 | -webkit-appearance: none; 12 | ` 13 | -------------------------------------------------------------------------------- /frontend/lib/firebase/server.ts: -------------------------------------------------------------------------------- 1 | // can import by server side 2 | 3 | import * as admin from 'firebase-admin' 4 | import { serverEnv } from 'env' 5 | 6 | if (admin.apps.length === 0) { 7 | const config: any = { 8 | type: 'service_account', 9 | ...serverEnv.firebase, 10 | } 11 | 12 | admin.initializeApp({ 13 | credential: admin.credential.cert(config), 14 | databaseURL: process.env.PUBLIC_DATABASE_URL, 15 | }) 16 | } 17 | 18 | export default admin 19 | -------------------------------------------------------------------------------- /functions/src/test/repository/annict.test.ts: -------------------------------------------------------------------------------- 1 | import { AnnictRepository, WorkRepository } from "../../repository" 2 | 3 | describe('AnnictRepository', () => { 4 | it('fetchWorks', async () => { 5 | const repo = new AnnictRepository() 6 | const result = await repo.fetchWorks(2020, 'spring') 7 | if (!result.isFailure) { 8 | const workRepo = new WorkRepository() 9 | await workRepo.save(result.value) 10 | console.log('saved!') 11 | } 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /schema.yml: -------------------------------------------------------------------------------- 1 | works: 2 | documentID: annictID 3 | annictID: String 4 | title: String 5 | titleKana: String? 6 | imageURL: String? # annictにのってる画像のURLは怪しいのでAmazonから取ってきたほうがいいかも 7 | season: String # ex: 2019-winter 8 | # TODO: 購入用URLのフィールド or サブコレクション 9 | 10 | originals: 11 | documentID: auto 12 | info: String # TODO: 何巻からなのか フィールドは検討 13 | createdAt: Timestamp 14 | 15 | # seriesWorks: # これ取れないかも 16 | # documentID: String 17 | 18 | users: 19 | documentID: auth.uid 20 | -------------------------------------------------------------------------------- /frontend/env/.env.sample: -------------------------------------------------------------------------------- 1 | USE_BASIC_AUTH=true 2 | PUBLIC_HOST=https://nextjssample.web.app 3 | PUBLIC_API_KEY= 4 | PUBLIC_AUTH_DOMAIN= 5 | PUBLIC_DATABASE_URL= 6 | PUBLIC_STORAGE_BUCKET= 7 | PUBLIC_MESSAGING_SENDER_ID= 8 | PUBLIC_APP_ID= 9 | PUBLIC_MEASUREMENT_ID= 10 | PUBLIC_PROJECT_ID= 11 | PRIVATE_KEY_ID= 12 | PRIVATE_KEY= 13 | CLIENT_EMAIL= 14 | CLIENT_ID= 15 | AUTH_URI= 16 | TOKEN_URI= 17 | AUTH_PROVIDER_URL= 18 | CLIENT_CERT_URL= 19 | ALGOLIA_APP_ID= 20 | ALGOLIA_SEARCH_KEY= 21 | ALGOLIA_INDEX_PREFIX= 22 | -------------------------------------------------------------------------------- /functions/src/script/makeUserAdmin.ts: -------------------------------------------------------------------------------- 1 | import { initializeProject } from './helper' 2 | const app = initializeProject('prod') 3 | 4 | const userID = 'YbUXXo91yrepFVe6swkIqdApe3A3' 5 | 6 | const main = async () => { 7 | const auth = app.auth() 8 | const user = await auth.getUser(userID) 9 | const currentClaims = user.customClaims || {} 10 | const newClaims = Object.assign(currentClaims, { role: 'admin' }) 11 | await auth.setCustomUserClaims(userID, newClaims) 12 | process.exit(0) 13 | } 14 | 15 | main() 16 | -------------------------------------------------------------------------------- /functions/src/script/sample/searchVideo.ts: -------------------------------------------------------------------------------- 1 | import { initializeProject } from '../helper' 2 | initializeProject('dev') 3 | 4 | import { AmazonRepository } from '../../repository' 5 | 6 | const main = async () => { 7 | const repo = new AmazonRepository() 8 | // const url = await repo.fetchImageURL('七つの大罪 戒めの復活 (第2期)') 9 | const url = await repo.fetchImageURL('https://www.amazon.co.jp/dp/B07D7LF4KP?tag=seiyaorz-22&linkCode=osi&th=1&psc=1') 10 | console.log(url) 11 | process.exit(0) 12 | } 13 | 14 | main() 15 | -------------------------------------------------------------------------------- /frontend/repository/firestore.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from 'lib/firebase/client' 2 | import firebase from 'firebase/app' 3 | 4 | export class FirestoreRepository { 5 | constructor(readonly db: firebase.firestore.Firestore = firestore) {} 6 | 7 | get worksRef() { 8 | return this.db.collection('works') 9 | } 10 | 11 | workRef(workID: string) { 12 | return this.worksRef.doc(workID) 13 | } 14 | 15 | originalsRef(workID: string) { 16 | return this.workRef(workID).collection('originals') 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/pages/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { NextPage } from 'next' 3 | import Link from 'next/link' 4 | 5 | const Page: NextPage = () => { 6 | return 7 | } 8 | 9 | const Body: FC = () => { 10 | return ( 11 | <> 12 |
    13 |
  • 14 | ログイン 15 |
  • 16 |
  • 17 | 作品一覧 18 |
  • 19 |
20 | 21 | ) 22 | } 23 | 24 | export default Page 25 | -------------------------------------------------------------------------------- /frontend/components/lv2/SectionContainer.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { FC } from 'react' 3 | 4 | type Props = { 5 | withMargin?: boolean 6 | } 7 | 8 | const defaultProps: Props = { 9 | withMargin: true, 10 | } 11 | 12 | const _SectionContainer = styled.div( 13 | p => ` 14 | margin: ${p.withMargin ? '32px' : '0px'} 0px; 15 | ` 16 | ) 17 | 18 | export const SectionContainer: FC = ({ withMargin, children } = defaultProps) => { 19 | return <_SectionContainer withMargin={withMargin}>{children} 20 | } 21 | -------------------------------------------------------------------------------- /frontend/repository/search.ts: -------------------------------------------------------------------------------- 1 | import { Work } from 'model/work' 2 | import { compactMap } from 'helper/array' 3 | import { AlgoliaRepository } from './algolia' 4 | 5 | export class SearchRepository extends AlgoliaRepository { 6 | decode(hit: any): Work | undefined { 7 | return new Work(hit.annictID, hit.title, hit.imageURL, hit.season, hit.year) 8 | } 9 | 10 | async searchWorks(keyword: string, count = 30): Promise { 11 | const result = await this.workIndex.search(keyword, { hitsPerPage: count }) 12 | return compactMap(result.hits, hit => this.decode(hit)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NextPage, NextPageContext } from 'next' 3 | 4 | type Props = { 5 | statusCode: number 6 | message?: string 7 | } 8 | 9 | const ErrorPage: NextPage = (props: Props) => { 10 | return ( 11 |
12 |
{props.statusCode}
13 |
14 | ) 15 | } 16 | 17 | ErrorPage.getInitialProps = ({ res, err }: NextPageContext): Props => { 18 | const statusCode: number = res?.statusCode || err?.statusCode || 404 19 | const message = err?.message 20 | return { statusCode, message } 21 | } 22 | 23 | export default ErrorPage 24 | -------------------------------------------------------------------------------- /frontend/repository/work.ts: -------------------------------------------------------------------------------- 1 | import { Work } from 'model' 2 | import firebase from 'firebase/app' 3 | import { FirestoreRepository } from './firestore' 4 | 5 | export class WorkRepository extends FirestoreRepository { 6 | decode(snap: firebase.firestore.DocumentSnapshot): Work | undefined { 7 | const data = snap.data() 8 | if (!data) return 9 | return new Work(data.annictID, data.title, data.imageURL, data.season, data.year) 10 | } 11 | 12 | async find(workID: string): Promise { 13 | const result = await this.worksRef.doc(workID).get() 14 | return this.decode(result) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /composition.md: -------------------------------------------------------------------------------- 1 | # 構成 2 | 3 | ## プラットフォーム 4 | - Web(ブラウザ)のみで動くサービス 5 | - ユースケース 6 | - Google検索から 7 | - Twitter調べる 8 | 9 | ## 技術 10 | ### フロントエンド 11 | - Next.js(React) 12 | - 対抗馬:Nuxt(Vue) 13 | - TypeScriptとの相性がReactのほうが良いためNextを使う 14 | - SSR x SPA 15 | - SEO 16 | - OGImageとか出したいかもな〜 17 | - 状態管理 18 | - React Hooks 19 | - 実行環境 20 | - CloudRun x Firebase Hosting 21 | - 自前テンプレートを活用する 22 | 23 | ### バックエンド 24 | - Firestore, Authentication, Functions, Storage 25 | - Annict 26 | - アニメのマスターデータ 27 | - https://developers.annict.jp/graphql-api/ 28 | - 検索エンジン 29 | - Algolia 30 | - 対抗馬:Elasticsearch 31 | - Amazon PA-API 32 | -------------------------------------------------------------------------------- /frontend/components/lv1/Image.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { StyleConst } from 'styles/const' 3 | import { FC } from 'react' 4 | 5 | type Props = { 6 | src: string | null | undefined 7 | alt?: string 8 | } 9 | 10 | const _Image = styled.img` 11 | object-fit: cover; 12 | background-color: ${StyleConst.COLOR.IMAGE_BACKGROUND}; 13 | ` 14 | 15 | export const Image: FC = props => { 16 | const onError = (e: any) => { 17 | console.log('on error', props.src, e) 18 | } 19 | 20 | return <_Image src={props.src || '/assets/noimage.png'} onError={onError} alt={props.alt || '画像'} /> 21 | } 22 | -------------------------------------------------------------------------------- /functions/src/script/createWorkListPerYear.ts: -------------------------------------------------------------------------------- 1 | import { initializeProject } from './helper' 2 | const app = initializeProject('prod') 3 | 4 | import * as fs from 'fs' 5 | 6 | const main = async () => { 7 | const firestore = app.firestore() 8 | 9 | let csv = '' 10 | for (let year = 2020; year >= 2000; year--) { 11 | const snap = await firestore.collection('works').where('year', '==', year).get() 12 | snap.docs.forEach((doc: any) => { 13 | csv += `${doc.data()!.title},https://animenext.jp/works/${doc.id}\n` 14 | }) 15 | } 16 | 17 | fs.writeFileSync('./csv.csv', csv) 18 | process.exit(0) 19 | } 20 | 21 | main() 22 | -------------------------------------------------------------------------------- /functions/src/model/work.ts: -------------------------------------------------------------------------------- 1 | import { Model } from './model' 2 | import { Season } from '../enum/season' 3 | import { WorkMedia } from '../enum/workMedia' 4 | 5 | export class Work implements Model { 6 | readonly annictID: string 7 | 8 | constructor( 9 | annictId: string, 10 | readonly title: string, 11 | readonly titleEn: string | null, 12 | readonly titleKana: string | null, 13 | public imageURL: string | null = null, 14 | readonly season: Season, 15 | readonly year: number, 16 | readonly media: WorkMedia 17 | ) { 18 | this.annictID = annictId 19 | } 20 | 21 | get id() { 22 | return this.annictID 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | 5 | // firebaseの認証ユーザーかどうか 6 | function isAnyAuthenticated() { 7 | return request.auth != null; 8 | } 9 | 10 | function isAdminUser() { 11 | return request.auth.token.role == 'admin'; 12 | } 13 | 14 | match /works/{workID} { 15 | allow get: if true; 16 | allow list: if isAdminUser(); 17 | 18 | match /originals/{originalID} { 19 | // TODO: work is not locked and authenticated 20 | allow list: if true; 21 | allow create: if isAnyAuthenticated(); 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/components/lv3/WorkList.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { Work } from 'model' 3 | import styled from 'styled-components' 4 | import { WorkCard } from 'components/lv2/WorkCard' 5 | 6 | type Props = { 7 | works: Work[] 8 | } 9 | 10 | const WorkListContainer = styled.div` 11 | display: grid; 12 | grid-template-columns: repeat(auto-fit, 48%); 13 | row-gap: 12px; 14 | justify-content: space-between; 15 | ` 16 | 17 | export const WorkList: FC = ({ works }) => { 18 | return ( 19 | 20 | {works.map(work => ( 21 | 22 | ))} 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /frontend/components/lv1/LabelButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { FC } from 'react' 3 | import { StyleConst } from 'styles/const' 4 | 5 | const _Button = styled.button` 6 | color: ${StyleConst.COLOR.PRIMARY}; 7 | background-color: transparent; 8 | border: none; 9 | cursor: pointer; 10 | outline: none; 11 | padding: 0; 12 | appearance: none; 13 | ` 14 | 15 | type Props = { 16 | label: string 17 | disabled?: boolean 18 | onClick?: () => void 19 | } 20 | 21 | export const LabelButton: FC = props => { 22 | return ( 23 | <_Button disabled={props.disabled} onClick={props.onClick}> 24 | {props.label} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /functions/src/script/copyWorksDevToProd.ts: -------------------------------------------------------------------------------- 1 | import { initializeProject } from './helper' 2 | const devApp = initializeProject('dev', 'dev') 3 | const prodApp = initializeProject('prod', 'prod') 4 | 5 | import { findInBatch } from '../helper/firestore' 6 | import { sleep } from '../common/sleep' 7 | 8 | const main = async () => { 9 | let index = 0 10 | await findInBatch(devApp.firestore().collection('works'), 100, async docs => { 11 | await Promise.all( 12 | docs.map(doc => { 13 | prodApp.firestore().collection('works').doc(doc.id).set(doc.data()!) 14 | }) 15 | ) 16 | await sleep(1000) 17 | console.log(index++) 18 | }) 19 | process.exit(0) 20 | } 21 | 22 | main() 23 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | const APP_ENV = process.env.APP_ENV || 'local' 2 | console.log("launch on", `node: ${process.env.NODE_ENV}`, `app_env: ${APP_ENV}`, `project: ${process.env.GCLOUD_PROJECT}`) 3 | 4 | // 環境変数 5 | const envPath = `env/.env.${APP_ENV}` 6 | const fs = require('fs') 7 | fs.statSync(envPath) // envファイルの存在確認 8 | const DotEnv = require('dotenv-webpack') 9 | const envFile = new DotEnv({ path: envPath, systemvars: true }) 10 | 11 | const nextConfig = { 12 | webpack: config => { 13 | // module alias 14 | config.resolve.alias['@'] = __dirname 15 | 16 | // env 17 | config.plugins = config.plugins || [] 18 | config.plugins.push(envFile) 19 | 20 | return config 21 | }, 22 | } 23 | 24 | module.exports = nextConfig 25 | -------------------------------------------------------------------------------- /functions/src/helper/array.ts: -------------------------------------------------------------------------------- 1 | export function compactMap(array: T[], handle: (data: T) => R | null | undefined) { 2 | const list: R[] = [] 3 | array.forEach(data => { 4 | const result = handle(data) 5 | if (result) { 6 | list.push(result) 7 | } 8 | }) 9 | return list 10 | } 11 | 12 | export async function handleInBatch(array: T[], batchSize: number, handler: (data: T[]) => Promise) { 13 | const length = array.length 14 | 15 | let data: T[] = [] 16 | for (let i = 0; i < length; i++) { 17 | if (data.length === batchSize) { 18 | await handler(data) 19 | data = [] 20 | } 21 | 22 | data.push(array[i]) 23 | } 24 | 25 | if (data.length > 0) { 26 | await handler(data) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /functions/src/eventHandler/callable/deleteWork.ts: -------------------------------------------------------------------------------- 1 | import { HttpsError } from 'firebase-functions/lib/providers/https' 2 | import { SearchRepository, WorkRepository } from '../../repository' 3 | import { defaultFunctions } from '../function' 4 | 5 | const functions = defaultFunctions() 6 | export const deleteWork = functions.https.onCall(async (data, context) => { 7 | const role = context.auth?.token?.role 8 | if (role !== 'admin') throw new HttpsError('permission-denied', '権限がありません') 9 | 10 | const workID = data.workID as string 11 | const workRepo = new WorkRepository() 12 | const searchRepo = new SearchRepository() 13 | 14 | await Promise.all([workRepo.delete(workID), searchRepo.workIndex.deleteObject(workID)]) 15 | return { success: true } 16 | }) 17 | -------------------------------------------------------------------------------- /functions/src/env.ts: -------------------------------------------------------------------------------- 1 | import * as functions from 'firebase-functions' 2 | 3 | export interface EnvorinmentVariables { 4 | annict: { 5 | token: string 6 | } 7 | amazon: { 8 | access: string 9 | secret: string 10 | } 11 | algolia: { 12 | indexprefix: string 13 | id: string 14 | key: string 15 | } 16 | } 17 | 18 | let config = functions.config() 19 | console.log('config', config) 20 | 21 | if (Object.keys(config).length === 0) { 22 | if (process.env.NODE_ENV === 'test' || process.env.GCLOUD_PROJECT === 'animenextdev') { 23 | config = require('../env/dev.json') 24 | } else if (process.env.GCLOUD_PROJECT === 'animenextprod') { 25 | config = require('../env/prod.json') 26 | } 27 | } 28 | 29 | export const env = config as EnvorinmentVariables 30 | -------------------------------------------------------------------------------- /frontend/components/seo/DefaultSeo.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultSeo as DS } from 'next-seo' 2 | import { publicEnv } from 'env' 3 | import { Const } from 'lib/const' 4 | 5 | export const DefaultSeo = () => { 6 | const title = `${Const.SERVICE_NAME}|アニメの続きの原作情報が探せるサービス` 7 | const description = 8 | 'おもしろかったアニメの続きは原作の何巻から読めるのかがすぐ見つかる!原作のマンガやライトノベル、小説をすぐ購入できるサービスです。' 9 | return ( 10 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /frontend/components/lv2/WorkOriginalEmpty.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styled from 'styled-components' 3 | import { Button } from 'components/lv1' 4 | import { Work } from 'model' 5 | 6 | const Container = styled.div` 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: center; 10 | align-items: center; 11 | text-align: center; 12 | ` 13 | 14 | type Props = { 15 | work: Work 16 | } 17 | 18 | export const WorkOriginalEmpty: FC = (props: Props) => { 19 | return ( 20 | 21 | まだ原作情報が登録されていません😫 22 |
23 | アニメの続きは原作の何巻からなのかを調べて登録してみませんか…? 24 | 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /functions/src/model/original.ts: -------------------------------------------------------------------------------- 1 | export const OriginalType = { 2 | comic: 'comic', 3 | lightNovel: 'lightNovel', 4 | novel: 'novel', 5 | } as const 6 | 7 | export type OriginalType = typeof OriginalType[keyof typeof OriginalType] 8 | 9 | export class OriginalLink { 10 | constructor(readonly amazon: string | undefined) {} 11 | } 12 | 13 | export const OriginalLinkSite = { 14 | amazon: 'amazon', 15 | } 16 | export type OriginalLinkSite = typeof OriginalLinkSite[keyof typeof OriginalLinkSite] 17 | 18 | export class Original { 19 | constructor( 20 | readonly id: string, 21 | readonly workID: string, 22 | readonly originalType: OriginalType, 23 | readonly animeEpisodeNo: string | null, 24 | readonly originalNo: string | null, 25 | readonly link: OriginalLink | null, 26 | readonly title: string | null, 27 | readonly imageURL: string | null 28 | ) {} 29 | } 30 | -------------------------------------------------------------------------------- /frontend/components/lv1/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styled from 'styled-components' 3 | import Loader from 'react-loader-spinner' 4 | import { StyleConst } from 'styles/const' 5 | 6 | const Container = styled.div` 7 | text-align: center; 8 | ` 9 | 10 | type Size = 'small' 11 | 12 | type Props = { 13 | size?: Size 14 | paddingLess?: boolean 15 | } 16 | 17 | export const LoadingIndicator: FC = ({ size, paddingLess }) => { 18 | const loaderSize: number = (() => { 19 | switch (size!) { 20 | case 'small': 21 | return 44 22 | } 23 | })() 24 | 25 | return ( 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | LoadingIndicator.defaultProps = { size: 'small', paddingLess: false } 33 | -------------------------------------------------------------------------------- /functions/src/script/registerToAlgolia.ts: -------------------------------------------------------------------------------- 1 | import { initializeProject } from './helper' 2 | const app = initializeProject('prod') 3 | 4 | import { SearchRepository, WorkRepository } from '../repository' 5 | import { findInBatch } from '../helper/firestore' 6 | import { compactMap } from '../helper/array' 7 | import { sleep } from '../common/sleep' 8 | 9 | const main = async () => { 10 | const db = app.firestore() 11 | const searchRepo = new SearchRepository(db) 12 | await searchRepo.setWorkSettings() 13 | 14 | const workRepo = new WorkRepository(db) 15 | const query = workRepo.worksRef 16 | let index = 0 17 | await findInBatch(query, 100, async snapshots => { 18 | const works = compactMap(snapshots, s => workRepo.decode(s)) 19 | await searchRepo.registerWorks(works) 20 | await sleep(2000) 21 | console.log('register', index++) 22 | }) 23 | 24 | process.exit(0) 25 | } 26 | 27 | main() 28 | -------------------------------------------------------------------------------- /frontend/hooks/work/useTrendWorks.ts: -------------------------------------------------------------------------------- 1 | import { Work } from 'model' 2 | import { useState, useEffect } from 'react' 3 | import { TrendRepository } from 'repository' 4 | 5 | type ReturnType = { 6 | loading: boolean 7 | works: Work[] 8 | } 9 | 10 | export const useTrendWorks = (props: { count: number }): ReturnType => { 11 | const [works, setWorks] = useState([]) 12 | const [loading, setLoading] = useState(true) 13 | const trendRepo = new TrendRepository() 14 | 15 | useEffect(() => { 16 | let cancel = false 17 | 18 | const retrieve = async () => { 19 | setLoading(true) 20 | const works = await trendRepo.fetchTrendWorks(props.count) 21 | if (!cancel) { 22 | setWorks(works) 23 | setLoading(false) 24 | } 25 | } 26 | 27 | retrieve() 28 | return () => { 29 | cancel = true 30 | } 31 | }, []) 32 | 33 | return { loading, works } 34 | } 35 | -------------------------------------------------------------------------------- /functions/src/script/removeOldAnimeFromDev.ts: -------------------------------------------------------------------------------- 1 | import { initializeProject } from './helper' 2 | const app = initializeProject('dev', 'dev') 3 | 4 | import { SearchRepository } from '../repository' 5 | import { findInBatch } from '../helper/firestore' 6 | import { sleep } from '../common/sleep' 7 | 8 | const main = async () => { 9 | const writer = app.firestore().bulkWriter() 10 | const searchRepo = new SearchRepository(app.firestore()) 11 | 12 | for (let year = 2000; year < 2019; year++) { 13 | const query = app.firestore().collection('works').where('year', '==', year) 14 | await findInBatch(query, 100, async snapshots => { 15 | snapshots.forEach(s => writer.delete(s.ref)) 16 | await searchRepo.workIndex.deleteObjects(snapshots.map(s => s.id)) 17 | console.log(year, snapshots.length) 18 | await sleep(1000) 19 | }) 20 | } 21 | 22 | await writer.close() 23 | process.exit(0) 24 | } 25 | 26 | main() 27 | -------------------------------------------------------------------------------- /frontend/components/lv2/Header.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styled from 'styled-components' 3 | import { StyleConst } from 'styles/const' 4 | import { Const } from 'lib/const' 5 | import Link from 'next/link' 6 | 7 | const _Header = styled.header` 8 | height: ${StyleConst.HEIGHT.HEADER}px; 9 | width: 100%; 10 | z-index: 1000; 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | background: white; 15 | border-bottom: 1px #f0f0f0 solid; 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | 20 | img { 21 | height: 30px; 22 | width: auto; 23 | margin: auto; 24 | cursor: pointer; 25 | } 26 | ` 27 | 28 | export const Header: FC = () => { 29 | return ( 30 | <_Header> 31 |

32 | 33 | {Const.SERVICE_NAME} 34 | 35 |

36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /frontend/model/original.ts: -------------------------------------------------------------------------------- 1 | export const OriginalType = { 2 | comic: 'comic', 3 | lightNovel: 'lightNovel', 4 | novel: 'novel', 5 | } as const 6 | 7 | export type OriginalType = typeof OriginalType[keyof typeof OriginalType] 8 | 9 | export function originalTypeLabel(type: OriginalType) { 10 | switch (type) { 11 | case 'comic': 12 | return 'コミック' 13 | case 'lightNovel': 14 | return 'ライトノベル' 15 | case 'novel': 16 | return '小説' 17 | } 18 | } 19 | 20 | export class OriginalLink { 21 | constructor(readonly amazon: string | undefined) {} 22 | } 23 | 24 | export class Original { 25 | constructor( 26 | readonly id: string, 27 | readonly originalType: OriginalType, 28 | readonly animeEpisodeNo: string | undefined, 29 | readonly originalNo: string | undefined, 30 | readonly link: OriginalLink | undefined, 31 | readonly title: string | null, 32 | readonly imageURL: string | null 33 | ) {} 34 | } 35 | -------------------------------------------------------------------------------- /frontend/pages/admin/auth/sign_in.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { NextPage } from 'next' 3 | import { LabelButton } from 'components/lv1' 4 | import firebase from 'firebase/app' 5 | import { auth } from 'lib/firebase/client' 6 | import { useAuth } from 'hooks/useAuth' 7 | import Link from 'next/link' 8 | 9 | const Page: NextPage = () => { 10 | return 11 | } 12 | 13 | const Body: FC = () => { 14 | const authState = useAuth() 15 | 16 | const onGoogleLogin = async () => { 17 | await auth.signInWithRedirect(new firebase.auth.GoogleAuthProvider()) 18 | } 19 | 20 | return ( 21 | <> 22 | 23 | {authState.user && ( 24 |
25 | userID: {authState.user.id} 26 |
27 | 作品一覧 28 |
29 |
30 | )} 31 | 32 | ) 33 | } 34 | 35 | export default Page 36 | -------------------------------------------------------------------------------- /frontend/hooks/work/useRelatedWorks.ts: -------------------------------------------------------------------------------- 1 | import { Work } from 'model' 2 | import { useState, useEffect } from 'react' 3 | import { TrendRepository } from 'repository' 4 | 5 | type ReturnType = { 6 | loading: boolean 7 | relatedWorks: Work[] 8 | } 9 | 10 | export const useRelatedWorks = (work: Work, count = 10): ReturnType => { 11 | const [relatedWorks, setRelatedWorks] = useState([]) 12 | const [loading, setLoading] = useState(false) 13 | 14 | useEffect(() => { 15 | let cancel = false 16 | 17 | const retrieve = async () => { 18 | setLoading(true) 19 | const trendRepo = new TrendRepository() 20 | const works = await trendRepo.fetchRelatedWorks(work, count) 21 | if (!cancel) { 22 | setLoading(false) 23 | setRelatedWorks(works) 24 | } 25 | } 26 | retrieve() 27 | 28 | return () => { 29 | cancel = true 30 | } 31 | }, [work]) 32 | 33 | return { loading, relatedWorks } 34 | } 35 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "functions": { 7 | "predeploy": [ 8 | "npm --prefix \"$RESOURCE_DIR\" run lint", 9 | "npm --prefix \"$RESOURCE_DIR\" run build" 10 | ] 11 | }, 12 | "hosting": { 13 | "public": "frontend/.next/static", 14 | "rewrites": [ 15 | { 16 | "source": "**/**", 17 | "run": { 18 | "serviceId": "renderer", 19 | "region": "asia-northeast1" 20 | } 21 | } 22 | ], 23 | "postdeploy": [ 24 | "npm --prefix frontend run gcloud:build", 25 | "npm --prefix frontend run gcloud:run" 26 | ] 27 | }, 28 | "emulators": { 29 | "functions": { 30 | "port": 5001 31 | }, 32 | "firestore": { 33 | "port": 8080 34 | }, 35 | "hosting": { 36 | "port": 5000 37 | }, 38 | "ui": { 39 | "enabled": true 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/components/lv2/WorkImage.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styled from 'styled-components' 3 | import { Image } from 'components/lv1' 4 | 5 | const imageRatio = (630 / 1200) * 100 6 | const Container = styled.div` 7 | width: 100%; 8 | position: relative; 9 | overflow: hidden; 10 | 11 | ::before { 12 | content: ''; 13 | display: block; 14 | padding-top: ${imageRatio}%; /* div.imageContainerの幅の50% */ 15 | } 16 | 17 | div.cardImage { 18 | position: absolute; 19 | top: ${imageRatio}%; 20 | transform: translateY(-${imageRatio}%); 21 | width: 100%; 22 | 23 | img { 24 | width: 100%; 25 | } 26 | } 27 | ` 28 | 29 | type Props = { 30 | src: string | null | undefined 31 | } 32 | 33 | export const WorkImage: FC = (props: Props) => { 34 | return ( 35 | 36 |
37 | アニメ画像 38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /functions/src/repository/original.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from './base' 2 | import { firestore } from 'firebase-admin' 3 | import { Original, OriginalLinkSite } from '../model/original' 4 | 5 | export class OriginalRepository extends Repository { 6 | decode(snap: firestore.DocumentSnapshot): Original | undefined { 7 | const workID = snap.ref.parent.parent?.id 8 | const data = snap.data() 9 | if (!(workID && data)) return 10 | // FIXME: もう少しちゃんと 11 | return new Original(snap.id, workID, data.originalType, data.animeEpisodeNo, data.originalNo, data.link, data.title, data.imageURL) 12 | } 13 | 14 | setAffiriateInfo(original: Original, info: { title: string; link: { site: OriginalLinkSite; url: string }; imageURL: string | null }) { 15 | const link: any = {} 16 | link[info.link.site] = info.link.url 17 | return this.originalsRef(original.workID).doc(original.id).update({ 18 | title: info.title, 19 | link, 20 | imageURL: info.imageURL, 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /functions/src/common/logger.ts: -------------------------------------------------------------------------------- 1 | import { logger } from 'firebase-functions' 2 | 3 | export class Logger { 4 | static debug(payload: any) { 5 | this.write(payload, 'DEBUG') 6 | } 7 | 8 | static info(payload: any) { 9 | this.write(payload, 'INFO') 10 | } 11 | 12 | static warn(payload: any) { 13 | this.write(payload, 'WARNING') 14 | } 15 | 16 | static error(payload: any, error?: Error) { 17 | let json = payload 18 | if (error) { 19 | json = Object.assign(json, { _errorMessage: error.message, stacktrace: error.stack }) 20 | } 21 | this.write(json, 'ERROR') 22 | } 23 | 24 | static fatal(payload: any, error?: Error) { 25 | let json = payload 26 | if (error) { 27 | json = Object.assign(json, { _errorMessage: error.message, stacktrace: error.stack }) 28 | } 29 | this.write(json, 'ALERT') 30 | } 31 | 32 | static write(payload: any, severity: logger.LogSeverity) { 33 | logger.write({ 34 | severity, 35 | ...payload, 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/lib/firebase/client.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app' 2 | import 'firebase/firestore' 3 | import 'firebase/auth' 4 | import 'firebase/analytics' 5 | import 'firebase/functions' 6 | 7 | const app = (() => { 8 | if (firebase.apps.length !== 0) { 9 | return firebase.app() 10 | } 11 | 12 | const app = firebase.initializeApp({ 13 | apiKey: process.env.PUBLIC_API_KEY, 14 | authDomain: process.env.PUBLIC_AUTH_DOMAIN, 15 | databaseURL: process.env.PUBLIC_DATABASE_URL, 16 | projectId: process.env.PUBLIC_PROJECT_ID, 17 | storageBucket: process.env.PUBLIC_STORAGE_BUCKET, 18 | messagingSenderId: process.env.PUBLIC_MESSAGING_SENDER_ID, 19 | appId: process.env.PUBLIC_APP_ID, 20 | measurementId: process.env.PUBLIC_MEASUREMENT_ID, 21 | }) 22 | 23 | return app 24 | })() 25 | 26 | if (process.browser) { 27 | app.analytics() 28 | } 29 | 30 | export const firestore = firebase.firestore() 31 | export const auth = firebase.auth() 32 | export const functions = firebase.app().functions('asia-northeast1') 33 | -------------------------------------------------------------------------------- /functions/src/helper/firestore.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from 'firebase-admin' 2 | 3 | /// queryに対してbatchSizeずつ取得してexecutorに渡してくれる 4 | export async function findInBatch( 5 | query: firestore.Query, 6 | batchSize: number, 7 | executor: (snapshot: firestore.QueryDocumentSnapshot[]) => Promise 8 | ): Promise { 9 | let hasNextPage = true 10 | let lastDocument: firestore.DocumentSnapshot | undefined 11 | 12 | while (hasNextPage) { 13 | // 1件多めに取得して次がまだあるか判断する 14 | let q = query.limit(batchSize + 1) 15 | if (lastDocument) { 16 | // 前回の続きから取得 17 | q = q.startAt(lastDocument) 18 | } 19 | 20 | const snapshot = await q.get() 21 | 22 | // 取得できた件数が指定通りだったら次のデータがまだある 23 | hasNextPage = snapshot.size === batchSize + 1 24 | // snapshot.docsをpopしても要素が消えないのでコピーした配列に対して操作する 25 | const docs = snapshot.docs.concat() 26 | if (hasNextPage) { 27 | // 続きから取得する用に1件とっておく 28 | lastDocument = docs.pop() 29 | } 30 | 31 | // batch処理してもらう 32 | await executor(docs) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:@typescript-eslint/eslint-recommended", 7 | "plugin:prettier/recommended", 8 | "prettier/@typescript-eslint" 9 | ], 10 | "plugins": ["@typescript-eslint", "react"], 11 | "parser": "@typescript-eslint/parser", 12 | "env": { 13 | "browser": true, 14 | "node": true, 15 | "es6": true 16 | }, 17 | "parserOptions": { 18 | "sourceType": "module", 19 | "ecmaFeatures": { 20 | "jsx": true 21 | } 22 | }, 23 | "rules": { 24 | "react/display-name": "off", 25 | "react/prop-types": "off", 26 | "react/react-in-jsx-scope": "off", 27 | "@typescript-eslint/no-explicit-any": "off", 28 | "@typescript-eslint/explicit-module-boundary-types": "off", 29 | "@typescript-eslint/no-non-null-assertion": "off", 30 | "@typescript-eslint/no-var-requires": "off", 31 | "no-case-declarations": "off" 32 | }, 33 | "ignorePatterns": ["test/**/*"] 34 | } 35 | -------------------------------------------------------------------------------- /frontend/components/seo/WorkDetailSeo.tsx: -------------------------------------------------------------------------------- 1 | import { NextSeo } from 'next-seo' 2 | import { FC } from 'react' 3 | import { Work, Original, originalTypeLabel } from 'model' 4 | import { Const } from 'lib/const' 5 | import { seasonLabel } from 'enum/season' 6 | import { publicEnv } from 'env' 7 | 8 | export const WorkDetailSeo: FC<{ work: Work; originals: Original[] }> = ({ work, originals }) => { 9 | const original = originals[0] 10 | let originalMessage = '' 11 | if (original) { 12 | originalMessage = `気になる続きは${originalTypeLabel(original.originalType)}で読むことができます!詳細情報はこちら!` 13 | } 14 | 15 | return ( 16 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "jsx": "preserve", 6 | "lib": [ 7 | "dom", 8 | "es2017" 9 | ], 10 | "baseUrl": ".", 11 | "plugins": [ 12 | { 13 | "name": "typescript-styled-plugin" 14 | } 15 | ], 16 | "typeRoots": ["types", "node_modules/@types"], 17 | "moduleResolution": "node", 18 | "strict": true, 19 | "noEmit": true, 20 | "allowSyntheticDefaultImports": true, 21 | "esModuleInterop": true, 22 | "skipLibCheck": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "isolatedModules": true, 26 | "removeComments": false, 27 | "preserveConstEnums": true, 28 | "sourceMap": true, 29 | "forceConsistentCasingInFileNames": true, 30 | "resolveJsonModule": true, 31 | "allowJs": true 32 | }, 33 | "exclude": [ 34 | "dist", 35 | ".next", 36 | "out", 37 | "next.config.js" 38 | ], 39 | "include": [ 40 | "next-env.d.ts", 41 | "**/*.ts", 42 | "**/*.tsx", "next.config.js" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /functions/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "project": "tsconfig.json", 15 | "sourceType": "module" 16 | }, 17 | "overrides": [ 18 | { 19 | "files": ["src/**/*"], 20 | "excludedFiles": ["src/script/**/*"] 21 | } 22 | ], 23 | "ignorePatterns": ["src/test/**/*"], 24 | "rules": { 25 | "react/prop-types": "off", 26 | "react/react-in-jsx-scope": "off", 27 | "@typescript-eslint/no-explicit-any": "off", 28 | "@typescript-eslint/explicit-module-boundary-types": "off", 29 | "@typescript-eslint/no-non-null-assertion": "off", 30 | "@typescript-eslint/no-var-requires": "off", 31 | "no-case-declarations": "off" 32 | }, 33 | "settings": { 34 | "jsdoc": { 35 | "tagNamePreference": { 36 | "returns": "return" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/pages/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage, NextPageContext } from 'next' 2 | import { SearchBar } from 'components/lv2' 3 | import { WorkList } from 'components/lv3' 4 | import { SearchRepository } from 'repository/search' 5 | import { Work } from 'model' 6 | import styled from 'styled-components' 7 | 8 | type Props = { 9 | searchText: string | null 10 | works: Work[] 11 | } 12 | 13 | const SearchBarContainer = styled.div` 14 | padding: 16px 0px; 15 | ` 16 | 17 | const SearchPage: NextPage = (props: Props) => { 18 | return ( 19 | <> 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | SearchPage.getInitialProps = async ({ query }: NextPageContext): Promise => { 29 | const keyword = query.q as string | null 30 | const repo = new SearchRepository() 31 | if (!keyword) { 32 | return { searchText: keyword, works: [] } 33 | } 34 | 35 | const works = await repo.searchWorks(keyword) 36 | return { 37 | searchText: keyword, 38 | works, 39 | } 40 | } 41 | 42 | export default SearchPage 43 | -------------------------------------------------------------------------------- /frontend/styles/const.ts: -------------------------------------------------------------------------------- 1 | const FONT = { 2 | XXXLARGE: 32, 3 | XXLARGE: 28, 4 | XLARGE: 24, 5 | LARGE: 20, 6 | MEDIUM: 18, 7 | BASE: 16, 8 | SMALL: 12, 9 | XSMALL: 10, 10 | TINY: 8, 11 | } as const 12 | 13 | const FONT_WEIGHT = { 14 | NORMAL: 400, 15 | BOLD: 600, 16 | } as const 17 | 18 | const COLOR = { 19 | PRIMARY: '#f59042', 20 | SYSTEM_BACKGROUND: '#fdfdfd', 21 | HIGHLIGHT_BACKGROUND: '#f2f0ed', 22 | FORM_BACKGROUND: '#f2f2f2', 23 | IMAGE_BACKGROUND: '#DDDDDD', 24 | SHADOW: '#e8e8e8', 25 | LABEL: '#444444', 26 | PLACEHOLDER: '#c7c7c7', 27 | STRONG_LABEL: '#000000', 28 | TEXT_INPUT_BORDER: '#dddddd', 29 | TEXT_INPUT_FOCUS_BORDER: '#f59042', 30 | } as const 31 | 32 | const WIDTH = { 33 | CONTENT_MAX: 768, 34 | } as const 35 | 36 | const HEIGHT = { 37 | HEADER: 52, 38 | } as const 39 | 40 | const SHADOW = { 41 | DEFAULT: `0px 0px 6px 3px ${COLOR.SHADOW}`, 42 | } as const 43 | 44 | const PADDING = { 45 | SIDE: 16, 46 | } as const 47 | 48 | const CORNER_RADIUS = { 49 | DEFAULT: 6, 50 | } as const 51 | 52 | export const StyleConst = { 53 | FONT, 54 | FONT_WEIGHT, 55 | COLOR, 56 | WIDTH, 57 | HEIGHT, 58 | SHADOW, 59 | PADDING, 60 | CORNER_RADIUS, 61 | } 62 | -------------------------------------------------------------------------------- /frontend/components/lv2/WorkCard.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styled from 'styled-components' 3 | import { Work } from 'model' 4 | import Link from 'next/link' 5 | import { StyleConst } from 'styles/const' 6 | import { WorkImage } from './WorkImage' 7 | 8 | const WorkCardContainer = styled.div` 9 | border-radius: ${StyleConst.CORNER_RADIUS.DEFAULT}px; 10 | overflow: hidden; 11 | width: 100%; 12 | text-align: center; 13 | box-shadow: ${StyleConst.SHADOW.DEFAULT}; 14 | 15 | a { 16 | display: flex; 17 | flex-direction: column; 18 | height: 100%; 19 | } 20 | 21 | div.title { 22 | font-weight: bold; 23 | font-size: ${StyleConst.FONT.BASE}px; 24 | padding: 12px 6px; 25 | flex: 1; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | } 30 | ` 31 | 32 | type Props = { 33 | work: Work 34 | } 35 | 36 | export const WorkCard: FC = ({ work }: Props) => { 37 | return ( 38 | 39 | 40 | 41 | 42 |
{work.title}
43 |
44 | 45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /frontend/styles/Global.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components' 2 | import { resetCss } from 'styles/reset' 3 | import { StyleConst } from 'styles/const' 4 | 5 | export const GlobalStyle = createGlobalStyle` 6 | ${resetCss} 7 | 8 | body { 9 | color: ${StyleConst.COLOR.LABEL}; 10 | background-color: ${StyleConst.COLOR.SYSTEM_BACKGROUND}; 11 | font-size: ${StyleConst.FONT.BASE}px; 12 | font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "ヒラギノ角ゴ ProN W3", "ヒラギノ角ゴシック", "メイリオ", Meiryo, "MS Pゴシック", "MS PGothic", sans-serif; 13 | } 14 | 15 | a { 16 | color: ${StyleConst.COLOR.LABEL}; 17 | text-decoration: none; 18 | } 19 | 20 | h2 { 21 | font-size: ${StyleConst.FONT.XLARGE}px; 22 | font-weight: ${StyleConst.FONT_WEIGHT.BOLD}; 23 | padding: 16px 0px; 24 | } 25 | 26 | input { 27 | color: ${StyleConst.COLOR.LABEL}; 28 | } 29 | 30 | input[type=radio] { 31 | margin-right: 3px; 32 | } 33 | 34 | input::placeholder { 35 | color: ${StyleConst.COLOR.PLACEHOLDER}; 36 | } 37 | 38 | input::-ms-input-placeholder { 39 | color: ${StyleConst.COLOR.PLACEHOLDER}; 40 | } 41 | 42 | input::-webkit-input-placeholder { 43 | color: ${StyleConst.COLOR.PLACEHOLDER}; 44 | } 45 | ` 46 | -------------------------------------------------------------------------------- /frontend/styles/global.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components' 2 | import { resetCss } from 'styles/reset' 3 | import { StyleConst } from 'styles/const' 4 | 5 | export const GlobalStyle = createGlobalStyle` 6 | ${resetCss} 7 | 8 | body { 9 | color: ${StyleConst.COLOR.LABEL}; 10 | background-color: ${StyleConst.COLOR.SYSTEM_BACKGROUND}; 11 | font-size: ${StyleConst.FONT.BASE}px; 12 | font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "ヒラギノ角ゴ ProN W3", "ヒラギノ角ゴシック", "メイリオ", Meiryo, "MS Pゴシック", "MS PGothic", sans-serif; 13 | } 14 | 15 | a { 16 | color: ${StyleConst.COLOR.LABEL}; 17 | text-decoration: none; 18 | } 19 | 20 | h2 { 21 | font-size: ${StyleConst.FONT.XLARGE}px; 22 | font-weight: ${StyleConst.FONT_WEIGHT.BOLD}; 23 | padding: 16px 0px; 24 | } 25 | 26 | input { 27 | color: ${StyleConst.COLOR.LABEL}; 28 | } 29 | 30 | input[type=radio] { 31 | margin-right: 3px; 32 | } 33 | 34 | input::placeholder { 35 | color: ${StyleConst.COLOR.PLACEHOLDER}; 36 | } 37 | 38 | input::-ms-input-placeholder { 39 | color: ${StyleConst.COLOR.PLACEHOLDER}; 40 | } 41 | 42 | input::-webkit-input-placeholder { 43 | color: ${StyleConst.COLOR.PLACEHOLDER}; 44 | } 45 | ` 46 | -------------------------------------------------------------------------------- /frontend/env/index.ts: -------------------------------------------------------------------------------- 1 | interface ServerEnv { 2 | host: string 3 | firebase: { [key: string]: any } 4 | } 5 | 6 | // ブラウザから触ると落ちるようにしている 7 | export const serverEnv: ServerEnv = (() => { 8 | if (process.browser) return undefined as any 9 | return { 10 | host: process.env.PUBLIC_HOST!, 11 | firebase: { 12 | project_id: process.env.PUBLIC_PROJECT_ID, 13 | private_key_id: process.env.PRIVATE_KEY_ID, 14 | private_key: process.env.PRIVATE_KEY!.replace(/\\n/g, '\n'), 15 | client_email: process.env.CLIENT_EMAIL, 16 | client_id: process.env.CLIENT_ID, 17 | auth_uri: process.env.AUTH_URI, 18 | token_uri: process.env.TOKEN_URI, 19 | auth_provider_x509_cert_url: process.env.AUTH_PROVIDER_URL, 20 | client_x509_cert_url: process.env.CLIENT_CERT_URL, 21 | }, 22 | } as ServerEnv 23 | })() 24 | 25 | interface PublicEnv { 26 | host: string 27 | algolia: { 28 | appID: string 29 | searchKey: string 30 | indexPrefix: string 31 | } 32 | } 33 | 34 | export const publicEnv: PublicEnv = { 35 | host: process.env.PUBLIC_HOST!, 36 | algolia: { 37 | appID: process.env.ALGOLIA_APP_ID!, 38 | searchKey: process.env.ALGOLIA_SEARCH_KEY!, 39 | indexPrefix: process.env.ALGOLIA_INDEX_PREFIX!, 40 | }, 41 | } 42 | -------------------------------------------------------------------------------- /frontend/components/lv4/Top.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { SearchBar } from 'components/lv2/SearchBar' 3 | import styled from 'styled-components' 4 | import { useTrendWorks } from 'hooks/work/useTrendWorks' 5 | import { WorkList } from 'components/lv3' 6 | import { LoadingIndicator } from 'components/lv1/LoadingIndicator' 7 | import { Center } from 'components/lv1' 8 | 9 | const SearchBarContainer = styled.div` 10 | text-align: center; 11 | margin: 36px 0px; 12 | ` 13 | 14 | const PromotionContainer = styled.div` 15 | margin: 36px 0px; 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | ` 20 | 21 | export const Top: FC = () => { 22 | const { loading, works } = useTrendWorks({ count: 10 }) 23 | return ( 24 | <> 25 | 26 |
\
27 |
28 | アニメの続きは原作の何巻から 29 |
30 | なのかをさっそく調べてみよう👀 31 |
32 |
/
33 |
34 | 35 | 36 | 37 | 38 |

人気の作品

39 | {loading ? : } 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /frontend/hooks/work/useWorkOriginals.ts: -------------------------------------------------------------------------------- 1 | import { Work, Original } from 'model' 2 | import { useState, useEffect } from 'react' 3 | import { OriginalRepository } from 'repository' 4 | 5 | type ReturnType = { 6 | loading: boolean 7 | originals: Original[] 8 | addOriginal: (original: Original) => void 9 | } 10 | 11 | export const useWorkOriginals = (work: Work, defaultOriginals?: Original[]): ReturnType => { 12 | const [originals, setOriginals] = useState(defaultOriginals || []) 13 | const [loading, setLoading] = useState(true) 14 | const originalRepo = new OriginalRepository() 15 | 16 | useEffect(() => { 17 | const defaultLength = defaultOriginals?.length || 0 18 | if (defaultLength !== 0 && defaultLength === originals.length) { 19 | setLoading(false) 20 | return 21 | } 22 | 23 | let cancel = false 24 | const unsubscribe = originalRepo.subscribeOriginals(work.id, originals => { 25 | if (!cancel) { 26 | setLoading(false) 27 | setOriginals(originals) 28 | } 29 | }) 30 | 31 | return () => { 32 | unsubscribe() 33 | cancel = true 34 | } 35 | }, [work]) 36 | 37 | const addOriginal = (original: Original) => { 38 | setOriginals([original, ...originals]) 39 | } 40 | 41 | return { loading, originals, addOriginal } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/pages/works/[workID].tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { NextPageContext, NextPage } from 'next' 3 | import { WorkDetail } from 'components/lv4/WorkDetail' 4 | import { WorkRepository } from 'repository/work' 5 | import { WorkDetailSeo } from 'components/seo' 6 | import { Work, Original } from 'model' 7 | import { OriginalRepository } from 'repository' 8 | 9 | interface Props { 10 | work: Work | undefined // FIXME: 型は仮 11 | originals: Original[] 12 | } 13 | 14 | const Page: NextPage = (props: Props) => { 15 | const work = props.work 16 | return ( 17 | <> 18 | {!work &&
not found
} 19 | 20 | {work && ( 21 | <> 22 | 23 | 24 | 25 | )} 26 | 27 | ) 28 | } 29 | 30 | Page.getInitialProps = async ({ res, query }: NextPageContext): Promise => { 31 | const workID = query.workID as string 32 | const workRepo = new WorkRepository() 33 | const originalRepo = new OriginalRepository() 34 | const [work, originals] = await Promise.all([workRepo.find(workID), originalRepo.fetchOriginals(workID)]) 35 | if (!work && res) res.statusCode = 404 36 | return { work, originals } 37 | } 38 | 39 | export default Page 40 | -------------------------------------------------------------------------------- /frontend/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { DocumentContext, Html, Head, Main, NextScript } from 'next/document' 2 | import { ServerStyleSheet } from 'styled-components' 3 | 4 | // 参考:https://medium.com/swlh/server-side-rendering-styled-components-with-nextjs-1db1353e915e 5 | // まだClass記法じゃないとダメらしい 6 | 7 | export default class CustomDocument extends Document { 8 | static async getInitialProps(ctx: DocumentContext) { 9 | const sheet = new ServerStyleSheet() 10 | const originalRenderPage = ctx.renderPage 11 | 12 | try { 13 | ctx.renderPage = () => 14 | originalRenderPage({ 15 | enhanceApp: App => props => sheet.collectStyles(), 16 | }) 17 | 18 | const initialProps = await Document.getInitialProps(ctx) 19 | return { 20 | ...initialProps, 21 | styles: ( 22 | <> 23 | {initialProps.styles} 24 | {sheet.getStyleElement()} 25 | 26 | ), 27 | } 28 | } finally { 29 | sheet.seal() 30 | } 31 | } 32 | 33 | render() { 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 | 43 | 44 | 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/components/lv2/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'next/link' 4 | import { StyleConst } from 'styles/const' 5 | import { Const } from 'lib/const' 6 | 7 | const _Footer = styled.footer` 8 | padding: 16px 0px; 9 | font-size: ${StyleConst.FONT.SMALL}px; 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | align-items: center; 14 | ` 15 | 16 | const CopyRight = styled.div` 17 | font-size: 12px; 18 | ` 19 | 20 | const FlexRowContainer = styled.div` 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | ` 25 | 26 | const Content = styled.div` 27 | margin: 0px 8px 16px; 28 | ` 29 | 30 | export const Footer: FC = () => { 31 | return ( 32 | <_Footer> 33 | 34 | 35 | 36 | このサービスについて 37 | 38 | 39 | 40 | 41 | 利用規約 42 | 43 | 44 | 45 | 46 | プライバシーポリシー 47 | 48 | 49 | 50 | © {Const.SERVICE_NAME} 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /functions/src/script/helper.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as admin from 'firebase-admin' 3 | 4 | export type AppEnvironment = 'dev' | 'prod' 5 | 6 | export const isValidAppEnv = (value: string): value is AppEnvironment => { 7 | return ['dev', 'prod'].includes(value) 8 | } 9 | 10 | export const initializeProject = (env?: AppEnvironment, name?: string) => { 11 | const envValue = process.env.APP_ENV || env 12 | if (!envValue) throw new Error('No env specified. You must set APP_ENV or pass as args.') 13 | if (!isValidAppEnv(envValue)) throw new Error('env must be "dev" or "prod".') 14 | 15 | console.log(`initialize project with ${envValue}.`) 16 | const serviceAccountPath = path.resolve(__dirname, 'serviceAccount', `${envValue}.json`) 17 | const serviceAccount = require(serviceAccountPath) 18 | if (!serviceAccount) throw new Error('JSON credential must be placed in serviceAccount dir.') 19 | 20 | process.env.GCLOUD_PROJECT = serviceAccount.project_id 21 | 22 | // FIXME: prodで動かすときはこれしないとだめ 23 | // firebase -P prod functions:config:get > .runtimeconfig.json 24 | 25 | return admin.initializeApp( 26 | { 27 | credential: admin.credential.cert(serviceAccount), 28 | // if you specify databaseURL, please fix script 29 | databaseURL: `https://${serviceAccount.project_id}.firebaseio.com`, 30 | }, 31 | name 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /functions/src/script/getWorksFromAnnict.ts: -------------------------------------------------------------------------------- 1 | import { initializeProject } from './helper' 2 | initializeProject('prod') 3 | 4 | import { SetWorkService } from '../service' 5 | import { Season } from '../enum/season' 6 | import * as fs from 'fs' 7 | import { sleep } from '../common/sleep' 8 | 9 | type Params = { 10 | year: number 11 | seasons: Season[] 12 | } 13 | 14 | const main = async () => { 15 | const service = new SetWorkService() 16 | 17 | const list: Params[] = [] 18 | list.push({ year: 2021, seasons: ['spring', 'summer'] }) 19 | // for (let year = 2000; year <= 2020; year++) { 20 | // list.push({ year, seasons: allSeasons }) 21 | // } 22 | 23 | const failedWorkIDs: string[] = [] 24 | 25 | for (const params of list) { 26 | for (const season of params.seasons) { 27 | const result = await service.execute(params.year, season, { skipToGetAdditionalImage: false, registerToAlgolia: true }) 28 | if (result.isFailure) { 29 | console.log(season, params.year, 'failure') 30 | throw result.error 31 | } 32 | 33 | failedWorkIDs.push(...result.value.setImageFailedWorkIDs) 34 | await sleep(10 * 1000) 35 | } 36 | } 37 | 38 | if (failedWorkIDs.length > 0) { 39 | fs.writeFileSync('./src/script/setWorksResult.json', JSON.stringify({ failedWorkIDs, list }, null, 2)) 40 | } 41 | 42 | process.exit(0) 43 | } 44 | 45 | main() 46 | -------------------------------------------------------------------------------- /frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app' 2 | import Head from 'next/head' 3 | import { GlobalStyle } from 'styles/global' 4 | import { AuthProvider } from 'hooks/useAuth' 5 | import styled from 'styled-components' 6 | import { StyleConst } from 'styles/const' 7 | import NProgress from 'nextjs-progressbar' 8 | import { Header, Footer } from 'components/lv2' 9 | import { DefaultSeo } from 'components/seo' 10 | 11 | const AppContainer = styled.div` 12 | position: relative; 13 | padding: ${StyleConst.HEIGHT.HEADER}px ${StyleConst.PADDING.SIDE}px 32px; 14 | margin: 0 auto; 15 | max-width: ${StyleConst.WIDTH.CONTENT_MAX}px; 16 | min-height: 100vh; 17 | ` 18 | 19 | const Provider = ({ children }: { children: React.ReactNode }) => {children} 20 | 21 | const App = ({ Component, pageProps }: AppProps) => { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |