├── .dockerignore ├── components ├── Tierlist │ ├── ViewNavbar │ │ ├── style.scss │ │ └── ViewNavbar.tsx │ ├── _variables.scss │ ├── Notification │ │ ├── style.scss │ │ └── Notification.tsx │ ├── types.d.ts │ ├── style.scss │ ├── index.ts │ ├── StaticCharacter │ │ └── StaticCharacter.tsx │ ├── Navbar │ │ ├── style.scss │ │ └── Navbar.tsx │ ├── Tier │ │ ├── style.scss │ │ └── Tier.tsx │ ├── DraggableCharacter │ │ ├── style.scss │ │ └── DraggableCharacter.tsx │ ├── CharacterHolder │ │ ├── style.scss │ │ └── CharacterHolder.tsx │ └── Tierlist.tsx └── Home │ ├── SavedLists │ ├── style.scss │ └── SavedLists.tsx │ ├── index.ts │ ├── SearchBar │ ├── style.scss │ └── SearchBar.tsx │ ├── Title │ ├── style.scss │ └── Title.tsx │ ├── Results │ ├── style.scss │ └── Results.tsx │ ├── SearchResult │ ├── style.scss │ └── SearchResult.tsx │ ├── style.scss │ └── Home.tsx ├── static ├── cry.mp4 ├── mal.png ├── anilist.png ├── favicon.png ├── hifumi.png ├── kitsu.png ├── waifu.jpg ├── background.png ├── waifu_large.jpg ├── favicon_small.png └── manifest.json ├── globals.d.ts ├── layouts ├── _variables.scss ├── PageWrapper │ ├── style.scss │ └── PageWrapper.tsx ├── globalStyle.scss ├── index.ts ├── _mixins.scss └── Head │ └── Head.tsx ├── hooks └── useCondition.ts ├── .editorconfig ├── nodemon.json ├── .env.example ├── .gitignore ├── pages ├── index.tsx ├── _document.tsx ├── tierlist.tsx ├── _app.tsx └── view.tsx ├── Dockerfile ├── .circleci └── config.yml ├── tsconfig.server.json ├── server ├── api │ ├── characters.ts │ ├── save.ts │ ├── searchAnime.ts │ └── routes.ts ├── models │ └── savedList.ts ├── startup.ts └── index.ts ├── .babelrc ├── shared ├── http.ts ├── helpers.ts └── types.d.ts ├── README.md ├── tsconfig.json ├── next.config.js └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /components/Tierlist/ViewNavbar/style.scss: -------------------------------------------------------------------------------- 1 | .bar { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /static/cry.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/cry.mp4 -------------------------------------------------------------------------------- /static/mal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/mal.png -------------------------------------------------------------------------------- /static/anilist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/anilist.png -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/favicon.png -------------------------------------------------------------------------------- /static/hifumi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/hifumi.png -------------------------------------------------------------------------------- /static/kitsu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/kitsu.png -------------------------------------------------------------------------------- /static/waifu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/waifu.jpg -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.scss"; 2 | declare module "*.png"; 3 | declare module "next-ga"; 4 | -------------------------------------------------------------------------------- /layouts/_variables.scss: -------------------------------------------------------------------------------- 1 | $break-small: 320px; 2 | $break-large: 714px; 3 | $search-width: 300px; 4 | -------------------------------------------------------------------------------- /static/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/background.png -------------------------------------------------------------------------------- /static/waifu_large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/waifu_large.jpg -------------------------------------------------------------------------------- /static/favicon_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xetera/waifu-tierlist/HEAD/static/favicon_small.png -------------------------------------------------------------------------------- /components/Home/SavedLists/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../layouts/mixins"; 2 | .save { 3 | padding: 15px; 4 | } 5 | .label { 6 | font-size: 24px; 7 | } 8 | -------------------------------------------------------------------------------- /hooks/useCondition.ts: -------------------------------------------------------------------------------- 1 | export default (condition: boolean, hook: any) => { 2 | if (!condition) { 3 | return {}; 4 | } 5 | return hook(); 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["server/**/*.ts", "static/**/*"], 3 | "exec": "ts-node --project tsconfig.server.json server/index.ts", 4 | "signal": "SIGHUP" 5 | } 6 | -------------------------------------------------------------------------------- /layouts/PageWrapper/style.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | height: 100%; 3 | width: 100%; 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | } 8 | 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | WAIFU_TIERLIST_URL=http://localhost:3000 2 | WAIFU_TIERLIST_MONGODB_URL=your-cute-database-here 3 | # TIP: use mongodb atlas, it's free 4 | # https://www.mongodb.com/cloud/atlas 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # next.js build output 5 | .next 6 | 7 | # production server output 8 | dist 9 | .env 10 | .idea 11 | database.json 12 | .env 13 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Home } from "../components/Home/index"; 3 | import { PageWrapper } from "../layouts"; 4 | 5 | export default () => ( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /layouts/globalStyle.scss: -------------------------------------------------------------------------------- 1 | span, 2 | h1, 3 | h2, 4 | h3, 5 | h4, 6 | h5, 7 | h6, 8 | p, 9 | div { 10 | font-family: "Lato", "Roboto", sans-serif !important; 11 | } 12 | body { 13 | padding: 0; 14 | margin: 0; 15 | height: 100%; 16 | width: 100%; 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine 2 | 3 | WORKDIR /opt/app 4 | 5 | COPY package*.json ./ 6 | RUN npm install 7 | 8 | COPY . . 9 | ENV WAIFU_TIERLIST_URL=https://waifu.hifumi.io 10 | ARG DATABASE_URL 11 | ENV WAIFU_TIERLIST_DATABASE_URL=$DATABASE_URL 12 | RUN npm run build 13 | 14 | CMD npm start 15 | -------------------------------------------------------------------------------- /layouts/index.ts: -------------------------------------------------------------------------------- 1 | export { default as PageWrapper } from "./PageWrapper/PageWrapper"; 2 | export { default as Head } from "./Head/Head"; 3 | 4 | export interface HeadProps { 5 | readonly title?: string; 6 | readonly image?: string; 7 | readonly url?: string; 8 | readonly description?: string; 9 | } 10 | -------------------------------------------------------------------------------- /components/Tierlist/_variables.scss: -------------------------------------------------------------------------------- 1 | $tiers: ( 2 | tier-s: #6e44ff, 3 | tier-a: #b892ff, 4 | tier-b: #ffc2e2, 5 | tier-c: #ff90b3, 6 | tier-d: #ef7a85, 7 | tier-f: #e85f5c 8 | ); 9 | $tier-height-sm: 120px; 10 | $tier-height-lg: 150px; 11 | $character-width-sm: 75px; 12 | $character-width-lg: 105px; 13 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | docker: circleci/docker@0.5.0 4 | workflows: 5 | build-and-push: 6 | jobs: 7 | - docker/publish: 8 | tag: latest 9 | extra_build_args: "--build-arg WAIFU_TIERLIST_DATABASE_URL=$WAIFU_DATABASE_TIERLIST_URL" 10 | image: xetera/waifu-tierlist 11 | -------------------------------------------------------------------------------- /components/Tierlist/Notification/style.scss: -------------------------------------------------------------------------------- 1 | .notification { 2 | position: relative; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | font-weight: normal; 7 | color: #23231c; 8 | padding: 20px; 9 | max-width: 100%; 10 | background: #70dcd2; 11 | } 12 | 13 | .text { 14 | font-size: 14px; 15 | } 16 | -------------------------------------------------------------------------------- /layouts/_mixins.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | @mixin on-tablet { 4 | @media (min-width: $break-large) { 5 | @content 6 | } 7 | } 8 | 9 | @mixin on-desktop { 10 | @media (min-width: 916px) { 11 | @content 12 | } 13 | } 14 | 15 | @mixin before-tablet { 16 | @media (max-width: $break-large) { 17 | @content 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Waifu Tierlist", 3 | "name": "Waifu Tierlist", 4 | "start_url": "/", 5 | "background_color": "#3F51B5", 6 | "theme_color": "#2196f3", 7 | "display": "standalone", 8 | "icons": [ 9 | { 10 | "src": "/static/favicon.png", 11 | "type": "image/png", 12 | "sizes": "192x192" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "dist", 6 | "resolveJsonModule": true, 7 | "target": "es2017", 8 | "esModuleInterop": true, 9 | "isolatedModules": false, 10 | "noEmit": false 11 | }, 12 | "include": ["server/**/*.ts", "server/**/*.d.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /components/Tierlist/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Character } from "../../shared/types"; 2 | 3 | export type TierName = "S" | "A" | "B" | "C" | "D" | "F" | "Unranked"; 4 | 5 | export interface Tier { 6 | readonly draggable?: boolean; 7 | readonly total: number; 8 | readonly name: TierName; 9 | readonly characters: Character[]; 10 | readonly className?: string; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /server/api/characters.ts: -------------------------------------------------------------------------------- 1 | import J from "jikants"; 2 | import { Character } from "../../shared/types"; 3 | 4 | export const getAnimeCharacters = (id: string | number): Promise => 5 | J.Anime.charactersStaff(Number(id)).then(res => { 6 | if (!res) { 7 | return []; 8 | } 9 | return res.characters.map(({ voice_actors, ...rest }) => rest); 10 | }); 11 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "development": { 4 | "presets": ["next/babel", "@zeit/next-typescript/babel"] 5 | }, 6 | "production": { 7 | "presets": ["next/babel", "@zeit/next-typescript/babel"] 8 | }, 9 | "test": { 10 | "presets": [["next/babel", { "preset-env": { "modules": "commonjs" } }], "@zeit/next-typescript/babel"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /components/Home/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SearchResult } from "./SearchResult/SearchResult" 2 | export { default as SearchBar } from "./SearchBar/SearchBar" 3 | export { default as SavedLists } from "./SavedLists/SavedLists" 4 | export { default as Home } from "./Home" 5 | 6 | export interface Save { 7 | readonly name: string; 8 | readonly url: string; 9 | readonly image: string; 10 | } 11 | -------------------------------------------------------------------------------- /components/Tierlist/Notification/Notification.tsx: -------------------------------------------------------------------------------- 1 | import css from "./style.scss"; 2 | import * as React from "react"; 3 | 4 | const Notification = ({ children }: React.PropsWithChildren<{}>) => { 5 | return ( 6 |
9 |
{children}
10 |
11 | ); 12 | }; 13 | 14 | export default Notification; 15 | -------------------------------------------------------------------------------- /components/Tierlist/style.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "../../layouts/mixins"; 3 | 4 | .container { 5 | display: flex; 6 | flex-direction: column; 7 | width: 100vw; 8 | max-height: 100vh; 9 | } 10 | 11 | .scroller { 12 | height: 100%; 13 | overflow-y: auto; 14 | } 15 | 16 | .unranked { 17 | height: 60%; 18 | overflow-y: auto; 19 | } 20 | 21 | .thin { 22 | font-weight: lighter; 23 | } 24 | -------------------------------------------------------------------------------- /components/Home/SearchBar/style.scss: -------------------------------------------------------------------------------- 1 | .searchBar { 2 | height: 34px; 3 | width: 100%; 4 | padding: 5px; 5 | display: flex; 6 | align-items: center; 7 | border-radius: 3px; 8 | background: white; 9 | } 10 | 11 | .input { 12 | display: flex; 13 | align-items: center; 14 | font-weight: 300; 15 | width: 100%; 16 | } 17 | 18 | .icon { 19 | width: 26px !important; 20 | height: 26px !important; 21 | margin: 0 8px; 22 | } 23 | -------------------------------------------------------------------------------- /layouts/PageWrapper/PageWrapper.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import css from "./style.scss"; 3 | import "../globalStyle.scss"; 4 | import { Head, HeadProps } from ".."; 5 | 6 | export default ({ children, ...all }: React.PropsWithChildren) => ( 7 | <> 8 | 9 |
{children}
10 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /components/Tierlist/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as DraggableCharacter 3 | } from "./DraggableCharacter/DraggableCharacter"; 4 | export { default as TierlistView } from "./Tierlist"; 5 | export { default as Tier } from "./Tier/Tier"; 6 | export { default as Navbar } from "./Navbar/Navbar"; 7 | export { default as Notification } from "./Notification/Notification"; 8 | export { default as ViewNavbar } from "./ViewNavbar/ViewNavbar"; 9 | 10 | export const types = { 11 | CHARACTER: "character" 12 | }; 13 | -------------------------------------------------------------------------------- /components/Home/Title/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../layouts/mixins"; 2 | 3 | .title { 4 | text-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); 5 | color: #ffffff; 6 | z-index: 3; 7 | padding: 45px; 8 | text-align: center; 9 | } 10 | 11 | .text { 12 | line-height: 1.2; 13 | font-size: 52px !important; 14 | @include on-tablet { 15 | font-size: 62px !important; 16 | } 17 | @media (min-width: 900px) { 18 | font-size: 84px !important; 19 | } 20 | @media (min-width: 1200px) { 21 | font-size: 104px !important; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /components/Home/Results/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../layouts/variables"; 2 | @import "../../../layouts/mixins"; 3 | 4 | .container { 5 | margin: 10px 0; 6 | display: flex; 7 | width: 100%; 8 | flex-direction: column; 9 | } 10 | 11 | .label { 12 | color: #3F3E3E; 13 | font-size: 14px; 14 | } 15 | 16 | .labels { 17 | margin: 0 auto !important; 18 | padding: 5px 0; 19 | display: flex; 20 | flex-direction: row; 21 | justify-content: space-between; 22 | width: 100%; 23 | @include on-tablet { 24 | width: 80%; 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /shared/http.ts: -------------------------------------------------------------------------------- 1 | import "isomorphic-fetch"; 2 | 3 | export const get = (url: string, opts = {}) => { 4 | const isRelative = url.startsWith("/"); 5 | const endpoint = isRelative ? `${process.env.API_URL}${url}` : url; 6 | return fetch(endpoint, opts).then(r => r.json()); 7 | }; 8 | 9 | export const endpoints = { 10 | searchAnime: (id: string) => `/mal/search/${id}`, 11 | searchCharacters: (id: string) => `/mal/characters/${id}`, 12 | view: (id: string) => `/view/${id}`, 13 | save: `/save`, 14 | lookupSave: (id: string) => `/lookup/${id}` 15 | }; 16 | -------------------------------------------------------------------------------- /components/Home/Title/Title.tsx: -------------------------------------------------------------------------------- 1 | import Typography from "@material-ui/core/Typography"; 2 | import Box from "@material-ui/core/Box"; 3 | import css from "./style.scss" 4 | 5 | export const Title = () => { 6 | return ( 7 | 8 | 15 | Waifu Tierlist 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default Title; 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Waifu-Tierlist 2 | 3 | Rank the waifus in your favorite anime and share it with your friends! 4 | 5 | ![m](https://mamamoo.xetera.dev/%F0%9F%91%84%F0%9F%98%91%F0%9F%A4%B6%F0%9F%91%8D%F0%9F%91%A4%E2%98%A0.png) 6 | 7 | ![s](https://mamamoo.xetera.dev/%F0%9F%91%A8%F0%9F%98%8F%F0%9F%98%AE%F0%9F%A4%A7%E2%98%BA%F0%9F%91%B3.png) 8 | 9 | ![b](https://mamamoo.xetera.dev/%F0%9F%98%B4%E2%9B%91%F0%9F%98%A5%F0%9F%A7%9D%F0%9F%98%BE%F0%9F%91%8E.png) 10 | 11 | ![](https://mamamoo.xetera.dev/%F0%9F%98%AF%F0%9F%A4%98%F0%9F%A7%95%F0%9F%A5%BA%F0%9F%91%89%F0%9F%96%95.png) 12 | 13 | ## Development build 14 | 1. run `npm install` 15 | 2. copy `.env.example` to `.env` 16 | 3. run `npm run dev` 17 | 18 | ## Production build 19 | 1. run `npm install` 20 | 1. run `npm run build` 21 | 2. run `npm start` 22 | 23 | -------------------------------------------------------------------------------- /server/models/savedList.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema, Model, model } from "mongoose"; 2 | import { TierlistState } from "../../shared/types"; 3 | import shortid from "shortid"; 4 | 5 | export interface ISavedList extends Document { 6 | name: string; 7 | characters: TierlistState; 8 | animeId: string; 9 | animeName: string; 10 | url: string; 11 | } 12 | 13 | export const SavedListSchema: Schema = new Schema( 14 | { 15 | name: String, 16 | characters: Object, 17 | animeId: Number, 18 | animeName: String, 19 | url: { 20 | type: String, 21 | default: shortid.generate 22 | } 23 | }, 24 | { 25 | timestamps: true 26 | } 27 | ); 28 | 29 | export const SavedList: Model = model( 30 | "SavedList", 31 | SavedListSchema 32 | ); 33 | -------------------------------------------------------------------------------- /components/Home/Results/Results.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import css from "./style.scss"; 3 | import Typography from "@material-ui/core/Typography"; 4 | import Box from "@material-ui/core/Box"; 5 | 6 | interface Props { 7 | readonly right?: string; 8 | readonly left?: string; 9 | readonly className?: string; 10 | } 11 | 12 | const Results = ({ 13 | children, 14 | right, 15 | left, 16 | className 17 | }: React.PropsWithChildren) => { 18 | return ( 19 |
20 | {left && right && ( 21 | 22 | {left && {left}} 23 | {right && {right}} 24 | 25 | )} 26 |
{children}
27 |
28 | ); 29 | }; 30 | 31 | export default Results; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "jsx": "preserve", 6 | "lib": ["dom", "es2017", "es2018.promise"], 7 | "baseUrl": ".", 8 | "resolveJsonModule": true, 9 | "moduleResolution": "node", 10 | "strict": true, 11 | "allowJs": true, 12 | "noEmit": true, 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "isolatedModules": true, 19 | "removeComments": false, 20 | "preserveConstEnums": true, 21 | "sourceMap": true, 22 | "typeRoots": [ 23 | "./shared" 24 | ] 25 | }, 26 | "include": [ 27 | "./shared/types.d.ts", 28 | "./globals.d.ts", 29 | "node_modules/@types/node/globals.d.ts" 30 | ], 31 | "exclude": ["dist", ".next", "out", "next.config.js"] 32 | } 33 | -------------------------------------------------------------------------------- /shared/helpers.ts: -------------------------------------------------------------------------------- 1 | export const muuris: any = {}; 2 | 3 | export const MAX_CHAR_COUNT = 32; 4 | 5 | export const withToggle = async ( 6 | state: () => any, 7 | func: (b: boolean) => any 8 | ) => { 9 | func(true); 10 | const result = await state(); 11 | func(false); 12 | return result; 13 | }; 14 | 15 | export const extractAnimeId = (text: string) => text.split("/").reverse()[0]; 16 | 17 | export const filterOne = ( 18 | pred: (item: T) => boolean, 19 | [head, ...tail]: T[] 20 | ): T[] => { 21 | if (pred(head)) { 22 | return [head, ...filterOne(pred, tail)]; 23 | } else { 24 | return tail; 25 | } 26 | }; 27 | 28 | export const mapObject = ( 29 | f: (value: V) => R, 30 | obj: Record 31 | ): Record => { 32 | return Object.entries(obj).reduce((previous, [key, value]) => { 33 | const newValue = f(value); 34 | return { 35 | ...previous, 36 | [key]: newValue 37 | }; 38 | }, {}); 39 | }; 40 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Main, NextScript, Head } from "next/document"; 2 | import { ServerStyleSheets } from "@material-ui/styles"; 3 | import * as React from "react"; 4 | 5 | class Html extends Document { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | ); 16 | } 17 | static async getInitialProps(ctx: any) { 18 | // I have absolutely no idea what's going on here 19 | const sheets = new ServerStyleSheets(); 20 | const originalRenderPage = ctx.renderPage; 21 | ctx.renderPage = () => 22 | originalRenderPage({ 23 | enhanceApp: (App: any) => (props: any) => 24 | sheets.collect() 25 | }); 26 | 27 | const initialProps = await Document.getInitialProps(ctx); 28 | return { 29 | ...initialProps, 30 | styles: sheets.getStyleElement() 31 | }; 32 | } 33 | } 34 | 35 | export default Html; 36 | -------------------------------------------------------------------------------- /components/Tierlist/StaticCharacter/StaticCharacter.tsx: -------------------------------------------------------------------------------- 1 | import css from "../DraggableCharacter/style.scss"; 2 | import Icon from "@material-ui/core/Icon"; 3 | import Tooltip from "@material-ui/core/Tooltip"; 4 | import Favorite from "@material-ui/icons/Favorite"; 5 | import { Character } from "../../../shared/types"; 6 | const StaticCharacter = ({ character }: { character: Character }) => { 7 | return ( 8 |
9 | {/**/} 10 | character 15 | {character.role === "Main" && ( 16 | 17 | 18 | 19 | 20 | 21 | )} 22 |
{character.name}
23 |
24 | ); 25 | }; 26 | 27 | export default StaticCharacter; 28 | -------------------------------------------------------------------------------- /server/startup.ts: -------------------------------------------------------------------------------- 1 | import { get } from "../shared/http"; 2 | import * as fs from "fs"; 3 | import mongoose from "mongoose"; 4 | 5 | const url = process.env.DB_URL || process.env.WAIFU_TIERLIST_MONGODB_URL; 6 | mongoose 7 | .connect(url || "missing mongodb url", { useNewUrlParser: true }) 8 | .catch(() => 9 | console.log( 10 | "> Unable to connect to mongodb, saving functionality will be disabled" 11 | ) 12 | ); 13 | 14 | const DATABASE_ENDPOINT = 15 | "https://github.com/manami-project/anime-offline-database/blob/master/anime-offline-database.json?raw=true"; 16 | const DOWNLOAD_LOCATION = "./database.json"; 17 | 18 | export const init = async () => { 19 | try { 20 | fs.statSync(DOWNLOAD_LOCATION); 21 | console.log("> database.json already exists, skipping download"); 22 | } catch (_) { 23 | console.log("> Downloading database.json"); 24 | const json = await get(DATABASE_ENDPOINT); 25 | fs.writeFileSync(DOWNLOAD_LOCATION, JSON.stringify(json)); 26 | console.log("> Download finished!"); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /components/Home/SavedLists/SavedLists.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Save } from "../index"; 3 | import Box from "@material-ui/core/Box"; 4 | import css from "./style.scss"; 5 | import Typography from "@material-ui/core/Typography"; 6 | import ListIcon from "@material-ui/icons/List"; 7 | import Card from "@material-ui/core/Card"; 8 | import CardContent from "@material-ui/core/CardContent"; 9 | import CardActions from "@material-ui/core/CardActions"; 10 | import IconButton from "@material-ui/core/IconButton"; 11 | 12 | const SavedLists = ({ save }: { save: Save }) => { 13 | return ( 14 | 15 | 16 | 17 | 18 | {save.name} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default SavedLists; 32 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withTypescript = require("@zeit/next-typescript"); 2 | const withSass = require("@zeit/next-sass"); 3 | const withOffline = require("next-offline"); 4 | const withImages = require("next-images"); 5 | const withBundleAnalyzer = require("@next/bundle-analyzer"); 6 | const { EnvironmentPlugin } = require("webpack"); 7 | const { config } = require("dotenv"); 8 | 9 | config(); 10 | 11 | const compose = (...fs) => x => fs.reduce((state, fs) => fs(state), x); 12 | 13 | const setup = config => compose( 14 | withImages, 15 | withTypescript, 16 | withBundleAnalyzer({ enabled: Boolean(process.env.ANALYZE) }), 17 | withSass, 18 | withOffline 19 | )(config); 20 | 21 | module.exports = setup({ 22 | cssModules: true, 23 | cssLoaderOptions: { 24 | importLoaders: 1, 25 | localIdentName: "[local]_[hash:base64:3]" 26 | }, 27 | postcssLoaderOptions: { 28 | autoprefixer: {} 29 | }, 30 | distDir: "dist", 31 | env: { 32 | API_URL: process.env.WAIFU_TIERLIST_URL, 33 | DB_URL: process.env.WAIFU_TIERLIST_MONGODB_URL 34 | }, 35 | webpack: config => { 36 | return config; 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /components/Tierlist/Navbar/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../layouts/mixins"; 2 | .divider { 3 | display: flex; 4 | flex-direction: row; 5 | justify-content: space-between; 6 | } 7 | 8 | .title { 9 | white-space: nowrap; 10 | max-width: 100%; 11 | text-overflow: ellipsis; 12 | overflow: hidden; 13 | font-size: 14px !important; 14 | @include on-tablet { 15 | font-size: 22px !important; 16 | } 17 | } 18 | 19 | .section { 20 | align-items: center; 21 | display: flex; 22 | flex-direction: row; 23 | } 24 | 25 | .left { 26 | @extend .section; 27 | // big hack, trying to not make the style 28 | // overflow into the button 29 | max-width: 45%; 30 | } 31 | 32 | .linkIcon { 33 | margin-left: 5px; 34 | } 35 | 36 | .linkText { 37 | font-size: 12px; 38 | word-break: break-word; 39 | @include on-tablet { 40 | font-size: 16px !important; 41 | } 42 | } 43 | 44 | .actionsOverride { 45 | padding: 16px 24px !important; 46 | } 47 | 48 | .dialogueBody { 49 | overflow-x: hidden; 50 | } 51 | 52 | 53 | .clipboardSlider { 54 | display: flex; 55 | flex-direction: row; 56 | align-items: center; 57 | } 58 | -------------------------------------------------------------------------------- /components/Tierlist/Tier/style.scss: -------------------------------------------------------------------------------- 1 | @import "../variables"; 2 | 3 | @import "../../../layouts/mixins"; 4 | 5 | .tier { 6 | display: flex; 7 | flex-direction: row; 8 | min-height: $tier-height-sm; 9 | @include on-tablet { 10 | min-height: $tier-height-lg; 11 | } 12 | border-bottom: 1px dimgrey solid; 13 | width: 100%; 14 | overflow: hidden; 15 | } 16 | 17 | .tierText { 18 | color: white; 19 | font-family: "Roboto", sans-serif; 20 | font-size: 24px; 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | width: 50px; 25 | @include on-tablet { 26 | width: 100px; 27 | } 28 | text-align: center; 29 | } 30 | 31 | .tierCharacters { 32 | position: relative; 33 | width: 100%; 34 | min-height: $tier-height-sm; 35 | @include on-tablet { 36 | min-height: $tier-height-lg; 37 | } 38 | } 39 | 40 | .static { 41 | display: grid; 42 | grid-template-columns: repeat(auto-fit, $character-width-sm); 43 | @include on-tablet { 44 | grid-template-columns: repeat(auto-fit, $character-width-lg); 45 | } 46 | } 47 | 48 | @each $rank, $color in $tiers { 49 | .#{$rank} { 50 | background-color: $color; 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | config(); 3 | 4 | import next from "next"; 5 | import express from "express"; 6 | import { init } from "./startup"; 7 | const port = parseInt(process.env.PORT || "3000", 10); 8 | const dev = process.env.NODE_ENV !== "production"; 9 | const app = next({ dev }); 10 | const handle = app.getRequestHandler(); 11 | 12 | const server = express(); 13 | server.use(express.json()); 14 | server.listen(port); 15 | 16 | app 17 | .prepare() 18 | .then(init) 19 | // importing async to allow init to check 20 | // database.json first 21 | .then(() => { 22 | // @ts-ignore 23 | server.use(require("./api/routes").default); 24 | server.get("/tierlist/:id", (req, res) => { 25 | return app.render(req, res, "/tierlist", req.params); 26 | }); 27 | server.get("/view/:id", (req, res) => { 28 | return app.render(req, res, "/view", req.params); 29 | }); 30 | server.get("*", (req, res) => handle(req, res)); 31 | 32 | // tslint:disable-next-line:no-console 33 | console.log( 34 | `> Server listening at http://localhost:${port} as ${ 35 | dev ? "development" : process.env.NODE_ENV 36 | }` 37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /components/Tierlist/DraggableCharacter/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../layouts/mixins"; 2 | @import "../variables"; 3 | .character { 4 | cursor: grab; 5 | height: 120px; 6 | width: $character-width-sm; 7 | max-width: $character-width-sm; 8 | min-width: $character-width-sm; 9 | overflow: hidden; 10 | @include on-tablet { 11 | width: $character-width-lg; 12 | max-width: $character-width-lg; 13 | min-width: $character-width-lg; 14 | height: 150px; 15 | } 16 | position: relative; 17 | border-bottom: 5px purple; 18 | } 19 | $name-padding-w: 10px; 20 | .name { 21 | text-align: center; 22 | display: inline-block; 23 | color: gainsboro; 24 | position: absolute; 25 | font-size: 10px; 26 | @include on-tablet { 27 | font-size: 12px; 28 | } 29 | bottom: 0; 30 | left: 0; 31 | // absolutely positioned things don't respect 32 | // automatic padding boundary 33 | width: calc(100% - 20px); 34 | padding: 5px 10px; 35 | background: rgba(0, 0, 0, 0.5); 36 | } 37 | 38 | .favorite { 39 | color: #ff90b3; 40 | font-size: 18px !important; 41 | position: absolute; 42 | top: 0; 43 | left: 0; 44 | margin: 5px; 45 | } 46 | 47 | .characterImage { 48 | object-fit: contain; 49 | width: 100%; 50 | } 51 | -------------------------------------------------------------------------------- /server/api/save.ts: -------------------------------------------------------------------------------- 1 | import { SavePayload } from "../../shared/types"; 2 | import { ISavedList, SavedList } from "../models/savedList"; 3 | import { getAnime } from "./searchAnime"; 4 | import { MAX_CHAR_COUNT } from "../../shared/helpers"; 5 | 6 | export const save = async ({ anime, characters, name }: SavePayload) => { 7 | try { 8 | console.log(`Saving tierlist from ${name}`); 9 | const animeResult = await getAnime(anime); 10 | if (!animeResult) { 11 | return Promise.reject("Invalid anime ID"); 12 | } 13 | const list = new SavedList({ 14 | animeName: animeResult.title, 15 | animeId: anime, 16 | name: name ? name.slice(0, MAX_CHAR_COUNT) : "", 17 | characters 18 | }); 19 | const { url } = await list.save(); 20 | return url; 21 | } catch (e) { 22 | console.error(`Error saving ${name}'s list`); 23 | console.error(characters); 24 | console.error(e); 25 | return Promise.reject("Could not save user's list"); 26 | } 27 | }; 28 | 29 | export const getSave = (url: string): Promise => 30 | SavedList.findOne({ 31 | url 32 | }).then(async res => { 33 | if (!res) { 34 | return Promise.reject("invalid url"); 35 | } 36 | return res; 37 | }); 38 | -------------------------------------------------------------------------------- /pages/tierlist.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { PageWrapper } from "../layouts"; 3 | import { endpoints, get } from "../shared/http"; 4 | import { 5 | Anime, 6 | Character, 7 | CharacterSearchResponse, 8 | InitialProps 9 | } from "../shared/types"; 10 | import Tierlist from "../components/Tierlist/Tierlist"; 11 | 12 | interface Props { 13 | readonly id: number; 14 | readonly characters: Character[]; 15 | readonly anime: Anime; 16 | } 17 | 18 | const TierlistView = ({ characters, anime, id }: Props) => { 19 | return ( 20 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | TierlistView.getInitialProps = async ({ query }: InitialProps) => { 34 | const { id } = query; 35 | const { characters, anime } = (await get( 36 | endpoints.searchCharacters(id) 37 | )) as CharacterSearchResponse; 38 | return { characters, anime, id }; 39 | }; 40 | export default TierlistView; 41 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import App, { AppProps, Container } from "next/app"; 2 | // @ts-ignore 3 | import withGA from "next-ga"; 4 | import * as React from "react"; 5 | import Router from "next/router"; 6 | 7 | class WaifuTierlist extends App { 8 | static async getInitialProps({ Component, ctx }: AppProps & { ctx: any }) { 9 | let pageProps = {}; 10 | 11 | if (Component.getInitialProps) { 12 | pageProps = await Component.getInitialProps(ctx); 13 | } 14 | 15 | return { pageProps }; 16 | } 17 | 18 | render() { 19 | const { Component, pageProps } = this.props; 20 | 21 | return ( 22 | 23 | 44 | 45 | 46 | ); 47 | } 48 | } 49 | 50 | export default withGA("UA-133545986-5", Router)(WaifuTierlist); 51 | -------------------------------------------------------------------------------- /components/Tierlist/DraggableCharacter/DraggableCharacter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Character } from "../../../shared/types"; 3 | import Favorite from "@material-ui/icons/Favorite"; 4 | import css from "./style.scss"; 5 | import Icon from "@material-ui/core/Icon"; 6 | import Tooltip from "@material-ui/core/Tooltip"; 7 | 8 | interface Props { 9 | readonly character: Character; 10 | } 11 | 12 | const DraggableCharacter = ({ character }: Props) => { 13 | return ( 14 |
18 |
19 |
20 | {/**/} 21 | character 26 | {character.role === "Main" && ( 27 | 28 | 29 | 30 | 31 | 32 | )} 33 |
{character.name}
34 |
35 |
36 |
37 | ); 38 | }; 39 | export default DraggableCharacter; 40 | -------------------------------------------------------------------------------- /components/Tierlist/CharacterHolder/style.scss: -------------------------------------------------------------------------------- 1 | @import "../variables"; 2 | @import "../../../layouts/mixins"; 3 | 4 | .tier { 5 | display: flex; 6 | width: 100%; 7 | flex-direction: column; 8 | border-bottom: 1px dimgrey solid; 9 | } 10 | 11 | .tierText { 12 | color: white; 13 | font-family: "Roboto", sans-serif; 14 | font-size: 24px; 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | width: 50px; 19 | @include on-tablet { 20 | width: 100px; 21 | } 22 | text-align: center; 23 | } 24 | 25 | .collapse { 26 | border-top: 1px solid #b2b2b2; 27 | max-height: 50vh; 28 | overflow-x: auto !important; 29 | } 30 | 31 | .swipeSection { 32 | @include before-tablet { 33 | &:before { 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | height: 50px; 38 | flex-direction: column; 39 | font-size: 12px; 40 | color: #3c3c3c; 41 | content: "Swipe here to scroll" 42 | } 43 | } 44 | } 45 | 46 | .tierCharacters { 47 | position: relative; 48 | //display: flex; 49 | min-height: $tier-height-sm; 50 | max-height: $tier-height-sm * 2; 51 | width: 90%; 52 | @include on-tablet { 53 | width: 100%; 54 | min-height: $tier-height-lg; 55 | max-height: $tier-height-lg * 2.5; 56 | } 57 | } 58 | 59 | .bottomDrawer { 60 | min-height: 56px; 61 | } 62 | -------------------------------------------------------------------------------- /components/Tierlist/ViewNavbar/ViewNavbar.tsx: -------------------------------------------------------------------------------- 1 | import AppBar from "@material-ui/core/AppBar"; 2 | import base from "../Navbar/style.scss"; 3 | import Toolbar from "@material-ui/core/Toolbar"; 4 | import IconButton from "@material-ui/core/IconButton"; 5 | import Typography from "@material-ui/core/Typography"; 6 | import * as React from "react"; 7 | import HomeIcon from "@material-ui/icons/Home" 8 | import Button from "@material-ui/core/Button"; 9 | 10 | interface ViewNavbarProps { 11 | readonly animeId: string; 12 | readonly title: string; 13 | } 14 | const ViewNavbar = ({ title, animeId }: ViewNavbarProps) => { 15 | return ( 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | {title} 24 | 25 |
26 |
27 | 34 |
35 |
36 |
37 | ); 38 | }; 39 | 40 | export default ViewNavbar; 41 | -------------------------------------------------------------------------------- /server/api/searchAnime.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore [not available during compile time] 2 | // import database from "../../database.json"; 3 | import Fuse from "fuse.js"; 4 | import { Anime } from "../../shared/types"; 5 | 6 | const IMAGE_RESPONSE_LIMIT = 20; 7 | const VALID_ANIMES = ["myanimelist"]; 8 | 9 | const isValidAnime = (name: string) => 10 | VALID_ANIMES.some(valid => name.toLowerCase().includes(valid)); 11 | 12 | const filterSearchable = (elems: Anime[]) => 13 | elems.filter(elem => elem.sources.some(isValidAnime)); 14 | 15 | // @ts-ignore 16 | const animes = import("../../database.json").then(database => 17 | // @ts-ignore 18 | filterSearchable(database.data) 19 | ); 20 | 21 | const fuse = animes.then( 22 | ams => 23 | new Fuse(ams, { 24 | shouldSort: true, 25 | threshold: 0.2, 26 | maxPatternLength: 32, 27 | keys: ["title", "synonyms"] 28 | }) 29 | ); 30 | 31 | export const searchAnime = async (query: string): Promise => { 32 | const fs = await fuse; 33 | const results = fs.search(query); 34 | return results.slice(0, IMAGE_RESPONSE_LIMIT); 35 | }; 36 | 37 | export const getAnime = async (id: string): Promise => { 38 | const pattern = new RegExp(`myanimelist.net/anime/${id}$`); 39 | const ams = await animes; 40 | return ams.find((anime: Anime) => 41 | anime.sources.some(source => pattern.test(source)) 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /shared/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "jikants/dist/src/interfaces/manga/Characters"; 2 | import { TierName } from "../components/Tierlist/types"; 3 | 4 | export interface Anime { 5 | sources: string[]; 6 | type: "TV" | "OVA" | "Music" | "Special" | "Movie" | "ONA"; 7 | title: string; 8 | picture: string; 9 | relations: string[]; 10 | thumbnail: string; 11 | episodes?: number; 12 | synonyms: string[]; 13 | } 14 | 15 | export interface AnimeDatabase { 16 | data: Anime[]; 17 | } 18 | 19 | export interface Character { 20 | readonly image_url: string; 21 | readonly mal_id: number; 22 | readonly name: string; 23 | readonly role: Role; 24 | readonly url: string; 25 | readonly tier?: TierName; 26 | } 27 | 28 | export interface CharacterSearchResponse { 29 | readonly characters: Character[]; 30 | readonly anime: Anime; 31 | } 32 | 33 | export interface SaveLookupResponse { 34 | readonly name: string; 35 | readonly url: string; 36 | readonly characters: Record; 37 | readonly anime: Anime; 38 | readonly animeId: string; 39 | } 40 | 41 | export type TierlistState = { [name in TierName]: T }; 42 | 43 | export interface SavePayload { 44 | readonly name?: string; 45 | readonly characters: TierlistState; 46 | readonly anime: string; 47 | } 48 | 49 | export interface InitialProps { 50 | readonly query: { 51 | readonly id: string; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /components/Home/SearchBar/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Input from "@material-ui/core/Input"; 3 | import InputAdornment from "@material-ui/core/InputAdornment"; 4 | import SearchIcon from "@material-ui/icons/Search"; 5 | import css from "./style.scss"; 6 | import Box from "@material-ui/core/Box"; 7 | import debounce from "lodash/debounce"; 8 | import { useEffect } from "react"; 9 | 10 | interface Props { 11 | readonly search: (anime: string) => void; 12 | } 13 | 14 | export default ({ search }: Props) => { 15 | const fireChange = (r: React.ChangeEvent) => { 16 | search(r.target.value) 17 | }; 18 | 19 | const debouncedChange = debounce(fireChange, 200); 20 | 21 | const onChange = (r: React.ChangeEvent) => { 22 | r.persist(); 23 | debouncedChange(r); 24 | }; 25 | 26 | useEffect(() => { 27 | return debouncedChange.cancel; 28 | }, []); 29 | 30 | return ( 31 | 32 | 41 | 42 | 43 | } 44 | /> 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /layouts/Head/Head.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Head from "next/head"; 3 | import { HeadProps } from "../index"; 4 | 5 | const defaults = { 6 | title: "Waifu Tierlist", 7 | url: "https://waifu.hifumi.io", 8 | description: 9 | "Waifu tierlist maker, generate tier lists of the characters in your favorite anime" 10 | }; 11 | 12 | export default ({ title, description, image, url }: HeadProps) => ( 13 | 14 | {title || defaults.title} 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {image && } 28 | {url || (!title && )} 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | -------------------------------------------------------------------------------- /components/Tierlist/Tier/Tier.tsx: -------------------------------------------------------------------------------- 1 | import { Tier as TierType } from "../types"; 2 | import css from "./style.scss"; 3 | import * as React from "react"; 4 | import { DraggableCharacter } from "../index"; 5 | import { Character } from "../../../shared/types"; 6 | import StaticCharacter from "../StaticCharacter/StaticCharacter"; 7 | import { muuris, Muuris } from "../../../shared/helpers"; 8 | import { Simulate } from "react-dom/test-utils"; 9 | 10 | const getColor = (tier: string) => css[`tier-${tier.toLowerCase()}`]; 11 | 12 | const Tier = ({ name, className, characters, draggable }: TierType) => { 13 | React.useEffect(() => { 14 | if (!draggable) { 15 | return; 16 | } 17 | const Muuri = require("muuri"); 18 | const grid = new Muuri(`.${name}`, { 19 | dragEnabled: true, 20 | dragContainer: document.body, 21 | dragSort: () => Object.values(muuris) 22 | }); 23 | muuris[name] = grid; 24 | }, []); 25 | return ( 26 |
27 | {name} 28 |
29 | {characters.map((char) => 30 | draggable ? ( 31 | 35 | ) : ( 36 | 37 | ) 38 | )} 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default Tier; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-server-typescript", 3 | "version": "1.0.0", 4 | "typings": "./shared/types.d.ts", 5 | "scripts": { 6 | "dev": "nodemon", 7 | "build": "next build && tsc --project tsconfig.server.json", 8 | "start": "cross-env NODE_ENV=production node dist/server/index.js" 9 | }, 10 | "dependencies": { 11 | "@material-ui/core": "^4.0.0-rc.0", 12 | "@material-ui/icons": "^4.1.0", 13 | "@next/bundle-analyzer": "^8.1.0", 14 | "@zeit/next-sass": "^1.0.1", 15 | "dotenv": "^8.0.0", 16 | "express": "^4.17.1", 17 | "fuse.js": "^3.4.5", 18 | "hammerjs": "^2.0.8", 19 | "isomorphic-fetch": "^2.2.1", 20 | "jikants": "^1.2.8", 21 | "lodash": "^4.17.21", 22 | "mongoose": "^5.7.5", 23 | "muuri": "^0.7.1", 24 | "next": "^11.1.0", 25 | "next-ga": "^2.3.4", 26 | "next-images": "^1.1.1", 27 | "next-offline": "^4.0.2", 28 | "node-sass": "^4.13.1", 29 | "react": "^16.8.6", 30 | "react-copy-to-clipboard": "^5.0.1", 31 | "react-dom": "^16.8.6", 32 | "react-github-corner": "^2.3.0", 33 | "shortid": "^2.2.14" 34 | }, 35 | "devDependencies": { 36 | "@types/dotenv": "^6.1.1", 37 | "@types/express": "^4.17.0", 38 | "@types/lodash": "^4.14.134", 39 | "@types/mongodb": "^3.1.28", 40 | "@types/mongoose": "^5.5.6", 41 | "@types/next": "^8.0.5", 42 | "@types/node": "^12.0.8", 43 | "@types/react": "^16.8.19", 44 | "@types/react-copy-to-clipboard": "^4.2.6", 45 | "@types/react-dom": "16.8.4", 46 | "@types/shortid": "0.0.29", 47 | "@zeit/next-typescript": "^1.1.1", 48 | "cross-env": "^5.2.0", 49 | "nodemon": "^1.19.0", 50 | "ts-node": "^8.1.0", 51 | "typescript": "^3.4.5" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /components/Home/SearchResult/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../../layouts/variables"; 2 | .thumbnailWrapper { 3 | display: flex; 4 | margin: 5px; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | 9 | .gray { 10 | color: dimgray; 11 | } 12 | 13 | .info { 14 | display: flex; 15 | padding: 10px; 16 | flex-direction: column; 17 | justify-content: space-between; 18 | } 19 | 20 | .top { 21 | max-height: 80%; 22 | display: flex; 23 | align-items: flex-start; 24 | justify-content: space-between; 25 | } 26 | 27 | .title { 28 | color: #3F3E3E; 29 | font-style: normal; 30 | width: 80%; 31 | text-overflow: ellipsis; 32 | overflow: hidden; 33 | font-size: 14px; 34 | font-weight: 300; 35 | } 36 | 37 | .type { 38 | font-weight: lighter; 39 | color: #6D6D6D; 40 | font-size: 12px; 41 | } 42 | 43 | .episodes { 44 | color: #6D6D6D; 45 | font-weight: normal; 46 | font-size: 12px; 47 | } 48 | 49 | .bottom { 50 | display: flex; 51 | flex-direction: row; 52 | justify-content: space-between; 53 | align-items: center; 54 | } 55 | 56 | .text { 57 | padding: 5px; 58 | display: flex; 59 | align-items: center; 60 | } 61 | 62 | .thumbnail { 63 | max-width: 100%; 64 | border-radius: 3px; 65 | } 66 | 67 | .container { 68 | min-height: 90px; 69 | max-height: 100%; 70 | display: grid; 71 | grid-template-columns: 70px auto; 72 | padding: 5px; 73 | width: 100%; 74 | } 75 | .resultWrapper { 76 | display: flex; 77 | align-items: center; 78 | } 79 | .icons a, 80 | .icons { 81 | display: flex; 82 | flex-direction: row; 83 | justify-content: center; 84 | align-items: center; 85 | } 86 | 87 | .icon { 88 | margin-left: 5px; 89 | border-radius: 3px; 90 | width: 22px; 91 | height: 22px; 92 | } 93 | -------------------------------------------------------------------------------- /pages/view.tsx: -------------------------------------------------------------------------------- 1 | import { PageWrapper } from "../layouts"; 2 | import { TIERS } from "../components/Tierlist/Tierlist"; 3 | import { endpoints, get } from "../shared/http"; 4 | import { InitialProps, SaveLookupResponse } from "../shared/types"; 5 | import { Tier, Notification, ViewNavbar } from "../components/Tierlist"; 6 | import * as React from "react"; 7 | import tierCss from "../components/Tierlist/style.scss"; 8 | import Typography from "@material-ui/core/Typography"; 9 | 10 | const View = ({ 11 | name, 12 | anime, 13 | characters, 14 | animeId, 15 | url 16 | }: SaveLookupResponse) => { 17 | return ( 18 | 24 |
25 | 26 |
27 | 28 | 29 | You are viewing{" "} 30 | {name ? ( 31 | 35 | {name} 36 | 37 | ) : ( 38 | "an anonymous user" 39 | )} 40 | 's tierlist 41 | 42 | 43 | {TIERS.map(tier => { 44 | const tierChars = characters[tier]; 45 | return ( 46 | {}} 53 | /> 54 | ); 55 | })} 56 |
57 |
58 |
59 | ); 60 | }; 61 | 62 | View.getInitialProps = async ({ query }: InitialProps) => { 63 | const { id } = query; 64 | const e = await get(endpoints.lookupSave(id)); 65 | return e; 66 | }; 67 | 68 | export default View; 69 | -------------------------------------------------------------------------------- /components/Tierlist/CharacterHolder/CharacterHolder.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Character } from "../../../shared/types"; 3 | import { DraggableCharacter } from "../index"; 4 | import { muuris } from "../../../shared/helpers"; 5 | import css from "./style.scss"; 6 | import Collapse from "@material-ui/core/Collapse"; 7 | import BottomNavigation from "@material-ui/core/BottomNavigation"; 8 | import BottomNavigationAction from "@material-ui/core/BottomNavigationAction"; 9 | import ArrowUp from "@material-ui/icons/KeyboardArrowUp"; 10 | import ArrowDown from "@material-ui/icons/KeyboardArrowDown"; 11 | 12 | interface CharacterHolder { 13 | readonly characters: Character[]; 14 | } 15 | 16 | /** 17 | * Yes I know this class a duplicate of Tier, but I can't 18 | * be bothered to extract the logic to a parent component 19 | * also because I don't know how that works with react 20 | * @param initialCharacters 21 | * @param update 22 | */ 23 | const CharacterHolder = ({ characters }: CharacterHolder) => { 24 | React.useEffect(() => { 25 | const Muuri = require("muuri"); 26 | const grid = new Muuri(".character-holder", { 27 | dragEnabled: true, 28 | layout: { 29 | horizontal: window.innerWidth < 614 30 | }, 31 | dragSortInterval: 150, 32 | dragContainer: document.body, 33 | dragSort: () => Object.values(muuris) 34 | }); 35 | window.addEventListener("load", () => { 36 | grid.refreshItems().layout(); 37 | }); 38 | muuris.Unranked = grid; 39 | }, []); 40 | const [isOpen, setOpen] = React.useState(true); 41 | 42 | return ( 43 |
44 | 45 |
46 |
47 | {characters.map((char) => ( 48 | 52 | ))} 53 |
54 |
55 |
56 | 57 | : } 60 | onClick={() => setOpen(!isOpen)} 61 | /> 62 | 63 |
64 | ); 65 | }; 66 | export default CharacterHolder; 67 | -------------------------------------------------------------------------------- /components/Tierlist/Tierlist.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar, Tier } from "."; 2 | import * as React from "react"; 3 | import css from "./style.scss"; 4 | import CharacterHolder from "./CharacterHolder/CharacterHolder"; 5 | import { TierName } from "./types"; 6 | import { Anime, Character, TierlistState } from "../../shared/types"; 7 | import { endpoints } from "../../shared/http"; 8 | import { extractAnimeId, mapObject, muuris } from "../../shared/helpers"; 9 | 10 | export const TIERS: TierName[] = ["S", "A", "B", "C", "D", "F"]; 11 | interface Props { 12 | readonly draggable: boolean; 13 | readonly characters: Character[]; 14 | readonly anime: Anime; 15 | } 16 | 17 | const initialState: TierlistState = { 18 | S: [], 19 | A: [], 20 | B: [], 21 | C: [], 22 | D: [], 23 | F: [], 24 | Unranked: [] 25 | }; 26 | 27 | const Tierlist = ({ characters, anime, draggable = true }: Props) => { 28 | const [ranks, setRank] = React.useState( 29 | characters.reduce((all, char) => { 30 | const tier = char.tier || "Unranked"; 31 | const currentState = all[tier]; 32 | return { 33 | ...all, 34 | [tier]: [...currentState, char] 35 | }; 36 | }, initialState) 37 | ); 38 | 39 | const save = async (name: string) => { 40 | // this can't fail 41 | const animeSource = anime.sources.find(source => 42 | source.includes("myanimelist") 43 | )!; 44 | const characters = mapObject( 45 | (muuri: any) => muuri._items.map((char: any) => Number(char._element.dataset.id)), 46 | muuris 47 | ); 48 | 49 | const req = await fetch(endpoints.save, { 50 | method: "POST", 51 | headers: { 52 | "Content-Type": "application/json" 53 | }, 54 | body: JSON.stringify({ 55 | name, 56 | characters, 57 | // we can't send anything tha 58 | anime: extractAnimeId(animeSource) 59 | }) 60 | }); 61 | const res = await req.json(); 62 | return res; 63 | }; 64 | 65 | const makeTier = (tier: TierName, characters: Character[]) => ( 66 | 73 | ); 74 | 75 | return ( 76 |
77 | 78 |
79 | {TIERS.map(tier => makeTier(tier, ranks[tier]))} 80 |
81 | 82 |
83 | ); 84 | }; 85 | 86 | export default Tierlist; 87 | -------------------------------------------------------------------------------- /components/Home/SearchResult/SearchResult.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import css from "./style.scss"; 3 | import Paper from "@material-ui/core/Paper"; 4 | import { Anime } from "../../../shared/types"; 5 | import Typography from "@material-ui/core/Typography"; 6 | import Box from "@material-ui/core/Box"; 7 | import Link from "next/link"; 8 | import { extractAnimeId } from "../../../shared/helpers"; 9 | 10 | interface Props { 11 | readonly anime: Anime; 12 | } 13 | 14 | const mappings = [ 15 | ["myanimelist.net", "/static/mal.png"], 16 | ["anilist.co", "/static/anilist.png"], 17 | ["kitsu.io", "/static/kitsu.png"] 18 | ]; 19 | 20 | const toIconElem = (link: string) => { 21 | const found = mappings.find(([fragment]) => link.includes(fragment)); 22 | if (!found) { 23 | return null; 24 | } 25 | const [, href] = found; 26 | 27 | return ( 28 | 29 | icon 30 | 31 | ); 32 | }; 33 | const generateLinkUrl = (anime: Anime) => { 34 | const mal = anime.sources.find(source => 35 | source.includes("myanimelist") 36 | ) as string; 37 | return `tierlist/${extractAnimeId(mal)}`; 38 | }; 39 | 40 | export default ({ anime }: Props) => { 41 | return ( 42 | 43 | 44 |
45 | 46 |
47 |
48 | 49 | {anime.title} 50 | {anime.type} 51 | 52 | 53 | 54 | {anime.episodes || "Unknown"} episode 55 | {anime.episodes ? anime.episodes > 1 && "s" : ""} 56 | 57 |
58 | {anime.sources.reduce( 59 | (all, source) => { 60 | const result = toIconElem(source); 61 | if (!result) { 62 | return all; 63 | } 64 | return [...all, result]; 65 | }, 66 | [] as JSX.Element[] 67 | )} 68 |
69 |
70 |
71 |
72 |
73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /components/Home/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../layouts/variables"; 2 | @import "../../layouts/mixins"; 3 | 4 | @mixin top-height { 5 | height: 40vh; 6 | min-height: 200px; 7 | max-height: 570px; 8 | @media (min-width: 900px) { 9 | background-position: 0 45%; 10 | } 11 | } 12 | 13 | .container { 14 | a { 15 | text-decoration: none; 16 | } 17 | display: flex; 18 | flex-direction: column; 19 | height: 100%; 20 | width: 100%; 21 | } 22 | 23 | .thumbnail { 24 | max-width: 50px; 25 | min-height: 50px; 26 | } 27 | 28 | .searchResult { 29 | display: flex; 30 | justify-content: center; 31 | } 32 | 33 | .noSearch { 34 | grid-template-columns: 1fr; 35 | } 36 | 37 | .searchPrompt { 38 | display: flex; 39 | flex-direction: column; 40 | margin: 0 auto; 41 | padding: 0 10px; 42 | } 43 | 44 | $gradient: linear-gradient( 45 | 180deg, 46 | rgba(171, 154, 154, 0.3) 0%, 47 | rgba(0, 0, 0, 0.3) 100% 48 | ); 49 | .banner { 50 | @include top-height; 51 | background-position: center; 52 | margin: 0 auto; 53 | background-size: cover; 54 | background-image: $gradient, url(/static/waifu.jpg); 55 | @media (min-width: 900px) { 56 | background-image: $gradient, url(/static/waifu_large.jpg); 57 | max-width: 1920px; 58 | } 59 | top: 0; 60 | position: absolute; 61 | width: 100%; 62 | } 63 | 64 | .hifumiImage { 65 | margin: 0 auto; 66 | } 67 | 68 | $content-padding: 20px; 69 | .content { 70 | z-index: 3; 71 | margin-top: -22px; 72 | display: flex; 73 | flex-direction: column; 74 | align-items: center; 75 | justify-content: center; 76 | padding: 0 $content-padding; 77 | @include on-tablet { 78 | padding: 0 20px; 79 | } 80 | @include on-desktop { 81 | padding: 0 15%; 82 | } 83 | } 84 | 85 | .top { 86 | @include top-height; 87 | display: flex; 88 | justify-content: center; 89 | position: relative; 90 | width: 100%; 91 | } 92 | 93 | .topContent { 94 | position: absolute; 95 | top: 0; 96 | width: 100%; 97 | height: 100%; 98 | display: flex; 99 | flex: 1; 100 | flex-direction: column; 101 | justify-content: space-evenly; 102 | } 103 | 104 | .github { 105 | z-index: 3; 106 | } 107 | 108 | .divider { 109 | margin: 15px 0; 110 | } 111 | 112 | .customButton { 113 | font-weight: 400 !important; 114 | width: 100%; 115 | height: 44px; 116 | //background: #ffffff !important; 117 | } 118 | 119 | .noResultContainer { 120 | display: flex; 121 | flex-direction: column; 122 | align-items: center; 123 | } 124 | 125 | .noResults { 126 | width: 100%; 127 | @include on-tablet { 128 | width: 60%; 129 | } 130 | } 131 | 132 | .gridResult { 133 | display: grid; 134 | justify-content: center; 135 | grid-template-columns: repeat(auto-fill, $search-width); 136 | grid-gap: 0.7rem; 137 | width: 100%; 138 | margin: 10px 0; 139 | } 140 | 141 | .disclaimer { 142 | margin-top: 10px; 143 | text-align: left; 144 | width: 100%; 145 | } 146 | 147 | .searchCover { 148 | height: 100%; 149 | } 150 | -------------------------------------------------------------------------------- /server/api/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { getAnime, searchAnime } from "./searchAnime"; 3 | import { Request, Response } from "express"; 4 | import { getAnimeCharacters } from "./characters"; 5 | import { endpoints } from "../../shared/http"; 6 | import { 7 | CharacterSearchResponse, 8 | SaveLookupResponse, 9 | SavePayload 10 | } from "../../shared/types"; 11 | import { getSave, save } from "./save"; 12 | import { mapObject } from "../../shared/helpers"; 13 | 14 | /** 15 | * Helper wrapper object around express routes for 16 | * dealing with single param actions 17 | * @param param 18 | */ 19 | const withParam = (param: string) => { 20 | return (f: (param: string) => object | Promise) => { 21 | return async (req: Request, res: Response) => { 22 | try { 23 | const target = req.params[param]; 24 | const out = await f(target); 25 | return res.send(out); 26 | } catch (err) { 27 | res.status(500); 28 | return res.send(err); 29 | } 30 | }; 31 | }; 32 | }; 33 | 34 | const router = Router(); 35 | 36 | const sendAnime = withParam("anime"); 37 | 38 | router.get(endpoints.searchAnime(":anime"), sendAnime(searchAnime)); 39 | router.get( 40 | endpoints.searchCharacters(":anime"), 41 | sendAnime( 42 | async (id: string): Promise => { 43 | const anime = await getAnime(id); 44 | if (!anime) { 45 | return Promise.reject({ error: "anime not found" }); 46 | } 47 | const resp = await getAnimeCharacters(id); 48 | return { 49 | characters: resp || [], 50 | anime 51 | }; 52 | } 53 | ) 54 | ); 55 | 56 | router.post(endpoints.save, async (req, res) => { 57 | const requiredFields = ["anime", "characters"]; 58 | const missingField = requiredFields.some(field => !req.body[field]); 59 | if (missingField) { 60 | return res 61 | .status(400) 62 | .send({ error: `${missingField} is missing from the body` }); 63 | } 64 | const payload = req.body as SavePayload; 65 | const url = await save(payload); 66 | res.send({ url }); 67 | }); 68 | 69 | const sendSave = withParam("saveId"); 70 | 71 | router.get( 72 | endpoints.lookupSave(":saveId"), 73 | sendSave( 74 | async (url): Promise => { 75 | try { 76 | const { name, animeId, characters } = await getSave(url); 77 | const rawCharacters = await getAnimeCharacters(animeId); 78 | const anime = await getAnime(animeId); 79 | if (!anime) { 80 | return Promise.reject({ 81 | error: `Anime recorded as ${animeId} on a save could not be found in the database` 82 | }); 83 | } 84 | const updatedCharacters = mapObject( 85 | chars => 86 | chars.map( 87 | char => 88 | rawCharacters.find(raw => { 89 | console.log(raw); 90 | return Number(raw.mal_id) === char; 91 | })! 92 | ), 93 | characters 94 | ); 95 | return { 96 | url, 97 | name, 98 | characters: updatedCharacters, 99 | anime: anime, 100 | animeId 101 | }; 102 | } catch (e) { 103 | console.error(e); 104 | return Promise.reject({ error: "Invalid id" }); 105 | } 106 | } 107 | ) 108 | ); 109 | 110 | export default router; 111 | -------------------------------------------------------------------------------- /components/Home/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import css from "./style.scss"; 3 | import { SavedLists, SearchBar, SearchResult } from "."; 4 | import { extractAnimeId, withToggle } from "../../shared/helpers"; 5 | import { get } from "../../shared/http"; 6 | import Typography from "@material-ui/core/Typography"; 7 | import { Anime } from "../../shared/types"; 8 | import Link from "next/link"; 9 | import ReactGithubCorner from "react-github-corner"; 10 | import Title from "./Title/Title"; 11 | import Results from "./Results/Results"; 12 | import GithubCorner from "react-github-corner"; 13 | import Button from "@material-ui/core/Button"; 14 | import Box from "@material-ui/core/Box"; 15 | 16 | const NoResults = () => ( 17 | 18 | 19 | Could find any results for that :( 20 | 21 | 23 | ); 24 | 25 | export default () => { 26 | const [animes, setAnimes] = React.useState([]); 27 | const [search, setSearch] = React.useState(""); 28 | const [loading, setLoading] = React.useState(false); 29 | const [saved, setSaved] = React.useState([]); 30 | 31 | React.useEffect(() => { 32 | setSaved(JSON.parse(localStorage.getItem("saves") || "[]")); 33 | }, []); 34 | 35 | const onNewAnime = async (value: string) => { 36 | setSearch(value); 37 | if (value === "") { 38 | return setAnimes([]); 39 | } 40 | const response = await withToggle( 41 | () => get(`/mal/search/${value}`), 42 | setLoading 43 | ); 44 | setAnimes(response); 45 | }; 46 | 47 | const isSearchEmpty = search === ""; 48 | const hasResults = animes.length > 0; 49 | 50 | const animeResults = animes.map(anime => ( 51 | // {/**/} 52 | 53 | // 54 | )); 55 | 56 | return ( 57 |
58 | 63 |
64 |
65 |
66 | 67 | </div> 68 | </div> 69 | <div className={css.content}> 70 | <SearchBar search={onNewAnime} /> 71 | {/*/!* TODO: fix this bs *!/*/} 72 | {!hasResults && isSearchEmpty && !loading ? ( 73 | <> 74 | <Box className={css.divider}>or</Box> 75 | <Button 76 | variant="outlined" 77 | className={css.customButton} 78 | disabled={true} 79 | href="/custom" 80 | color="default" 81 | > 82 | Rank your favorite waifus 83 | </Button> 84 | <Typography color="textSecondary" className={css.disclaimer}> 85 | <Box style={{ marginTop: "20px" }}> 86 | Custom waifu-ranking feature coming very soon! 87 | </Box> 88 | </Typography> 89 | {/*<Results*/} 90 | {/* className={css.gridResult}*/} 91 | {/* left="Your previous lists"*/} 92 | {/* right={`${saved.length} saves`}*/} 93 | {/*>*/} 94 | {/* {saved.map(save => (*/} 95 | {/* <SavedLists save={save}/>*/} 96 | {/* ))}*/} 97 | {/*</Results>*/} 98 | </> 99 | ) : !loading && !hasResults ? ( 100 | <Results> 101 | <NoResults /> 102 | </Results> 103 | ) : ( 104 | <Results 105 | right={`${animeResults.length || "No"} results`} 106 | left="Animes found" 107 | className={css.gridResult} 108 | > 109 | {animeResults} 110 | </Results> 111 | )} 112 | </div> 113 | </div> 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /components/Tierlist/Navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import AppBar from "@material-ui/core/AppBar"; 2 | import Toolbar from "@material-ui/core/Toolbar"; 3 | import HomeIcon from "@material-ui/icons/Home"; 4 | import IconButton from "@material-ui/core/IconButton"; 5 | import Typography from "@material-ui/core/Typography"; 6 | import css from "./style.scss"; 7 | import Dialog from "@material-ui/core/Dialog"; 8 | import * as React from "react"; 9 | import DialogTitle from "@material-ui/core/DialogTitle"; 10 | import Slide from "@material-ui/core/Slide"; 11 | import { TransitionProps } from "react-transition-group/Transition"; 12 | import DialogContentText from "@material-ui/core/DialogContentText"; 13 | import DialogContent from "@material-ui/core/DialogContent"; 14 | import Button from "@material-ui/core/Button"; 15 | import TextField from "@material-ui/core/TextField"; 16 | import DialogActions from "@material-ui/core/DialogActions"; 17 | import LinkIcon from "@material-ui/icons/Link"; 18 | import CircularProgress from "@material-ui/core/CircularProgress"; 19 | import { withToggle } from "../../../shared/helpers"; 20 | import { endpoints } from "../../../shared/http"; 21 | import Link from "next/link"; 22 | import FileCopy from "@material-ui/icons/FileCopy"; 23 | import { CopyToClipboard } from "react-copy-to-clipboard"; 24 | 25 | const Transition = React.forwardRef<unknown, TransitionProps>((props, ref) => { 26 | return <Slide direction="up" ref={ref} {...props} />; 27 | }); 28 | 29 | interface Props { 30 | readonly save: (name: string) => void; 31 | readonly title: string; 32 | } 33 | 34 | export default ({ title, save }: Props) => { 35 | const [open, setOpen] = React.useState(false); 36 | const [name, setName] = React.useState(""); 37 | const [waiting, setWaiting] = React.useState(false); 38 | const [completed, setCompleted] = React.useState({ 39 | slideOut: false, 40 | slideIn: false, 41 | url: "" 42 | }); 43 | 44 | const updateName = (evt: React.ChangeEvent<HTMLInputElement>) => { 45 | setName(evt.target.value); 46 | }; 47 | 48 | const triggerSave = async () => { 49 | setWaiting(true); 50 | const response = await withToggle(() => save(name), setWaiting); 51 | setCompleted(prev => ({ 52 | ...prev, 53 | slideOut: true, 54 | url: `${process.env.API_URL}${endpoints.view(response.url)}` 55 | })); 56 | setTimeout(() => setCompleted(prev => ({ ...prev, slideIn: true })), 200); 57 | }; 58 | 59 | return ( 60 | <AppBar position="static" color="default"> 61 | <Dialog 62 | open={open} 63 | TransitionComponent={Transition as any} 64 | onClose={() => setOpen(false)} 65 | > 66 | <DialogTitle>Save & Share</DialogTitle> 67 | <DialogContent className={css.dialogueBody}> 68 | <DialogContentText> 69 | Save and share your current list with your friends. 70 | </DialogContentText> 71 | {!completed.slideIn && ( 72 | <Slide direction="right" in={!completed.slideOut}> 73 | <TextField 74 | label="Username" 75 | variant="outlined" 76 | inputProps={{ 77 | maxLength: 32 78 | }} 79 | autoFocus 80 | fullWidth={true} 81 | onChange={updateName} 82 | /> 83 | </Slide> 84 | )} 85 | {completed.slideIn && ( 86 | <Slide direction="left" in={completed.slideIn}> 87 | <div className={css.clipboardSlider}> 88 | <CopyToClipboard text={completed.url}> 89 | <IconButton> 90 | <FileCopy /> 91 | </IconButton> 92 | </CopyToClipboard> 93 | <Link> 94 | <a href={completed.url} className={css.linkText}> 95 | {completed.url} 96 | </a> 97 | </Link> 98 | </div> 99 | </Slide> 100 | )} 101 | </DialogContent> 102 | <DialogActions className={css.actionsOverride}> 103 | <Button 104 | variant="contained" 105 | color="primary" 106 | size="medium" 107 | onClick={triggerSave} 108 | disabled={waiting || completed.slideOut || completed.slideIn} 109 | > 110 | Get link 111 | {!waiting && <LinkIcon className={css.linkIcon} />} 112 | {waiting && <CircularProgress size={20} className={css.linkIcon} />} 113 | </Button> 114 | </DialogActions> 115 | </Dialog> 116 | <Toolbar className={css.divider}> 117 | <div className={[css.section, css.left].join(" ")}> 118 | <IconButton edge="start" aria-label="Back" href="/"> 119 | <HomeIcon /> 120 | </IconButton> 121 | <Typography variant="h6" className={css.title}> 122 | {title} 123 | </Typography> 124 | </div> 125 | <div className={css.section}> 126 | <Button 127 | color="inherit" 128 | href="#" 129 | variant="outlined" 130 | onClick={() => setOpen(true)} 131 | > 132 | Save & Share 133 | </Button> 134 | </div> 135 | </Toolbar> 136 | </AppBar> 137 | ); 138 | }; 139 | --------------------------------------------------------------------------------