├── .nvmrc
├── Procfile
├── .gitignore
├── src
├── pages
│ ├── entry
│ │ ├── index.js
│ │ ├── Entry.css
│ │ └── Entry.jsx
│ ├── blog_home
│ │ ├── index.js
│ │ ├── BlogHome.css
│ │ └── BlogHome.jsx
│ ├── entrance
│ │ ├── index.js
│ │ ├── Entrance.css
│ │ └── Entrance.jsx
│ ├── not_found
│ │ ├── index.js
│ │ ├── NotFound.jsx
│ │ └── NotFound.css
│ └── pages.css
├── foundation
│ ├── components
│ │ ├── Main
│ │ │ ├── index.js
│ │ │ ├── Main.css
│ │ │ └── Main.jsx
│ │ ├── GlobalHeader
│ │ │ ├── index.js
│ │ │ ├── GlobalHeader.jsx
│ │ │ └── GlobalHeader.css
│ │ ├── ProportionalImage
│ │ │ ├── index.js
│ │ │ ├── ProportionalImage.jsx
│ │ │ └── ProportionalImage.css
│ │ └── foundation_components.css
│ ├── polyfills.js
│ ├── styles
│ │ ├── utils.css
│ │ ├── web_fonts.css
│ │ ├── vars.css
│ │ └── global.css
│ ├── flux
│ │ ├── store.js
│ │ └── reducers.js
│ ├── root.jsx
│ ├── render.jsx
│ ├── routes.jsx
│ └── gateway.js
├── domains
│ ├── blog
│ │ ├── blog.css
│ │ ├── components
│ │ │ └── BlogHeader
│ │ │ │ ├── index.js
│ │ │ │ ├── BlogHeader.jsx
│ │ │ │ └── BlogHeader.css
│ │ ├── blog_actions.js
│ │ └── blog_reducer.js
│ ├── blog_list
│ │ ├── components
│ │ │ ├── BlogCard
│ │ │ │ ├── index.js
│ │ │ │ ├── BlogCard.jsx
│ │ │ │ └── BlogCard.css
│ │ │ └── BlogCardList
│ │ │ │ ├── index.js
│ │ │ │ ├── BlogCardList.jsx
│ │ │ │ └── BlogCardList.css
│ │ ├── blog_list.css
│ │ ├── blog_list_actions.js
│ │ └── blog_list_reducer.js
│ ├── entry
│ │ ├── components
│ │ │ ├── EntryView
│ │ │ │ ├── index.js
│ │ │ │ ├── EntryView.css
│ │ │ │ └── EntryView.jsx
│ │ │ ├── EntryFooter
│ │ │ │ ├── index.js
│ │ │ │ ├── EntryFooter.css
│ │ │ │ └── EntryFooter.jsx
│ │ │ ├── EntryHeader
│ │ │ │ ├── index.js
│ │ │ │ ├── EntryHeader.css
│ │ │ │ └── EntryHeader.jsx
│ │ │ ├── AmidaLikeButton
│ │ │ │ ├── index.js
│ │ │ │ ├── AmidaLikeButton.jsx
│ │ │ │ └── AmidaLikeButton.css
│ │ │ ├── FacebookShareButton
│ │ │ │ ├── index.js
│ │ │ │ └── FacebookShareButton.jsx
│ │ │ ├── TwitterShareButton
│ │ │ │ ├── index.js
│ │ │ │ └── TwitterShareButton.jsx
│ │ │ └── HatenaBookmarkButton
│ │ │ │ ├── index.js
│ │ │ │ └── HatenaBookmarkButton.jsx
│ │ ├── entry.css
│ │ ├── entry_reducer.js
│ │ └── entry_actions.js
│ ├── entry_list
│ │ ├── entry_list.css
│ │ ├── components
│ │ │ └── EntryList
│ │ │ │ ├── index.js
│ │ │ │ ├── EntryList.css
│ │ │ │ └── EntryList.jsx
│ │ ├── entry_list_reducer.js
│ │ └── entry_list_actions.js
│ ├── comment_list
│ │ ├── components
│ │ │ ├── CommentList
│ │ │ │ ├── index.js
│ │ │ │ ├── CommentList.css
│ │ │ │ └── CommentList.jsx
│ │ │ └── CommentListItem
│ │ │ │ ├── index.js
│ │ │ │ ├── CommentListItem.jsx
│ │ │ │ └── CommentListItem.css
│ │ ├── comment_list.css
│ │ ├── comment_list_reducer.js
│ │ └── comment_list_actions.js
│ ├── domains.css
│ └── error
│ │ ├── error_actions.js
│ │ └── error_reducer.js
├── assets
│ ├── amida.png
│ ├── amida2.png
│ └── budda.gif
├── app.css
├── app.js
└── index.html
├── .prettierrc
├── .babelrc
├── lib
├── utils.js
├── server.js
├── controller
│ ├── spa.js
│ └── api.js
├── model
│ └── payload.js
└── api
│ └── blog.js
├── postcss.config.js
├── webpack.config.js
├── README.md
└── package.json
/.nvmrc:
--------------------------------------------------------------------------------
1 | 13.11.0
2 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: babel-node lib/server.js
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 |
4 | .DS_Store
5 |
--------------------------------------------------------------------------------
/src/pages/entry/index.js:
--------------------------------------------------------------------------------
1 | export { Entry } from './Entry';
2 |
--------------------------------------------------------------------------------
/src/pages/blog_home/index.js:
--------------------------------------------------------------------------------
1 | export { BlogHome } from './BlogHome';
2 |
--------------------------------------------------------------------------------
/src/pages/entrance/index.js:
--------------------------------------------------------------------------------
1 | export { Entrance } from './Entrance';
2 |
--------------------------------------------------------------------------------
/src/pages/not_found/index.js:
--------------------------------------------------------------------------------
1 | export { NotFound } from './NotFound';
2 |
--------------------------------------------------------------------------------
/src/foundation/components/Main/index.js:
--------------------------------------------------------------------------------
1 | export { Main } from './Main';
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/src/domains/blog/blog.css:
--------------------------------------------------------------------------------
1 | @import './components/BlogHeader/BlogHeader.css';
2 |
--------------------------------------------------------------------------------
/src/domains/blog_list/components/BlogCard/index.js:
--------------------------------------------------------------------------------
1 | export { BlogCard } from './BlogCard';
2 |
--------------------------------------------------------------------------------
/src/domains/entry/components/EntryView/index.js:
--------------------------------------------------------------------------------
1 | export { EntryView } from './EntryView';
2 |
--------------------------------------------------------------------------------
/src/domains/entry_list/entry_list.css:
--------------------------------------------------------------------------------
1 | @import './components/EntryList/EntryList.css';
2 |
--------------------------------------------------------------------------------
/src/foundation/polyfills.js:
--------------------------------------------------------------------------------
1 | import 'core-js';
2 | import 'regenerator-runtime/runtime';
3 |
--------------------------------------------------------------------------------
/src/domains/blog/components/BlogHeader/index.js:
--------------------------------------------------------------------------------
1 | export { BlogHeader } from './BlogHeader';
2 |
--------------------------------------------------------------------------------
/src/domains/entry/components/EntryFooter/index.js:
--------------------------------------------------------------------------------
1 | export { EntryFooter } from './EntryFooter';
2 |
--------------------------------------------------------------------------------
/src/domains/entry/components/EntryHeader/index.js:
--------------------------------------------------------------------------------
1 | export { EntryHeader } from './EntryHeader';
2 |
--------------------------------------------------------------------------------
/src/domains/entry_list/components/EntryList/index.js:
--------------------------------------------------------------------------------
1 | export { EntryList } from './EntryList';
2 |
--------------------------------------------------------------------------------
/src/foundation/components/GlobalHeader/index.js:
--------------------------------------------------------------------------------
1 | export { GlobalHeader } from './GlobalHeader';
2 |
--------------------------------------------------------------------------------
/src/domains/blog_list/components/BlogCardList/index.js:
--------------------------------------------------------------------------------
1 | export { BlogCardList } from './BlogCardList';
2 |
--------------------------------------------------------------------------------
/src/domains/comment_list/components/CommentList/index.js:
--------------------------------------------------------------------------------
1 | export { CommentList } from './CommentList';
2 |
--------------------------------------------------------------------------------
/src/domains/entry/components/AmidaLikeButton/index.js:
--------------------------------------------------------------------------------
1 | export { AmidaLikeButton } from './AmidaLikeButton';
2 |
--------------------------------------------------------------------------------
/src/foundation/components/ProportionalImage/index.js:
--------------------------------------------------------------------------------
1 | export { ProportionalImage } from './ProportionalImage';
2 |
--------------------------------------------------------------------------------
/src/pages/blog_home/BlogHome.css:
--------------------------------------------------------------------------------
1 | .BlogHome__entry-list-title {
2 | margin: calc(var(--space) * 4) 0;
3 | }
4 |
--------------------------------------------------------------------------------
/src/assets/amida.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2020/HEAD/src/assets/amida.png
--------------------------------------------------------------------------------
/src/assets/amida2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2020/HEAD/src/assets/amida2.png
--------------------------------------------------------------------------------
/src/assets/budda.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2020/HEAD/src/assets/budda.gif
--------------------------------------------------------------------------------
/src/domains/comment_list/components/CommentListItem/index.js:
--------------------------------------------------------------------------------
1 | export { CommentListItem } from './CommentListItem';
2 |
--------------------------------------------------------------------------------
/src/domains/entry/components/FacebookShareButton/index.js:
--------------------------------------------------------------------------------
1 | export { FacebookShareButton } from './FacebookShareButton';
2 |
--------------------------------------------------------------------------------
/src/domains/entry/components/TwitterShareButton/index.js:
--------------------------------------------------------------------------------
1 | export { TwitterShareButton } from './TwitterShareButton';
2 |
--------------------------------------------------------------------------------
/src/domains/entry/components/HatenaBookmarkButton/index.js:
--------------------------------------------------------------------------------
1 | export { HatenaBookmarkButton } from './HatenaBookmarkButton';
2 |
--------------------------------------------------------------------------------
/src/foundation/components/Main/Main.css:
--------------------------------------------------------------------------------
1 | .foundation-Main {
2 | margin: 0 auto calc(var(--space) * 5);
3 | width: 80%;
4 | }
5 |
--------------------------------------------------------------------------------
/src/domains/blog_list/blog_list.css:
--------------------------------------------------------------------------------
1 | @import './components/BlogCardList/BlogCardList.css';
2 | @import './components/BlogCard/BlogCard.css';
3 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-react"],
3 | "plugins": ["@babel/plugin-transform-modules-commonjs"]
4 | }
5 |
--------------------------------------------------------------------------------
/src/domains/comment_list/comment_list.css:
--------------------------------------------------------------------------------
1 | @import './components/CommentList/CommentList.css';
2 | @import './components/CommentListItem/CommentListItem.css';
3 |
--------------------------------------------------------------------------------
/src/domains/comment_list/components/CommentList/CommentList.css:
--------------------------------------------------------------------------------
1 | .comment-CommentList__item:not(:last-child) {
2 | margin-bottom: calc(var(--space) * 5);
3 | }
4 |
--------------------------------------------------------------------------------
/src/pages/pages.css:
--------------------------------------------------------------------------------
1 | @import './entrance/Entrance.css';
2 | @import './blog_home/BlogHome.css';
3 | @import './entry/Entry.css';
4 | @import './not_found/NotFound.css';
5 |
--------------------------------------------------------------------------------
/src/foundation/components/Main/Main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export function Main({ children }) {
4 | return {children};
5 | }
6 |
--------------------------------------------------------------------------------
/src/foundation/components/foundation_components.css:
--------------------------------------------------------------------------------
1 | @import './Main/Main.css';
2 | @import './GlobalHeader/GlobalHeader.css';
3 | @import './ProportionalImage/ProportionalImage.css';
4 |
--------------------------------------------------------------------------------
/src/foundation/styles/utils.css:
--------------------------------------------------------------------------------
1 | @import 'suitcss-utils';
2 | @import '@zendeskgarden/css-utilities';
3 | @import '@zendeskgarden/css-buttons';
4 | @import '@zendeskgarden/css-forms';
5 |
--------------------------------------------------------------------------------
/lib/utils.js:
--------------------------------------------------------------------------------
1 | export function asyncWrap(handler) {
2 | return async (req, res, next) => {
3 | try {
4 | await handler(req, res)
5 | } catch (e) {
6 | next(e);
7 | }
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/src/domains/domains.css:
--------------------------------------------------------------------------------
1 | @import './blog_list/blog_list.css';
2 | @import './blog/blog.css';
3 | @import './entry_list/entry_list.css';
4 | @import './entry/entry.css';
5 | @import './comment_list/comment_list.css';
6 |
--------------------------------------------------------------------------------
/src/domains/error/error_actions.js:
--------------------------------------------------------------------------------
1 | export const ACTION_ERROR_NOT_FOUND = 'NOT_FOUND';
2 |
3 | export async function renderNotFound({ dispatch }) {
4 | dispatch({
5 | type: ACTION_ERROR_NOT_FOUND,
6 | });
7 | }
8 |
--------------------------------------------------------------------------------
/src/foundation/styles/web_fonts.css:
--------------------------------------------------------------------------------
1 | @import 'https://fonts.googleapis.com/css?family=Baloo+Thambi+2:400,500,600,700,800&display=swap';
2 | @import 'https://fonts.googleapis.com/css?family=M+PLUS+Rounded+1c:400,500,600,700,800&display=swap';
3 |
--------------------------------------------------------------------------------
/src/foundation/flux/store.js:
--------------------------------------------------------------------------------
1 | import { createStore } from 'redux';
2 |
3 | import { rootReducer } from './reducers';
4 |
5 | export function configureStore() {
6 | const store = createStore(rootReducer);
7 |
8 | return store;
9 | }
10 |
--------------------------------------------------------------------------------
/src/domains/entry/entry.css:
--------------------------------------------------------------------------------
1 | @import './components/EntryHeader/EntryHeader.css';
2 | @import './components/EntryView/EntryView.css';
3 | @import './components/AmidaLikeButton/AmidaLikeButton.css';
4 | @import './components/EntryFooter/EntryFooter.css';
5 |
--------------------------------------------------------------------------------
/src/app.css:
--------------------------------------------------------------------------------
1 | @import './foundation/styles/vars.css';
2 | @import './foundation/styles/global.css';
3 | @import './foundation/styles/web_fonts.css';
4 | @import './foundation/styles/utils.css';
5 | @import './foundation/components/foundation_components.css';
6 | @import './domains/domains.css';
7 | @import './pages/pages.css';
8 |
--------------------------------------------------------------------------------
/lib/server.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { apiController } from './controller/api';
3 | import { spaController } from './controller/spa';
4 |
5 | const PORT = process.env.PORT || 3000;
6 | const app = express();
7 |
8 | app.use(express.static('dist'));
9 | app.use(apiController);
10 | app.use(spaController);
11 |
12 | app.listen(PORT);
13 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import './foundation/polyfills';
2 |
3 | import { render } from './foundation/render';
4 | import { setupMockAPIData } from './foundation/gateway';
5 |
6 | function init() {
7 | if (process.env.USE_MOCK_DATA === 'true') {
8 | setupMockAPIData();
9 | }
10 |
11 | render();
12 | }
13 |
14 | window.onload = () => {
15 | init();
16 | };
17 |
--------------------------------------------------------------------------------
/src/pages/entry/Entry.css:
--------------------------------------------------------------------------------
1 | .Entry__header {
2 | margin: calc(var(--space) * 5) 0 calc(var(--space) * 2);
3 | }
4 |
5 | .Entry__footer {
6 | margin-top: calc(var(--space) * 4);
7 | }
8 |
9 | .Entry__comment-list {
10 | margin-top: calc(var(--space) * 5);
11 | }
12 |
13 | .Entry__comment-list-header {
14 | margin-bottom: calc(var(--space) * 4);
15 | }
16 |
--------------------------------------------------------------------------------
/src/foundation/root.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter as Router } from 'react-router-dom';
3 |
4 | import { GlobalHeader } from './components/GlobalHeader';
5 | import { Routes } from './routes';
6 |
7 | export function Root() {
8 | return (
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/lib/controller/spa.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import express from 'express';
4 |
5 | export const spaController = express.Router();
6 |
7 | spaController.all('*', (_req, res) => {
8 | const result = fs.readFileSync(
9 | path.resolve(__dirname, '..', '..', 'dist', 'index.html'),
10 | 'utf-8',
11 | );
12 | res.send(result);
13 | });
14 |
--------------------------------------------------------------------------------
/src/domains/blog/blog_actions.js:
--------------------------------------------------------------------------------
1 | import { fetch } from '../../foundation/gateway';
2 |
3 | export const ACTION_BLOG_FETCHED = 'BLOG_FETCHED';
4 |
5 | export async function fetchBlog({ dispatch, blogId }) {
6 | const blog = await fetch(`/api/blog/${blogId}`);
7 |
8 | dispatch({
9 | type: ACTION_BLOG_FETCHED,
10 | data: {
11 | blog,
12 | },
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/src/domains/blog/blog_reducer.js:
--------------------------------------------------------------------------------
1 | import { Map, fromJS } from 'immutable';
2 | import { ACTION_BLOG_FETCHED } from './blog_actions';
3 |
4 | export function blogReducer(state = Map(), action) {
5 | switch (action.type) {
6 | case ACTION_BLOG_FETCHED: {
7 | return fromJS(action.data.blog);
8 | }
9 |
10 | default: {
11 | return state;
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/domains/blog_list/blog_list_actions.js:
--------------------------------------------------------------------------------
1 | import { fetch } from '../../foundation/gateway';
2 |
3 | export const ACTION_BLOG_LIST_FETCHED = 'BLOG_LIST_FETCHED';
4 |
5 | export async function fetchBlogList({ dispatch }) {
6 | const blogs = await fetch(`/api/blogs`);
7 |
8 | dispatch({
9 | type: ACTION_BLOG_LIST_FETCHED,
10 | data: {
11 | blogs,
12 | },
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/src/domains/error/error_reducer.js:
--------------------------------------------------------------------------------
1 | import { Map } from 'immutable';
2 | import { ACTION_ERROR_NOT_FOUND } from './error_actions';
3 |
4 | export function errorReducer(state = Map(), action) {
5 | switch (action.type) {
6 | case ACTION_ERROR_NOT_FOUND: {
7 | return state.set('error', action.type);
8 | }
9 |
10 | default: {
11 | return state;
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/domains/blog_list/blog_list_reducer.js:
--------------------------------------------------------------------------------
1 | import { List, fromJS } from 'immutable';
2 | import { ACTION_BLOG_LIST_FETCHED } from './blog_list_actions';
3 |
4 | export function blogListReducer(state = List(), action) {
5 | switch (action.type) {
6 | case ACTION_BLOG_LIST_FETCHED: {
7 | return fromJS(action.data.blogs);
8 | }
9 |
10 | default: {
11 | return state;
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/domains/entry_list/entry_list_reducer.js:
--------------------------------------------------------------------------------
1 | import { fromJS, List } from 'immutable';
2 | import { ACTION_ENTRY_LIST_FETCHED } from './entry_list_actions';
3 |
4 | export function entryListReducer(state = List(), action) {
5 | switch (action.type) {
6 | case ACTION_ENTRY_LIST_FETCHED: {
7 | return fromJS(action.data.entries);
8 | }
9 |
10 | default: {
11 | return state;
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/domains/entry_list/entry_list_actions.js:
--------------------------------------------------------------------------------
1 | import { fetch } from '../../foundation/gateway';
2 |
3 | export const ACTION_ENTRY_LIST_FETCHED = 'ENTRY_LIST_FETCHED';
4 |
5 | export async function fetchEntryList({ dispatch, blogId }) {
6 | const entries = await fetch(`/api/blog/${blogId}/entries`);
7 |
8 | dispatch({
9 | type: ACTION_ENTRY_LIST_FETCHED,
10 | data: {
11 | entries,
12 | },
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/src/domains/comment_list/comment_list_reducer.js:
--------------------------------------------------------------------------------
1 | import { fromJS, List } from 'immutable';
2 | import { ACTION_COMMENT_LIST_FETCHED } from './comment_list_actions';
3 |
4 | export function commentListReducer(state = List(), action) {
5 | switch (action.type) {
6 | case ACTION_COMMENT_LIST_FETCHED: {
7 | return fromJS(action.data.comments);
8 | }
9 |
10 | default: {
11 | return state;
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/domains/comment_list/comment_list_actions.js:
--------------------------------------------------------------------------------
1 | import { fetch } from '../../foundation/gateway';
2 |
3 | export const ACTION_COMMENT_LIST_FETCHED = 'COMMENT_LIST_FETCHED';
4 |
5 | export async function fetchCommentList({ dispatch, blogId, entryId }) {
6 | const comments = await fetch(`/api/blog/${blogId}/entry/${entryId}/comments`);
7 |
8 | dispatch({
9 | type: ACTION_COMMENT_LIST_FETCHED,
10 | data: {
11 | comments,
12 | },
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 |
5 | const importPlugin = require('postcss-import');
6 | const autoprefixer = require('autoprefixer');
7 | const customProperties = require('postcss-custom-properties');
8 |
9 | module.exports = {
10 | plugins: [
11 | importPlugin({
12 | root: path.resolve(__dirname, 'src'),
13 | }),
14 |
15 | autoprefixer(),
16 |
17 | customProperties(),
18 | ],
19 |
20 | map: true,
21 | };
22 |
--------------------------------------------------------------------------------
/src/domains/comment_list/components/CommentList/CommentList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import _ from 'lodash';
3 |
4 | import { CommentListItem } from '../CommentListItem';
5 |
6 | export function CommentList({ list }) {
7 | return (
8 |
9 | {_.map(list, (comment, i) => (
10 | -
11 |
12 |
13 | ))}
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/foundation/components/GlobalHeader/GlobalHeader.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | export function GlobalHeader() {
5 | return (
6 |
7 |
8 | Amida Blog:
9 | あみぶろ
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/foundation/render.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import $ from 'jquery';
5 |
6 | import { Root } from './root';
7 | import { configureStore } from './flux/store';
8 |
9 | export function render() {
10 | const root = $('#root').get()[0];
11 | const store = configureStore();
12 |
13 | ReactDOM.render(
14 |
15 |
16 | ,
17 | root,
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/foundation/styles/vars.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --space: 4px;
3 |
4 | --font-size-title: 30px;
5 |
6 | --font-size-l: 16px;
7 | --font-size-m: 14px;
8 | --font-size-s: 12px;
9 |
10 | --font-color-regular: black;
11 |
12 | -font-family: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN',
13 | 'Hiragino Sans', Meiryo, sans-serif;
14 |
15 | --line-height-default: 1;
16 | --line-height-paragraph: 1.7;
17 |
18 | --border: 1px #ddd solid;
19 |
20 | --height-global-header: 44px;
21 | }
22 |
--------------------------------------------------------------------------------
/src/domains/entry/entry_reducer.js:
--------------------------------------------------------------------------------
1 | import { fromJS, Map } from 'immutable';
2 | import { ACTION_ENTRY_FETCHED, ACTION_LIKE_UPDATED } from './entry_actions';
3 |
4 | export function entryReducer(state = Map(), action) {
5 | switch (action.type) {
6 | case ACTION_ENTRY_FETCHED: {
7 | return fromJS(action.data.entry);
8 | }
9 |
10 | case ACTION_LIKE_UPDATED: {
11 | return state.set('like_count', action.data.likeCount);
12 | }
13 |
14 | default: {
15 | return state;
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/domains/entry/components/AmidaLikeButton/AmidaLikeButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3 | import { faThumbsUp } from '@fortawesome/free-solid-svg-icons';
4 |
5 | export function AmidaLikeButton({ likeCount, onClick }) {
6 | return (
7 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/domains/entry/components/EntryHeader/EntryHeader.css:
--------------------------------------------------------------------------------
1 | .entry-EntryHeader {
2 | padding-bottom: calc(var(--space) * 3);
3 | border-bottom: var(--border);
4 | display: flex;
5 | flex-wrap: nowrap;
6 | justify-content: space-between;
7 | align-items: flex-end;
8 | }
9 |
10 | .entry-EntryHeader__title-link {
11 | color: black;
12 | text-decoration: none;
13 | }
14 |
15 | .entry-EntryHeader__heading-link {
16 | color: black;
17 | text-decoration: none;
18 | }
19 |
20 | .entry-EntryHeader__published {
21 | flex-shrink: 0;
22 | }
23 |
24 | .entry-EntryHeader__published-at {
25 | flex-shrink: 0;
26 | }
27 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= htmlWebpackPlugin.options.title %>
8 | <% for (var chunk in htmlWebpackPlugin.files.chunks) { %>
9 |
13 | <% } %>
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/foundation/styles/global.css:
--------------------------------------------------------------------------------
1 | @import 'normalize.css';
2 |
3 | * {
4 | box-sizing: border-box;
5 | }
6 |
7 | *,
8 | *::before,
9 | *::after {
10 | margin: 0;
11 | overflow-wrap: break-word;
12 | padding: 0;
13 | word-wrap: break-word;
14 | }
15 |
16 | body {
17 | background-color: var(--bg-regular);
18 | font-family: var(--font-family);
19 | font-size: var(--font-size-m);
20 | -webkit-font-smoothing: antialiased;
21 | -moz-osx-font-smoothing: grayscale;
22 | line-height: var(--line-height-default);
23 | }
24 |
25 | li {
26 | list-style: none;
27 | }
28 |
29 | button {
30 | border: none;
31 | border-radius: 0;
32 | color: inherit;
33 | }
34 |
--------------------------------------------------------------------------------
/src/domains/entry/components/AmidaLikeButton/AmidaLikeButton.css:
--------------------------------------------------------------------------------
1 | .entry-AmidaLikeButton {
2 | border: var(--border);
3 | border-radius: 3px;
4 | padding: 0 calc(var(--space) * 2);
5 | display: flex;
6 | align-items: center;
7 | justify-content: center;
8 | height: 20px;
9 | }
10 |
11 | .entry-AmidaLikeButton_inner {
12 | border: var(--border);
13 | border-radius: 3px;
14 | padding: 0 calc(var(--space) * 2);
15 | display: flex;
16 | align-items: center;
17 | justify-content: center;
18 | height: 20px;
19 | }
20 |
21 | .entry-AmidaLikeButton__count {
22 | margin-left: var(--space);
23 | }
24 |
25 | .entry-AmidaLikeButton__like-count {
26 | margin-left: var(--space);
27 | }
28 |
--------------------------------------------------------------------------------
/src/domains/blog_list/components/BlogCard/BlogCard.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | import { ProportionalImage } from '../../../../foundation/components/ProportionalImage';
5 |
6 | export function BlogCard({ blog }) {
7 | return (
8 |
9 |
17 | {blog.nickname}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/domains/entry/components/TwitterShareButton/TwitterShareButton.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import $ from 'jquery';
3 |
4 | const TWITTER_SDK = 'https://platform.twitter.com/widgets.js';
5 |
6 | export function TwitterShareButton() {
7 | useEffect(() => {
8 | const script$ = $(``).appendTo('body');
9 |
10 | return () => {
11 | script$.remove();
12 | };
13 | }, []);
14 |
15 | return (
16 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/foundation/components/GlobalHeader/GlobalHeader.css:
--------------------------------------------------------------------------------
1 | .foundation-GlobalHeader {
2 | height: var(--height-global-header);
3 | display: flex;
4 | flex-direction: row;
5 | align-items: center;
6 | padding: 0 calc(var(--space) * 2);
7 | }
8 |
9 | .foundation-GlobalHeader__link {
10 | color: black;
11 | text-decoration: none;
12 | display: flex;
13 | flex-direction: row;
14 | align-items: center;
15 | }
16 |
17 | .foundation-GlobalHeader__en {
18 | font-family: 'Baloo Thambi 2', cursive;
19 | font-weight: bold;
20 | flex-shrink: 0;
21 | }
22 |
23 | .foundation-GlobalHeader__ja {
24 | font-family: 'M PLUS Rounded 1c', sans-serif;
25 | font-weight: bold;
26 | flex-shrink: 0;
27 | }
28 |
--------------------------------------------------------------------------------
/lib/model/payload.js:
--------------------------------------------------------------------------------
1 | function createId(n) {
2 | const c = [];
3 | const len = n * 1000;
4 | for (let i = 0; i < len; i++) {
5 | c.push[i];
6 | }
7 | const result = c.sort((a, b) => a - b).join(',');
8 | return result;
9 | }
10 |
11 | export class Payload {
12 | constructor(source) {
13 | this.data = JSON.stringify(source.data);
14 | }
15 |
16 | toResponse() {
17 | try {
18 | const data = JSON.parse(this.data);
19 | const id = createId(Math.floor(Math.random() * this.data.length));
20 | return {
21 | data,
22 | id,
23 | };
24 | } catch (e) {
25 | console.error('Failed to parse Payload to Response', e);
26 | throw new Error(e);
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/foundation/components/ProportionalImage/ProportionalImage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | export function ProportionalImage({
5 | boxAspectRatio,
6 | roundedAsCardThumbnail,
7 | ...imageProps
8 | }) {
9 | return (
10 |
16 |
17 |
![]()
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/domains/entry/components/EntryFooter/EntryFooter.css:
--------------------------------------------------------------------------------
1 | .entry-EntryFooter {
2 | border-top: var(--border);
3 | padding-top: calc(var(--space) * 4);
4 | display: flex;
5 | flex-direction: row;
6 | justify-content: space-between;
7 | }
8 |
9 | .entry-EntryFooter__sns {
10 | margin: 0 0 calc(var(--space) * 2);
11 | display: flex;
12 | flex-direction: row;
13 | justify-content: flex-end;
14 | }
15 |
16 | .entry-EntryFooter__sns-item {
17 | margin-left: calc(var(--space) * 2);
18 | }
19 |
20 | .entry-EntryFooter__share {
21 | margin: 0 0 calc(var(--space) * 2);
22 | display: flex;
23 | flex-direction: row;
24 | justify-content: flex-end;
25 | }
26 |
27 | .entry-EntryFooter__share-item {
28 | margin-left: calc(var(--space) * 2);
29 | }
30 |
--------------------------------------------------------------------------------
/src/foundation/flux/reducers.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | import { errorReducer } from '../../domains/error/error_reducer';
4 | import { blogListReducer } from '../../domains/blog_list/blog_list_reducer';
5 | import { blogReducer } from '../../domains/blog/blog_reducer';
6 | import { entryListReducer } from '../../domains/entry_list/entry_list_reducer';
7 | import { entryReducer } from '../../domains/entry/entry_reducer';
8 | import { commentListReducer } from '../../domains/comment_list/comment_list_reducer';
9 |
10 | export const rootReducer = combineReducers({
11 | error: errorReducer,
12 | blogList: blogListReducer,
13 | blog: blogReducer,
14 | entryList: entryListReducer,
15 | entry: entryReducer,
16 | commentList: commentListReducer,
17 | });
18 |
--------------------------------------------------------------------------------
/src/pages/not_found/NotFound.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Main } from '../../foundation/components/Main';
4 |
5 | import BuddaImage from '../../assets/budda.gif';
6 |
7 | export function NotFound() {
8 | return (
9 |
10 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/domains/entry/components/EntryHeader/EntryHeader.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import moment from 'moment-timezone';
3 | import { Link } from 'react-router-dom';
4 |
5 | export function EntryHeader({ title, publishedAt, location }) {
6 | return (
7 |
8 |
9 |
10 | {title}
11 |
12 |
13 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/domains/blog_list/components/BlogCardList/BlogCardList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import _ from 'lodash';
3 |
4 | import { BlogCard } from '../BlogCard';
5 |
6 | export function BlogCardList({ list, columnCount }) {
7 | const rows = _.chunk(list, columnCount);
8 |
9 | return (
10 |
11 | {_.map(rows, (rowItems, i) => (
12 |
13 | {_.map(rowItems, (item, j) => (
14 |
19 |
20 |
21 | ))}
22 |
23 | ))}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/domains/blog/components/BlogHeader/BlogHeader.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | import { ProportionalImage } from '../../../../foundation/components/ProportionalImage';
5 |
6 | export function BlogHeader({ blog }) {
7 | return (
8 |
9 |
12 |
13 |
14 |
15 | {blog.nickname}
16 |
17 |
18 |
{blog.self_introduction}
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/domains/entry/entry_actions.js:
--------------------------------------------------------------------------------
1 | import { fetch, post } from '../../foundation/gateway';
2 |
3 | export const ACTION_ENTRY_FETCHED = 'ENTRY_FETCHED';
4 | export const ACTION_LIKE_UPDATED = 'LIKE_UPDATED';
5 |
6 | export async function fetchEntry({ dispatch, blogId, entryId }) {
7 | const entry = await fetch(`/api/blog/${blogId}/entry/${entryId}`);
8 |
9 | dispatch({
10 | type: ACTION_ENTRY_FETCHED,
11 | data: {
12 | entry,
13 | },
14 | });
15 | }
16 |
17 | export async function likeEntry({ dispatch, blogId, entryId }) {
18 | await post(`/api/blog/${blogId}/entry/${entryId}/like`);
19 |
20 | const entry = await fetch(`/api/blog/${blogId}/entry/${entryId}`);
21 | const latestLikeCount = entry.like_count;
22 |
23 | dispatch({
24 | type: ACTION_LIKE_UPDATED,
25 | data: {
26 | likeCount: latestLikeCount,
27 | },
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/src/domains/blog_list/components/BlogCardList/BlogCardList.css:
--------------------------------------------------------------------------------
1 | .blog-list-BlogCardList {
2 | width: 100%;
3 | }
4 |
5 | .blog-list-BlogCardList__row {
6 | width: 100%;
7 | margin-bottom: calc(var(--space) * 2);
8 | display: flex;
9 | flex-direction: row;
10 | justify-content: flex-start;
11 | }
12 |
13 | .blog-list-BlogCardList__row:last-child + .blog-list-BlogCardList__row {
14 | width: 100%;
15 | margin-bottom: calc(var(--space) * 2);
16 | display: flex;
17 | flex-direction: row;
18 | justify-content: flex-start;
19 | }
20 |
21 | .blog-list-BlogCardList__row:first-child {
22 | margin-bottom: calc(var(--space) * 2);
23 | }
24 |
25 | .blog-list-BlogCardList__row:last-child {
26 | margin-bottom: 0;
27 | }
28 |
29 | .blog-list-BlogCardList__column {
30 | padding-right: calc(var(--space) * 2);
31 | }
32 |
33 | .blog-list-BlogCardList__item {
34 | padding-right: calc(var(--space) * 2);
35 | }
36 |
--------------------------------------------------------------------------------
/src/pages/not_found/NotFound.css:
--------------------------------------------------------------------------------
1 | .NotFound {
2 | position: absolute;
3 | top: var(--height-global-header);
4 | left: 0;
5 | width: 100vw;
6 | height: calc(100vh - var(--height-global-header));
7 | background-color: black;
8 | color: white;
9 | text-align: center;
10 | }
11 |
12 | .NotFound__inner {
13 | margin: 0 auto;
14 | padding: calc(var(--space) * 4);
15 | }
16 |
17 | .NotFound__title {
18 | margin: calc(var(--space) * 10) 0;
19 | font-size: 40px;
20 | font-family: serif;
21 | }
22 |
23 | .NotFound__text {
24 | margin: calc(var(--space) * 10) 0;
25 | font-size: 40px;
26 | font-family: serif;
27 | }
28 |
29 | .NotFound__img {
30 | margin: 0 auto calc(var(--space) * 2);
31 | display: block;
32 | }
33 |
34 | .NotFound__img-credit {
35 | color: white;
36 | font-size: var(--font-size-s);
37 | }
38 |
39 | .NotFound__img-author {
40 | color: white;
41 | font-size: var(--font-size-s);
42 | }
43 |
--------------------------------------------------------------------------------
/src/foundation/routes.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Switch, Route } from 'react-router-dom';
3 | import { useSelector } from 'react-redux';
4 |
5 | import { Entrance } from '../pages/entrance';
6 | import { BlogHome } from '../pages/blog_home';
7 | import { Entry } from '../pages/entry';
8 | import { NotFound } from '../pages/not_found';
9 |
10 | export function Routes() {
11 | const error = useSelector((state) => state.error.toJS());
12 |
13 | if (error.error !== undefined) {
14 | return ;
15 | }
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/domains/entry/components/FacebookShareButton/FacebookShareButton.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import $ from 'jquery';
3 |
4 | const FACEBOOK_SDK =
5 | 'https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v3.0';
6 |
7 | export function FacebookShareButton() {
8 | useEffect(() => {
9 | if ('FB' in globalThis) {
10 | globalThis.FB.XFBML.parse();
11 | return;
12 | }
13 |
14 | const script$ = $(
15 | ``,
16 | ).appendTo('body');
17 |
18 | return () => {
19 | script$.remove();
20 | };
21 | }, []);
22 |
23 | return (
24 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/domains/entry/components/HatenaBookmarkButton/HatenaBookmarkButton.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import $ from 'jquery';
3 |
4 | const HATENA_SDK = 'https://b.st-hatena.com/js/bookmark_button.js';
5 |
6 | export function HatenaBookmarkButton({ location }) {
7 | useEffect(() => {
8 | const script$ = $(``).appendTo('body');
9 |
10 | return () => {
11 | script$.remove();
12 | };
13 | }, []);
14 |
15 | return (
16 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/lib/api/blog.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export const api = axios.create({
4 | baseURL: 'https://web-speed-hackathon-api.herokuapp.com/api',
5 | });
6 |
7 | export function getBlogs(limit, offset) {
8 | const params = { limit, offset };
9 | return api.get('/blogs', { params });
10 | }
11 |
12 | export function getBlogById(blogId) {
13 | return api.get(`/blog/${blogId}`);
14 | }
15 |
16 | export function getBlogEntries(blogId, limit, offset) {
17 | const params = { limit, offset };
18 | return api.get(`/blog/${blogId}/entries`, { params });
19 | }
20 |
21 | export function getBlogEntryById(blogId, entryId) {
22 | return api.get(`/blog/${blogId}/entry/${entryId}`);
23 | }
24 |
25 | export function getBlogEntryComments(blogId, entryId, limit, offset) {
26 | const params = { limit, offset };
27 | return api.get(`/blog/${blogId}/entry/${entryId}/comments`, { params });
28 | }
29 |
30 | export function getBlogEntryCommentById(blogId, entryId, commentId) {
31 | return api.get(`/blog/${blogId}/entry/${entryId}/comment/${commentId}`);
32 | }
33 |
34 | export function postBlogEntryLike(blogId, entryId) {
35 | return api.post(`/blog/${blogId}/entry/${entryId}/like`);
36 | }
37 |
--------------------------------------------------------------------------------
/src/foundation/components/ProportionalImage/ProportionalImage.css:
--------------------------------------------------------------------------------
1 | .foundation-ProportionalImage__outer {
2 | position: relative;
3 | top: 0;
4 | left: 0;
5 | width: 100%;
6 | height: 100%;
7 | }
8 |
9 | .foundation-ProportionalImage {
10 | position: relative;
11 | height: 0;
12 | width: 100%;
13 | overflow: hidden;
14 | }
15 |
16 | .foundation-ProportionalImage__inner {
17 | position: absolute;
18 | top: 0;
19 | left: 0;
20 | width: 100%;
21 | height: 100%;
22 | }
23 |
24 | .foundation-ProportionalImage__img {
25 | height: 100%;
26 | position: absolute;
27 | top: 50%;
28 | left: 50%;
29 | transform: translate(-50%, -50%);
30 | }
31 |
32 | .foundation-ProportionalImage__image {
33 | height: 100%;
34 | position: absolute;
35 | top: 50%;
36 | left: 50%;
37 | transform: translate(-50%, -50%);
38 | }
39 |
40 | .foundation-ProportionalImage--rounded-as-card-thumbnail,
41 | .foundation-ProportionalImage--rounded-as-card-thumbnail
42 | .foundation-ProportionalImage__inner,
43 | .foundation-ProportionalImage--rounded-as-card-thumbnail
44 | .foundation-ProportionalImage__img,
45 | .foundation-ProportionalImage--rounded-as-card-thumbnail
46 | .foundation-ProportionalImage__image {
47 | border-radius: 4px 4px 0 0;
48 | }
49 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 |
5 | const webpack = require('webpack');
6 | const HtmlWebpackPlugin = require('html-webpack-plugin');
7 |
8 | module.exports = {
9 | entry: path.resolve(__dirname, 'src', 'app.js'),
10 |
11 | output: {
12 | path: path.resolve(__dirname, 'dist'),
13 | filename: '[name].bundle.js',
14 | },
15 |
16 | resolve: {
17 | extensions: ['.js', '.jsx'],
18 | },
19 |
20 | plugins: [
21 | new webpack.DefinePlugin({
22 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
23 | 'process.env.USE_MOCK_DATA': JSON.stringify(process.env.USE_MOCK_DATA),
24 | }),
25 | new HtmlWebpackPlugin({
26 | title: 'Amida Blog: あみぶろ',
27 | template: path.resolve(__dirname, 'src', 'index.html'),
28 | inject: false,
29 | }),
30 | ],
31 |
32 | module: {
33 | rules: [
34 | {
35 | test: /\.m?jsx?$/,
36 | use: {
37 | loader: 'babel-loader',
38 | },
39 | },
40 | {
41 | test: /\.(png|svg|jpe?g|gif)$/,
42 | use: {
43 | loader: 'url-loader',
44 | },
45 | },
46 | ],
47 | },
48 |
49 | target: 'web',
50 |
51 | devtool: 'inline-source-map',
52 |
53 | mode: 'none',
54 | };
55 |
--------------------------------------------------------------------------------
/src/domains/comment_list/components/CommentListItem/CommentListItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import moment from 'moment-timezone';
3 | import { Link } from 'react-router-dom';
4 |
5 | import { ProportionalImage } from '../../../../foundation/components/ProportionalImage';
6 |
7 | export function CommentListItem({ comment }) {
8 | return (
9 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/domains/blog_list/components/BlogCard/BlogCard.css:
--------------------------------------------------------------------------------
1 | .blog-list-BlogCard {
2 | display: block;
3 | text-decoration: none;
4 | color: black;
5 | background-color: #eee;
6 | border-radius: 4px;
7 | width: 100%;
8 | height: 100%;
9 | }
10 |
11 | .blog-list-BlogCard:hover {
12 | opacity: 0.8;
13 | }
14 |
15 | .blog-list-BlogCard__thumbnail {
16 | border-radius: 4px 4px 0 0;
17 | width: 100%;
18 | }
19 |
20 | .blog-list-BlogCard__img {
21 | border-radius: 4px 4px 0 0;
22 | width: 100%;
23 | }
24 |
25 | .blog-list-BlogCard__image {
26 | border-radius: 4px 4px 0 0;
27 | width: 100%;
28 | }
29 |
30 | .blog-list-BlogCard__label {
31 | width: 100%;
32 | text-align: center;
33 | padding: calc(var(--space) * 2) 0;
34 | }
35 |
36 | .blog-list-BlogCard__caption {
37 | width: 100%;
38 | text-align: center;
39 | padding: calc(var(--space) * 2) 0;
40 | }
41 |
42 | .blog-list-BlogCard__title {
43 | width: 100%;
44 | text-align: center;
45 | padding: calc(var(--space) * 2) 0;
46 | }
47 |
48 | .blog-list-BlogCard__text {
49 | width: 100%;
50 | text-align: center;
51 | padding: calc(var(--space) * 2) 0;
52 | }
53 |
54 | .blog-list-BlogCard__heading {
55 | width: 100%;
56 | text-align: center;
57 | padding: calc(var(--space) * 2) 0;
58 | }
59 |
60 | .blog-list-BlogCard__header {
61 | width: 100%;
62 | text-align: center;
63 | padding: calc(var(--space) * 2) 0;
64 | }
65 |
--------------------------------------------------------------------------------
/src/domains/entry/components/EntryFooter/EntryFooter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import moment from 'moment-timezone';
3 | import { Link } from 'react-router-dom';
4 |
5 | import { AmidaLikeButton } from '../AmidaLikeButton';
6 | import { TwitterShareButton } from '../TwitterShareButton';
7 | import { FacebookShareButton } from '../FacebookShareButton';
8 | import { HatenaBookmarkButton } from '../HatenaBookmarkButton';
9 |
10 | export function EntryFooter({ location, likeCount, publishedAt, onClickLike }) {
11 | return (
12 |
13 |
14 |
17 |
18 |
19 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/domains/entry_list/components/EntryList/EntryList.css:
--------------------------------------------------------------------------------
1 | .entry-list-EntryList__entry-outer {
2 | border-bottom: var(--border);
3 | padding: calc(var(--space) * 4) calc(var(--space) * 4);
4 | display: flex;
5 | flex-direction: row;
6 | align-items: center;
7 | color: var(--font-color-regular);
8 | text-decoration: none;
9 | }
10 |
11 | .entry-list-EntryList__entry-container {
12 | border-bottom: var(--border);
13 | padding: calc(var(--space) * 4) calc(var(--space) * 4);
14 | display: flex;
15 | flex-direction: row;
16 | align-items: center;
17 | color: var(--font-color-regular);
18 | text-decoration: none;
19 | }
20 |
21 | .entry-list-EntryList__entry-inner {
22 | border-bottom: var(--border);
23 | padding: calc(var(--space) * 4) calc(var(--space) * 4);
24 | display: flex;
25 | flex-direction: row;
26 | align-items: center;
27 | color: var(--font-color-regular);
28 | text-decoration: none;
29 | }
30 |
31 | .entry-list-EntryList__entry:last-child .entry-list-EntryList__entry-inner {
32 | border-bottom: none;
33 | }
34 |
35 | .entry-list-EntryList__entry-inner:hover {
36 | opacity: 0.8;
37 | }
38 |
39 | .entry-list-EntryList__thumbnail {
40 | width: 120px;
41 | }
42 |
43 | .entry-list-EntryList__image {
44 | width: 120px;
45 | }
46 |
47 | .entry-list-EntryList__text {
48 | margin-left: calc(var(--space) * 3);
49 | line-height: var(--line-height-paragraph);
50 | }
51 |
--------------------------------------------------------------------------------
/src/domains/entry_list/components/EntryList/EntryList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import _ from 'lodash';
3 | import { Link } from 'react-router-dom';
4 | import moment from 'moment-timezone';
5 |
6 | import { ProportionalImage } from '../../../../foundation/components/ProportionalImage';
7 |
8 | export function EntryList({ blogId, list }) {
9 | return (
10 |
11 | {_.chain(list)
12 | .filter((entry) => entry.publish_flag === 'open')
13 | .map((entry, i) => {
14 | return (
15 | -
16 |
20 |
27 |
28 |
35 |
{entry.title}
36 |
37 |
38 |
39 | );
40 | })
41 | .value()}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/domains/comment_list/components/CommentListItem/CommentListItem.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --comment-CommentListItem-avatar-size: 50px;
3 | }
4 |
5 | .comment-CommentListItem {
6 | display: flex;
7 | flex-direction: row;
8 | }
9 |
10 | .comment-CommentListItem__avatar {
11 | flex: 0 0 var(--comment-CommentListItem-avatar-size);
12 | width: var(--comment-CommentListItem-avatar-size);
13 | height: var(--comment-CommentListItem-avatar-size);
14 | margin-right: calc(var(--space) * 3);
15 | }
16 |
17 | .comment-CommentListItem__body {
18 | flex: 1 1;
19 | }
20 |
21 | .comment-CommentListItem__author {
22 | font-size: var(--font-size-l);
23 | font-weight: bold;
24 | margin-bottom: calc(var(--space) * 2);
25 | }
26 |
27 | .comment-CommentListItem__poster {
28 | font-size: var(--font-size-l);
29 | font-weight: bold;
30 | margin-bottom: calc(var(--space) * 2);
31 | }
32 |
33 | .comment-CommentListItem__commenter {
34 | font-size: var(--font-size-l);
35 | font-weight: bold;
36 | margin-bottom: calc(var(--space) * 2);
37 | }
38 |
39 | .comment-CommentListItem__article {
40 | line-height: var(--line-height-paragraph);
41 | margin-bottom: calc(var(--space) * 2);
42 | }
43 |
44 | .comment-CommentListItem__content {
45 | line-height: var(--line-height-paragraph);
46 | margin-bottom: calc(var(--space) * 2);
47 | }
48 |
49 | .comment-CommentListItem__comment {
50 | line-height: var(--line-height-paragraph);
51 | margin-bottom: calc(var(--space) * 2);
52 | }
53 |
54 | .comment-CommentListItem__main {
55 | line-height: var(--line-height-paragraph);
56 | margin-bottom: calc(var(--space) * 2);
57 | }
58 |
59 | .comment-CommentListItem__section {
60 | margin-bottom: calc(var(--space) * 4);
61 | }
62 |
63 | .comment-CommentListItem__time {
64 | font-size: var(--font-size-s);
65 | }
66 |
67 | .comment-CommentListItem__footer {
68 | font-size: var(--font-size-s);
69 | }
70 |
71 | .comment-CommentListItem__bottom {
72 | font-size: var(--font-size-s);
73 | }
74 |
--------------------------------------------------------------------------------
/src/pages/blog_home/BlogHome.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useParams } from 'react-router-dom';
3 | import { useSelector, useDispatch } from 'react-redux';
4 | import Helmet from 'react-helmet';
5 |
6 | import { renderNotFound } from '../../domains/error/error_actions';
7 |
8 | import { fetchBlog } from '../../domains/blog/blog_actions';
9 | import { BlogHeader } from '../../domains/blog/components/BlogHeader';
10 |
11 | import { fetchEntryList } from '../../domains/entry_list/entry_list_actions';
12 | import { EntryList } from '../../domains/entry_list/components/EntryList';
13 |
14 | import { Main } from '../../foundation/components/Main';
15 |
16 | export function BlogHome() {
17 | const { blogId } = useParams();
18 | const dispatch = useDispatch();
19 | const blog = useSelector((state) => state.blog.toJS());
20 | const entryList = useSelector((state) => state.entryList.toJS());
21 | const [hasFetchFinished, setHasFetchFinished] = useState(false);
22 |
23 | useEffect(() => {
24 | setHasFetchFinished(false);
25 |
26 | (async () => {
27 | try {
28 | await fetchBlog({ dispatch, blogId });
29 | await fetchEntryList({ dispatch, blogId });
30 | } catch {
31 | await renderNotFound({ dispatch });
32 | }
33 |
34 | setHasFetchFinished(true);
35 | })();
36 | }, [dispatch, blogId]);
37 |
38 | if (!hasFetchFinished) {
39 | return (
40 |
41 | Amida Blog: あみぶろ
42 |
43 | );
44 | }
45 |
46 | return (
47 | <>
48 |
49 | {blog.nickname} - Amida Blog: あみぶろ
50 |
51 |
52 |
53 |
54 |
55 |
59 |
60 |
61 | >
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Web Speed Hackathon Online Vol.1
2 |
3 | ## 概要
4 |
5 | 「Web Speed Hackathon Online」はリモート参加型のハッカソンです。
6 | 予め準備してある Web アプリケーションのパフォーマンスを改善することで競い合います。
7 |
8 | - [パフォーマンス改善ハッカソン「Web Speed Hackathon Online」を学生向け・社内向けの2回開催しました! | CyberAgent Developers Blog](https://developers.cyberagent.co.jp/blog/archives/28484/)
9 | - [Web Speed Hackathon Online 出題のねらいと解説 · CyberAgentHack/web-speed-hackathon-2020 Wiki](https://github.com/CyberAgentHack/web-speed-hackathon-2020/wiki/Web-Speed-Hackathon-Online-%E5%87%BA%E9%A1%8C%E3%81%AE%E3%81%AD%E3%82%89%E3%81%84%E3%81%A8%E8%A7%A3%E8%AA%AC)
10 |
11 | ## 課題
12 |
13 | 架空のブログサイト、Amida Blog (あみぶろ)のパフォーマンスを改善してください。
14 |
15 | https://web-speed-hackathon-online-prd.herokuapp.com/
16 |
17 | ※注意: かなり遅いサイトとなっております
18 |
19 | ## 環境作成
20 |
21 | **評価対象となる環境(URL)を作成し、提出してください。**
22 |
23 | 任意の場所にデプロイいただくことができますが、すぐに準備できない方のために Heroku Review Apps によるデプロイも用意してあります。
24 |
25 | 1. 作業ブランチ `user/${your GitHub account name}` を作成
26 | 2. Pull Request の作成
27 | 3. 自動的に Heroku Review Apps でブランチの URL が作成されます
28 |
29 | 外部のサービスは全て無料枠の範囲内で使用してください。万が一コストが発生した場合は全て自己負担となります。
30 |
31 | ## 採点方法
32 |
33 | 1. [Lighthouse v6](https://github.com/GoogleChrome/lighthouse)を用いてトップ、ブログ、記事(2 ページ)、404 ページの計 5 ページを検査します
34 | 2. 各ページ毎に [Lighthouse Performance Scoring](https://web.dev/performance-scoring/#lighthouse-6) に基づき以下の点数の総和を計算し、ページのスコアとします
35 | - Performance Score (0-100 点)
36 | - First Contentful Paint の相対スコア × 3 (0-3 点)
37 | - Speed Index の相対スコア × 3 (0-3 点)
38 | - Largest Contentful Paint の相対スコア × 5 (0-5 点)
39 | - Time To Interactive の相対スコア × 3 (0-3 点)
40 | - Total Blocking Time の相対スコア × 5 (0-5 点)
41 | - Cumulative Layout Shift の相対スコア × 1 (0-1 点)
42 | 3. 各ページの合計のスコアを得点とします
43 | 4. 後述するレギュレーションに違反している場合、得点を 0 点とします
44 |
45 | ## レギュレーション
46 |
47 | - このリポジトリにあるコードはすべて変更してよい
48 | - 提供された Heroku Review Apps の他に自ら deploy 先を作成してよい
49 | - 外部のサービス (SaaS 等) も自由に利用してよい
50 | - **Google Chrome 最新版において、機能落ちやデザイン差異が発生していないこと** を必須要件とします。
51 | - 以下を満たない場合は、得点を得られません。
52 | - a. ページ読み込み完了時のデザイン差異がない(フォントの指定やウィンドウをリサイズしたときのデザインを含む)
53 | - b. ページをスクロールしたときに得られる情報に差異がない
54 | - c. ページ遷移、文字のアニメーション、「いいね」押下などの機能が正しく動作する
55 | - d. API が返却した内容とページで表示される内容に差異がない
56 |
57 | ## 開発
58 |
59 | ### 環境
60 |
61 | - Node.js (+13)
62 | - yarn
63 |
64 | ### 準備
65 |
66 | ```bash
67 | $ yarn install
68 | ```
69 |
70 | ### ビルド
71 |
72 | ```bash
73 | $ yarn build
74 | ```
75 |
76 | ### ローカルサーバー
77 |
78 | ```bash
79 | $ yarn serve
80 | ```
81 |
--------------------------------------------------------------------------------
/src/domains/entry/components/EntryView/EntryView.css:
--------------------------------------------------------------------------------
1 | .entry-EntryView {
2 | line-height: var(--line-height-paragraph);
3 | }
4 |
5 | .entry-EntryView__heading-1,
6 | .entry-EntryView__heading-2,
7 | .entry-EntryView__heading-3,
8 | .entry-EntryView__heading-4,
9 | .entry-EntryView__heading-5,
10 | .entry-EntryView__heading-6 {
11 | margin-bottom: calc(var(--space) * 2);
12 | }
13 |
14 | .entry-EntryView__headline-1,
15 | .entry-EntryView__headline-2,
16 | .entry-EntryView__headline-3,
17 | .entry-EntryView__headline-4,
18 | .entry-EntryView__headline-5,
19 | .entry-EntryView__headline-6 {
20 | margin-bottom: calc(var(--space) * 2);
21 | }
22 |
23 | .entry-EntryView__title-1,
24 | .entry-EntryView__title-2,
25 | .entry-EntryView__title-3,
26 | .entry-EntryView__title-4,
27 | .entry-EntryView__title-5,
28 | .entry-EntryView__title-6 {
29 | margin-bottom: calc(var(--space) * 2);
30 | }
31 |
32 | .entry-EntryView__figure-container,
33 | .entry-EntryView__video,
34 | .entry-EntryView__embed {
35 | display: block;
36 | margin: calc(var(--space) * 3) auto;
37 | }
38 |
39 | .entry-EntryView__image-container {
40 | text-align: center;
41 | }
42 |
43 | .entry-EntryView__image-link {
44 | display: inline-block;
45 | margin: 0 auto;
46 | border: var(--border);
47 | padding: calc(var(--space) * 2);
48 | color: black;
49 | text-decoration: none;
50 | }
51 |
52 | .entry-EntryView__image {
53 | display: inline-flex;
54 | flex-flow: column;
55 | align-items: center;
56 | }
57 |
58 | .entry-EntryView__image-caption {
59 | font-size: var(--font-size-s);
60 | margin-top: calc(var(--space) * 2);
61 | text-align: center;
62 | }
63 |
64 | .entry-EntryView__figure-container {
65 | text-align: center;
66 | }
67 |
68 | .entry-EntryView__figure-link {
69 | display: inline-block;
70 | margin: 0 auto;
71 | border: var(--border);
72 | padding: calc(var(--space) * 2);
73 | color: black;
74 | text-decoration: none;
75 | }
76 |
77 | .entry-EntryView__figure {
78 | display: inline-flex;
79 | flex-flow: column;
80 | align-items: center;
81 | }
82 |
83 | .entry-EntryView__figcaption {
84 | font-size: var(--font-size-s);
85 | margin-top: calc(var(--space) * 2);
86 | text-align: center;
87 | }
88 |
89 | .entry-EntryView__caption {
90 | font-size: var(--font-size-s);
91 | margin-top: calc(var(--space) * 2);
92 | text-align: center;
93 | }
94 |
95 | .entry-EntryView__embed {
96 | display: flex;
97 | justify-content: center;
98 | }
99 |
--------------------------------------------------------------------------------
/lib/controller/api.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { asyncWrap } from '../utils';
3 | import {
4 | getBlogById,
5 | getBlogEntries,
6 | getBlogEntryById,
7 | getBlogEntryCommentById,
8 | getBlogEntryComments,
9 | getBlogs,
10 | postBlogEntryLike,
11 | } from '../api/blog';
12 | import { Payload } from '../model/payload';
13 |
14 | export const apiController = express.Router();
15 |
16 | apiController.get(
17 | '/api/blogs',
18 | asyncWrap(async (req, res) => {
19 | const { limit, offset } = req.query;
20 | const { data } = await getBlogs(limit, offset);
21 | const payload = new Payload(data);
22 | res.send(payload.toResponse());
23 | }),
24 | );
25 |
26 | apiController.get(
27 | '/api/blog/:blogId',
28 | asyncWrap(async (req, res) => {
29 | const { blogId } = req.params;
30 | const { data } = await getBlogById(blogId);
31 | res.send(data);
32 | }),
33 | );
34 |
35 | apiController.get(
36 | '/api/blog/:blogId/entries',
37 | asyncWrap(async (req, res) => {
38 | const { blogId } = req.params;
39 | const { limit, offset } = req.query;
40 | const { data } = await getBlogEntries(blogId, limit, offset);
41 | const payload = new Payload(data);
42 | res.send(payload.toResponse());
43 | }),
44 | );
45 |
46 | apiController.get(
47 | '/api/blog/:blogId/entry/:entryId',
48 | asyncWrap(async (req, res) => {
49 | const { blogId, entryId } = req.params;
50 | const { data } = await getBlogEntryById(blogId, entryId);
51 | const payload = new Payload(data);
52 | res.send(payload.toResponse());
53 | }),
54 | );
55 |
56 | apiController.get(
57 | '/api/blog/:blogId/entry/:entryId/comments',
58 | asyncWrap(async (req, res) => {
59 | const { blogId, entryId } = req.params;
60 | const { limit, offset } = req.query;
61 | const { data } = await getBlogEntryComments(blogId, entryId, limit, offset);
62 | const payload = new Payload(data);
63 | res.send(payload.toResponse());
64 | }),
65 | );
66 |
67 | apiController.get(
68 | '/api/blog/:blogId/entry/:entryId/comment/:commentId',
69 | asyncWrap(async (req, res) => {
70 | const { blogId, entryId, commentId } = req.params;
71 | const { data } = await getBlogEntryCommentById(blogId, entryId, commentId);
72 | const payload = new Payload(data);
73 | res.send(payload.toResponse());
74 | }),
75 | );
76 |
77 | apiController.post(
78 | '/api/blog/:blogId/entry/:entryId/like',
79 | asyncWrap(async (req, res) => {
80 | const { blogId, entryId } = req.params;
81 | const { data } = await postBlogEntryLike(blogId, entryId);
82 | res.send(data);
83 | }),
84 | );
85 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web-speed-hackathon-online",
3 | "version": "0.0.1",
4 | "license": "Proprietary",
5 | "private": true,
6 | "scripts": {
7 | "clean": "rimraf dist",
8 | "build": "npm-run-all clean build:css build:webpack",
9 | "build:css": "postcss src/app.css -d dist",
10 | "build:webpack": "cross-env NODE_ENV=development webpack --config webpack.config.js",
11 | "build:webpack-with-mock-data": "cross-env USE_MOCK_DATA=true NODE_ENV=development webpack --config webpack.config.js",
12 | "fmt": "prettier --write 'src/**/*.{js,css}'",
13 | "serve": "nodemon --exec babel-node lib/server.js"
14 | },
15 | "devDependencies": {
16 | "autoprefixer": "^9.7.4",
17 | "babel-loader": "^8.1.0",
18 | "cross-env": "^7.0.2",
19 | "html-webpack-plugin": "^3.2.0",
20 | "husky": "^4.2.3",
21 | "lint-staged": "^10.0.8",
22 | "nodemon": "^2.0.2",
23 | "npm-run-all": "^4.1.5",
24 | "postcss": "^7.0.27",
25 | "postcss-cli": "^7.1.0",
26 | "postcss-custom-properties": "^9.1.1",
27 | "postcss-import": "^12.0.1",
28 | "prettier": "^2.0.1",
29 | "rimraf": "^3.0.2",
30 | "url-loader": "^4.0.0",
31 | "webpack": "^4.42.0",
32 | "webpack-cli": "^3.3.11"
33 | },
34 | "husky": {
35 | "hooks": {
36 | "pre-commit": "lint-staged"
37 | }
38 | },
39 | "lint-staged": {
40 | "src/**/*.{js,css}": "prettier --write"
41 | },
42 | "dependencies": {
43 | "@babel/cli": "^7.8.4",
44 | "@babel/core": "^7.9.0",
45 | "@babel/node": "^7.8.7",
46 | "@babel/plugin-transform-modules-commonjs": "^7.9.0",
47 | "@babel/preset-env": "^7.9.0",
48 | "@babel/preset-react": "^7.9.1",
49 | "@fortawesome/fontawesome-svg-core": "^1.2.28",
50 | "@fortawesome/free-solid-svg-icons": "^5.13.0",
51 | "@fortawesome/react-fontawesome": "^0.1.9",
52 | "@zendeskgarden/css-buttons": "^7.0.19",
53 | "@zendeskgarden/css-forms": "^7.0.20",
54 | "@zendeskgarden/css-utilities": "^4.5.5",
55 | "axios": "^0.19.2",
56 | "axios-mock-adapter": "^1.18.1",
57 | "classnames": "^2.2.6",
58 | "core-js": "^3.6.4",
59 | "express": "^4.17.1",
60 | "immutable": "^4.0.0-rc.12",
61 | "jquery": "^3.4.1",
62 | "lodash": "^4.17.15",
63 | "moment-timezone": "^0.5.28",
64 | "normalize.css": "^8.0.1",
65 | "race-timeout": "^1.0.0",
66 | "react": "^16.13.1",
67 | "react-dom": "^16.13.1",
68 | "react-helmet": "^5.2.1",
69 | "react-redux": "^7.2.0",
70 | "react-router-dom": "^5.1.2",
71 | "redux": "^4.0.5",
72 | "regenerator-runtime": "^0.13.5",
73 | "suitcss-utils": "^3.0.0"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/domains/entry/components/EntryView/EntryView.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import _ from 'lodash';
3 |
4 | function Headline({ level, text }) {
5 | const tagName = `h${level}`;
6 | const el = React.createElement(
7 | tagName,
8 | { className: `entry-EntryView__headline-${level}` },
9 | text,
10 | );
11 | return el;
12 | }
13 |
14 | function Paragraph({ text }) {
15 | return {text}
;
16 | }
17 |
18 | function Link({ url, text }) {
19 | return (
20 |
21 | {text}
22 |
23 | );
24 | }
25 |
26 | function Image({ url, width, height, caption }) {
27 | return (
28 |
48 | );
49 | }
50 |
51 | function Video({ url, width, height }) {
52 | return (
53 |
59 | );
60 | }
61 |
62 | function Embed({ html }) {
63 | return (
64 |
68 | );
69 | }
70 |
71 | export function EntryView({ items }) {
72 | return (
73 |
74 | {_.map(items, (item, i) => {
75 | if (item.type === 'headline') {
76 | return
;
77 | }
78 |
79 | if (item.type === 'paragraph') {
80 | return
;
81 | }
82 |
83 | if (item.type === 'link') {
84 | return
;
85 | }
86 |
87 | if (item.type === 'image') {
88 | return
;
89 | }
90 |
91 | if (item.type === 'video') {
92 | return
;
93 | }
94 |
95 | if (item.type === 'embed') {
96 | return
;
97 | }
98 |
99 | return
;
100 | })}
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/src/pages/entrance/Entrance.css:
--------------------------------------------------------------------------------
1 | .Entrance__hero {
2 | width: 100%;
3 | height: 400px;
4 | position: relative;
5 | background-color: #eee;
6 | }
7 |
8 | .Entrance__hero-bg {
9 | position: absolute;
10 | top: 0;
11 | left: 0;
12 | width: 100%;
13 | height: 100%;
14 | overflow: hidden;
15 | display: flex;
16 | align-items: center;
17 | }
18 |
19 | .Entrance__hero-bg-img {
20 | position: absolute;
21 | top: 0;
22 | left: 0;
23 | width: 100%;
24 | height: 100%;
25 | overflow: hidden;
26 | display: flex;
27 | align-items: center;
28 | }
29 |
30 | .Entrance__hero-background {
31 | position: absolute;
32 | top: 0;
33 | left: 0;
34 | width: 100%;
35 | height: 100%;
36 | overflow: hidden;
37 | display: flex;
38 | align-items: center;
39 | }
40 |
41 | .Entrance__hero-contents {
42 | position: absolute;
43 | top: 50%;
44 | left: 50%;
45 | transform: translate(-50%, -50%);
46 | display: flex;
47 | flex-direction: row;
48 | flex-wrap: wrap;
49 | align-items: center;
50 | justify-content: center;
51 | }
52 |
53 | .Entrance__hero-content {
54 | position: absolute;
55 | top: 50%;
56 | left: 50%;
57 | transform: translate(-50%, -50%);
58 | display: flex;
59 | flex-direction: row;
60 | flex-wrap: wrap;
61 | align-items: center;
62 | justify-content: center;
63 | }
64 |
65 | .Entrance__hero-logo {
66 | width: 100px;
67 | }
68 |
69 | .Entrance__hero-title {
70 | margin-left: calc(var(--var) * 5);
71 | font-size: var(--font-size-title);
72 | line-height: var(--line-height-paragraph);
73 | display: flex;
74 | flex-direction: row;
75 | align-items: center;
76 | }
77 |
78 | .Entrance__hero-title-en {
79 | font-family: 'Baloo Thambi 2', cursive;
80 | font-weight: bold;
81 | flex-shrink: 0;
82 | }
83 |
84 | .Entrance__hero-title-ja {
85 | font-family: 'M PLUS Rounded 1c', sans-serif;
86 | font-weight: bold;
87 | flex-shrink: 0;
88 | }
89 |
90 | .Entrance__hero-text {
91 | margin-left: calc(var(--var) * 5);
92 | font-size: var(--font-size-title);
93 | line-height: var(--line-height-paragraph);
94 | display: flex;
95 | flex-direction: row;
96 | align-items: center;
97 | }
98 |
99 | .Entrance__hero-text-en {
100 | font-family: 'Baloo Thambi 2', cursive;
101 | font-weight: bold;
102 | flex-shrink: 0;
103 | }
104 |
105 | .Entrance__hero-text-ja {
106 | font-family: 'M PLUS Rounded 1c', sans-serif;
107 | font-weight: bold;
108 | flex-shrink: 0;
109 | }
110 |
111 | .Entrance__title {
112 | margin-bottom: calc(var(--space) * 3);
113 | }
114 |
115 | .Entrance__pickup > .Entrance__title {
116 | color: orange;
117 | }
118 |
119 | .Entrance__section {
120 | margin-top: calc(var(--space) * 6);
121 | }
122 |
123 | .Entrance__section:not(:last-child) {
124 | margin-bottom: calc(var(--space) * 6);
125 | }
126 |
--------------------------------------------------------------------------------
/src/pages/entry/Entry.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useParams, useLocation } from 'react-router-dom';
3 | import { useSelector, useDispatch } from 'react-redux';
4 | import Helmet from 'react-helmet';
5 |
6 | import { Main } from '../../foundation/components/Main';
7 |
8 | import { renderNotFound } from '../../domains/error/error_actions';
9 |
10 | import { fetchBlog } from '../../domains/blog/blog_actions';
11 | import { BlogHeader } from '../../domains/blog/components/BlogHeader';
12 |
13 | import { fetchEntry, likeEntry } from '../../domains/entry/entry_actions';
14 | import { EntryHeader } from '../../domains/entry/components/EntryHeader/EntryHeader';
15 | import { EntryView } from '../../domains/entry/components/EntryView';
16 | import { EntryFooter } from '../../domains/entry/components/EntryFooter';
17 |
18 | import { fetchCommentList } from '../../domains/comment_list/comment_list_actions';
19 | import { CommentList } from '../../domains/comment_list/components/CommentList';
20 |
21 | export function Entry() {
22 | const location = useLocation();
23 | const { blogId, entryId } = useParams();
24 | const dispatch = useDispatch();
25 | const blog = useSelector((state) => state.blog.toJS());
26 | const entry = useSelector((state) => state.entry.toJS());
27 | const commentList = useSelector((state) => state.commentList.toJS());
28 | const [hasFetchFinished, setHasFetchFinished] = useState(false);
29 |
30 | useEffect(() => {
31 | setHasFetchFinished(false);
32 |
33 | (async () => {
34 | try {
35 | await fetchBlog({ dispatch, blogId });
36 | await fetchEntry({ dispatch, blogId, entryId });
37 | await fetchCommentList({ dispatch, blogId, entryId });
38 | } catch {
39 | await renderNotFound({ dispatch });
40 | }
41 |
42 | setHasFetchFinished(true);
43 | })();
44 | }, [dispatch, blogId, entryId]);
45 |
46 | if (!hasFetchFinished) {
47 | return (
48 |
49 | Amida Blog: あみぶろ
50 |
51 | );
52 | }
53 |
54 | return (
55 | <>
56 |
57 |
58 | {entry.title} - {blog.nickname} - Amida Blog: あみぶろ
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
73 |
76 |
84 |
85 |
86 |
89 |
90 |
91 |
92 |
93 | >
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/src/pages/entrance/Entrance.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import _ from 'lodash';
4 | import Helmet from 'react-helmet';
5 |
6 | import { renderNotFound } from '../../domains/error/error_actions';
7 |
8 | import { fetchBlogList } from '../../domains/blog_list/blog_list_actions';
9 | import { BlogCardList } from '../../domains/blog_list/components/BlogCardList';
10 |
11 | import { Main } from '../../foundation/components/Main';
12 | import { ProportionalImage } from '../../foundation/components/ProportionalImage';
13 |
14 | import AmidaImage from '../../assets/amida.png';
15 | import Amida2Image from '../../assets/amida2.png';
16 |
17 | export function Entrance() {
18 | const dispatch = useDispatch();
19 | const blogList = useSelector((state) => state.blogList.toJS());
20 | const [pickups, setPickups] = useState([]);
21 | const [hasFetchFinished, setHasFetchFinished] = useState(false);
22 | const heroTextJaList = ['あみぶろ', '阿弥ぶろ', 'アミブロ'];
23 | const [heroTextJa, setHeroTextJa] = useState(heroTextJaList[0]);
24 |
25 | useEffect(() => {
26 | setHasFetchFinished(false);
27 |
28 | (async () => {
29 | try {
30 | await fetchBlogList({ dispatch });
31 | } catch {
32 | await renderNotFound({ dispatch });
33 | }
34 |
35 | setHasFetchFinished(true);
36 | })();
37 | }, [dispatch]);
38 |
39 | useEffect(() => {
40 | const timers = [];
41 | const displayDurationInTotal = 3000;
42 | const typingDurationInTotal = 800;
43 |
44 | const setText = () => {
45 | const text = heroTextJaList.shift();
46 | const length = text.length;
47 | const charInterval = typingDurationInTotal / length;
48 |
49 | setHeroTextJa(' '.repeat(length));
50 |
51 | for (let i = 1; i <= length; i++) {
52 | timers[i] = setTimeout(() => {
53 | setHeroTextJa(text.substring(0, i) + ' '.repeat(length - i));
54 | }, charInterval * i);
55 | }
56 |
57 | heroTextJaList.push(text);
58 | };
59 |
60 | setText();
61 |
62 | timers[0] = setInterval(() => setText(), displayDurationInTotal);
63 |
64 | return () => {
65 | clearInterval(timers[0]);
66 | timers.filter((_, i) => i !== 0).forEach((timer) => clearTimeout(timer));
67 | };
68 | }, []);
69 |
70 | if (!hasFetchFinished) {
71 | return (
72 |
73 | Amida Blog: あみぶろ
74 |
75 | );
76 | }
77 |
78 | if (pickups.length === 0 && blogList.length !== 0) {
79 | setPickups(_.chain(blogList).take(10).shuffle().take(4).value());
80 | }
81 |
82 | return (
83 | <>
84 |
85 | Amida Blog: あみぶろ
86 |
87 |
88 |
89 |
96 |
97 |

98 |
99 | Amida Blog:
100 | {heroTextJa}
101 |
102 |
103 |
104 |
105 |
106 |
107 | Pickups
108 |
109 |
110 |
111 | ブログ一覧
112 |
113 |
114 |
115 |
116 | >
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/src/domains/blog/components/BlogHeader/BlogHeader.css:
--------------------------------------------------------------------------------
1 | .blog-BlogHeader {
2 | position: relative;
3 | width: 100%;
4 | height: 400px;
5 | background-color: #eee;
6 | }
7 |
8 | .blog-BlogHeader__bg {
9 | position: absolute;
10 | top: 0;
11 | left: 50%;
12 | width: 100%;
13 | max-width: 800px;
14 | height: 100%;
15 | overflow: hidden;
16 | display: flex;
17 | align-items: center;
18 | transform: translate(-50%, -50%);
19 | }
20 |
21 | .blog-BlogHeader__bg-image {
22 | position: absolute;
23 | top: 0;
24 | left: 50%;
25 | width: 100%;
26 | max-width: 800px;
27 | height: 100%;
28 | overflow: hidden;
29 | display: flex;
30 | align-items: center;
31 | transform: translateX(-50%);
32 | }
33 |
34 | .blog-BlogHeader__bg-img {
35 | position: absolute;
36 | top: 0;
37 | left: 50%;
38 | width: 100%;
39 | max-width: 800px;
40 | height: 100%;
41 | overflow: hidden;
42 | display: flex;
43 | align-items: center;
44 | transform: translateX(-50%);
45 | }
46 |
47 | .blog-BlogHeader__background {
48 | position: absolute;
49 | top: 0;
50 | left: 50%;
51 | width: 100%;
52 | max-width: 800px;
53 | height: 100%;
54 | overflow: hidden;
55 | display: flex;
56 | align-items: center;
57 | transform: translate(-50%, -50%);
58 | }
59 |
60 | .blog-BlogHeader__background-image {
61 | position: absolute;
62 | top: 0;
63 | left: 50%;
64 | width: 100%;
65 | max-width: 800px;
66 | height: 100%;
67 | overflow: hidden;
68 | display: flex;
69 | align-items: center;
70 | transform: translate(-50%, -50%);
71 | }
72 |
73 | .blog-BlogHeader__background-img {
74 | position: absolute;
75 | top: 0;
76 | left: 50%;
77 | width: 100%;
78 | max-width: 800px;
79 | height: 100%;
80 | overflow: hidden;
81 | display: flex;
82 | align-items: center;
83 | transform: translate(-50%, -50%);
84 | }
85 |
86 | .blog-BlogHeader__content-container {
87 | display: flex;
88 | flex-direction: row;
89 | align-items: center;
90 | justify-content: center;
91 | margin-top: calc(var(--space) * 4);
92 | margin-left: auto;
93 | margin-right: auto;
94 | }
95 |
96 | .blog-BlogHeader__content {
97 | position: absolute;
98 | top: 50%;
99 | left: 50%;
100 | transform: translate(-50%, -50%);
101 | display: flex;
102 | flex-direction: row;
103 | align-items: center;
104 | flex-wrap: wrap;
105 | align-items: center;
106 | justify-content: center;
107 | line-height: var(--line-height-paragraph);
108 | }
109 |
110 | .blog-BlogHeader__contents-container {
111 | display: flex;
112 | flex-direction: row;
113 | align-items: center;
114 | justify-content: center;
115 | margin-top: calc(var(--space) * 4);
116 | margin-left: auto;
117 | margin-right: auto;
118 | }
119 |
120 | .blog-BlogHeader__contents {
121 | position: absolute;
122 | top: 50%;
123 | left: 50%;
124 | transform: translate(-50%, -50%);
125 | display: flex;
126 | flex-direction: row;
127 | align-items: center;
128 | flex-wrap: wrap;
129 | align-items: center;
130 | justify-content: center;
131 | line-height: var(--line-height-paragraph);
132 | }
133 |
134 | .blog-BlogHeader__main {
135 | position: relative;
136 | top: 0;
137 | left: 0;
138 | margin: 0 0 calc(var(--space) * 2) calc(var(--space) * 4);
139 | display: flex;
140 | flex-direction: column;
141 | flex-wrap: no-wrap;
142 | align-items: center;
143 | justify-content: center;
144 | font-size: var(--font-size-l);
145 | line-height: var(--line-height-paragraph);
146 | }
147 |
148 | .blog-BlogHeader__main-content {
149 | position: relative;
150 | top: 0;
151 | left: 0;
152 | margin: 0 0 calc(var(--space) * 2) calc(var(--space) * 4);
153 | display: flex;
154 | flex-direction: column;
155 | flex-wrap: no-wrap;
156 | align-items: center;
157 | justify-content: center;
158 | font-size: var(--font-size-l);
159 | line-height: var(--line-height-paragraph);
160 | }
161 |
162 | .blog-BlogHeader__main-contents {
163 | position: relative;
164 | top: 0;
165 | left: 0;
166 | margin: 0 0 calc(var(--space) * 2) calc(var(--space) * 4);
167 | display: flex;
168 | flex-direction: column;
169 | flex-wrap: no-wrap;
170 | align-items: center;
171 | justify-content: center;
172 | font-size: var(--font-size-l);
173 | line-height: var(--line-height-paragraph);
174 | }
175 |
176 | .blog-BlogHeader__heading {
177 | font-size: var(--font-size-title);
178 | }
179 |
180 | .blog-BlogHeader__title {
181 | font-size: var(--font-size-title);
182 | }
183 |
184 | .blog-BlogHeader__header {
185 | font-size: var(--font-size-title);
186 | color: orange;
187 | font-family: monospace;
188 | user-select: none;
189 | }
190 |
191 | .blog-BlogHeader__header-link {
192 | color: black;
193 | text-decoration: none;
194 | }
195 |
196 | .blog-BlogHeader__heading-link {
197 | color: black;
198 | text-decoration: none;
199 | }
200 |
201 | .blog-BlogHeader__title-link {
202 | color: black;
203 | text-decoration: none;
204 | }
205 |
206 | .blog-BlogHeader__intro {
207 | font-weight: bold;
208 | margin-left: calc(var(--space) * 4);
209 | }
210 |
211 | .blog-BlogHeader__introduction {
212 | font-weight: bold;
213 | margin-left: calc(var(--space) * 4);
214 | }
215 |
216 | .blog-BlogHeader__logo-container {
217 | display: grid;
218 | grid-gap: calc(var(--space) * 2);
219 | grid-template-columns: 1fr 1fr 1fr;
220 | }
221 |
222 | .blog-BlogHeader__logo {
223 | width: 120px;
224 | height: 60px;
225 | vertical-align: bottom;
226 | object-fit: contain;
227 | }
228 |
--------------------------------------------------------------------------------
/src/foundation/gateway.js:
--------------------------------------------------------------------------------
1 | import timeout from 'race-timeout';
2 | import axiosMod from 'axios';
3 | import AxiosMockAdapter from 'axios-mock-adapter';
4 |
5 | const TIMEOUT = 20 * 1000;
6 | const API_ENDPOINT = window.location.origin;
7 |
8 | const axios = axiosMod.create({
9 | baseURL: API_ENDPOINT,
10 | });
11 |
12 | export function setupMockAPIData() {
13 | const mock = new AxiosMockAdapter(axios, { delayResponse: 250 });
14 |
15 | mock.onGet('/api/blogs').reply(200, {
16 | data: [
17 | {
18 | blog_id: 'b0000',
19 | nickname: 'Mock User 0',
20 | self_introduction: 'This blog is mock-user-0’s blog',
21 | image: 'https://placehold.it/600x300?text=MockUser0',
22 | },
23 | {
24 | blog_id: 'b0001',
25 | nickname: 'Mock User 1',
26 | self_introduction: 'This blog is mock-user-1’s blog',
27 | image: 'https://placehold.it/600x300?text=MockUser1',
28 | },
29 | {
30 | blog_id: 'b0002',
31 | nickname: 'Mock User 2',
32 | self_introduction: 'This blog is mock-user-2’s blog',
33 | image: 'https://placehold.it/600x300?text=MockUser2',
34 | },
35 | {
36 | blog_id: 'b0003',
37 | nickname: 'Mock User 3',
38 | self_introduction: 'This blog is mock-user-3’s blog',
39 | image: 'https://placehold.it/600x300?text=MockUser3',
40 | },
41 | {
42 | blog_id: 'b0004',
43 | nickname: 'Mock User 4',
44 | self_introduction: 'This blog is mock-user-4’s blog',
45 | image: 'https://placehold.it/600x300?text=MockUser4',
46 | },
47 | {
48 | blog_id: 'b0005',
49 | nickname: 'Mock User 5',
50 | self_introduction: 'This blog is mock-user-5’s blog',
51 | image: 'https://placehold.it/600x300?text=MockUser5',
52 | },
53 | {
54 | blog_id: 'b0006',
55 | nickname: 'Mock User 6',
56 | self_introduction: 'This blog is mock-user-6’s blog',
57 | image: 'https://placehold.it/600x300?text=MockUser6',
58 | },
59 | {
60 | blog_id: 'b0007',
61 | nickname: 'Mock User 7',
62 | self_introduction: 'This blog is mock-user-7’s blog',
63 | image: 'https://placehold.it/600x300?text=MockUser7',
64 | },
65 | {
66 | blog_id: 'b0008',
67 | nickname: 'Mock User 8',
68 | self_introduction: 'This blog is mock-user-8’s blog',
69 | image: 'https://placehold.it/600x300?text=MockUser8',
70 | },
71 | {
72 | blog_id: 'b0009',
73 | nickname: 'Mock User 9',
74 | self_introduction: 'This blog is mock-user-9’s blog',
75 | image: 'https://placehold.it/600x300?text=MockUser9',
76 | },
77 | {
78 | blog_id: 'b0010',
79 | nickname: 'Mock User 10',
80 | self_introduction: 'This blog is mock-user-10’s blog',
81 | image: 'https://placehold.it/600x300?text=MockUser10',
82 | },
83 | {
84 | blog_id: 'b0011',
85 | nickname: 'Mock User 11',
86 | self_introduction: 'This blog is mock-user-11’s blog',
87 | image: 'https://placehold.it/600x300?text=MockUser11',
88 | },
89 | {
90 | blog_id: 'b0012',
91 | nickname: 'Mock User 12',
92 | self_introduction: 'This blog is mock-user-12’s blog',
93 | image: 'https://placehold.it/600x300?text=MockUser12',
94 | },
95 | {
96 | blog_id: 'b0013',
97 | nickname: 'Mock User 13',
98 | self_introduction: 'This blog is mock-user-13’s blog',
99 | image: 'https://placehold.it/600x300?text=MockUser13',
100 | },
101 | {
102 | blog_id: 'b0014',
103 | nickname: 'Mock User 14',
104 | self_introduction: 'This blog is mock-user-14’s blog',
105 | image: 'https://placehold.it/600x300?text=MockUser14',
106 | },
107 | {
108 | blog_id: 'b0015',
109 | nickname: 'Mock User 15',
110 | self_introduction: 'This blog is mock-user-15’s blog',
111 | image: 'https://placehold.it/600x300?text=MockUser15',
112 | },
113 | {
114 | blog_id: 'b0016',
115 | nickname: 'Mock User 16',
116 | self_introduction: 'This blog is mock-user-16’s blog',
117 | image: 'https://placehold.it/600x300?text=MockUser16',
118 | },
119 | {
120 | blog_id: 'b0017',
121 | nickname: 'Mock User 17',
122 | self_introduction: 'This blog is mock-user-17’s blog',
123 | image: 'https://placehold.it/600x300?text=MockUser17',
124 | },
125 | {
126 | blog_id: 'b0018',
127 | nickname: 'Mock User 18',
128 | self_introduction: 'This blog is mock-user-18’s blog',
129 | image: 'https://placehold.it/600x300?text=MockUser18',
130 | },
131 | {
132 | blog_id: 'b0019',
133 | nickname: 'Mock User 19',
134 | self_introduction: 'This blog is mock-user-19’s blog',
135 | image: 'https://placehold.it/600x300?text=MockUser19',
136 | },
137 | {
138 | blog_id: 'b0020',
139 | nickname: 'Mock User 20',
140 | self_introduction: 'This blog is mock-user-20’s blog',
141 | image: 'https://placehold.it/600x300?text=MockUser20',
142 | },
143 | {
144 | blog_id: 'b0021',
145 | nickname: 'Mock User 21',
146 | self_introduction: 'This blog is mock-user-21’s blog',
147 | image: 'https://placehold.it/600x300?text=MockUser21',
148 | },
149 | {
150 | blog_id: 'b0022',
151 | nickname: 'Mock User 22',
152 | self_introduction: 'This blog is mock-user-22’s blog',
153 | image: 'https://placehold.it/600x300?text=MockUser22',
154 | },
155 | {
156 | blog_id: 'b0023',
157 | nickname: 'Mock User 23',
158 | self_introduction: 'This blog is mock-user-23’s blog',
159 | image: 'https://placehold.it/600x300?text=MockUser23',
160 | },
161 | {
162 | blog_id: 'b0024',
163 | nickname: 'Mock User 24',
164 | self_introduction: 'This blog is mock-user-24’s blog',
165 | image: 'https://placehold.it/600x300?text=MockUser24',
166 | },
167 | {
168 | blog_id: 'b0025',
169 | nickname: 'Mock User 25',
170 | self_introduction: 'This blog is mock-user-25’s blog',
171 | image: 'https://placehold.it/600x300?text=MockUser25',
172 | },
173 | {
174 | blog_id: 'b0026',
175 | nickname: 'Mock User 26',
176 | self_introduction: 'This blog is mock-user-26’s blog',
177 | image: 'https://placehold.it/600x300?text=MockUser26',
178 | },
179 | {
180 | blog_id: 'b0027',
181 | nickname: 'Mock User 27',
182 | self_introduction: 'This blog is mock-user-27’s blog',
183 | image: 'https://placehold.it/600x300?text=MockUser27',
184 | },
185 | {
186 | blog_id: 'b0028',
187 | nickname: 'Mock User 28',
188 | self_introduction: 'This blog is mock-user-28’s blog',
189 | image: 'https://placehold.it/600x300?text=MockUser28',
190 | },
191 | {
192 | blog_id: 'b0029',
193 | nickname: 'Mock User 29',
194 | self_introduction: 'This blog is mock-user-29’s blog',
195 | image: 'https://placehold.it/600x300?text=MockUser29',
196 | },
197 | {
198 | blog_id: 'b0030',
199 | nickname: 'Mock User 30',
200 | self_introduction: 'This blog is mock-user-30’s blog',
201 | image: 'https://placehold.it/600x300?text=MockUser30',
202 | },
203 | ],
204 | });
205 |
206 | mock.onGet(/^\/api\/blog\/\w+$/).reply(200, {
207 | data: {
208 | blog_id: 'b0000',
209 | nickname: 'Mock User 0',
210 | self_introduction: 'This blog is mock-user-0’s blog',
211 | image: 'https://placehold.it/600x300?text=MockUser0',
212 | },
213 | });
214 |
215 | mock.onGet(/^\/api\/blog\/\w+\/entries$/).reply(200, {
216 | data: [
217 | {
218 | entry_id: 'e0000',
219 | blog_id: 'b0000',
220 | title: '日記 #0',
221 | thumbnail: 'https://placehold.it/600x300?text=Entry+0',
222 | publish_flag: 'open',
223 | published_at: 1585394946024,
224 | like_count: 10,
225 | items: [],
226 | },
227 | {
228 | entry_id: 'e0001',
229 | blog_id: 'b0000',
230 | title: '日記 #1',
231 | thumbnail: 'https://placehold.it/600x300?text=Entry+1',
232 | publish_flag: 'close',
233 | published_at: 1585394946024,
234 | like_count: 10,
235 | items: [],
236 | },
237 | {
238 | entry_id: 'e0002',
239 | blog_id: 'b0000',
240 | title: '日記 #2',
241 | thumbnail: 'https://placehold.it/600x300?text=Entry+2',
242 | publish_flag: 'open',
243 | published_at: 1585394946024,
244 | like_count: 10,
245 | items: [],
246 | },
247 | {
248 | entry_id: 'e0003',
249 | blog_id: 'b0000',
250 | title: '日記 #3',
251 | thumbnail: 'https://placehold.it/600x300?text=Entry+3',
252 | publish_flag: 'open',
253 | published_at: 1585394946024,
254 | like_count: 10,
255 | items: [],
256 | },
257 | {
258 | entry_id: 'e0004',
259 | blog_id: 'b0000',
260 | title: '日記 #4',
261 | thumbnail: 'https://placehold.it/600x300?text=Entry+4',
262 | publish_flag: 'open',
263 | published_at: 1585394946024,
264 | like_count: 10,
265 | items: [],
266 | },
267 | {
268 | entry_id: 'e0005',
269 | blog_id: 'b0000',
270 | title: '日記 #5',
271 | thumbnail: 'https://placehold.it/600x300?text=Entry+5',
272 | publish_flag: 'open',
273 | published_at: 1585394946024,
274 | like_count: 10,
275 | items: [],
276 | },
277 | {
278 | entry_id: 'e0006',
279 | blog_id: 'b0000',
280 | title: '日記 #6',
281 | thumbnail: 'https://placehold.it/600x300?text=Entry+6',
282 | publish_flag: 'open',
283 | published_at: 1585394946024,
284 | like_count: 10,
285 | items: [],
286 | },
287 | ],
288 | });
289 |
290 | mock.onGet(/^\/api\/blog\/\w+\/entry\/\w+$/).reply(200, {
291 | data: {
292 | entry_id: 'e0000',
293 | blog_id: 'b0000',
294 | title: '日記 #0',
295 | thumbnail: 'https://placehold.it/600x300?text=Entry+0',
296 | publish_flag: 'open',
297 | published_at: 1585394946024,
298 | like_count: 10,
299 | items: [
300 | {
301 | type: 'headline',
302 | data: {
303 | level: 2,
304 | text: 'あのイーハトーヴォのすきとおった風',
305 | },
306 | },
307 | {
308 | type: 'paragraph',
309 | data: {
310 | text:
311 | 'あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。またそのなかでいっしょになったたくさんのひとたち、ファゼーロとロザーロ、羊飼のミーロや、顔の赤いこどもたち、地主のテーモ、山猫博士のボーガント・デストゥパーゴなど、いまこの暗い巨きな石の建物のなかで考えていると、みんなむかし風のなつかしい青い幻燈のように思われます。では、わたくしはいつかの小さなみだしをつけながら、しずかにあの年のイーハトーヴォの五月から十月までを書きつけましょう。',
312 | },
313 | },
314 | {
315 | type: 'link',
316 | data: {
317 | url: 'https://example.com',
318 | text: 'そのころわたくしは、モリーオ市の博物局に勤めて居りました。',
319 | },
320 | },
321 | {
322 | type: 'image',
323 | data: {
324 | url: 'https://placehold.it/600x300?text=image',
325 | width: 200,
326 | height: 100,
327 | caption: 'あのイーハトーヴォのすきとおった風',
328 | },
329 | },
330 | {
331 | type: 'video',
332 | data: {
333 | url:
334 | 'https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4',
335 | width: 400,
336 | height: 400 * (9 / 16),
337 | },
338 | },
339 | {
340 | type: 'headline',
341 | data: {
342 | level: 2,
343 | text: '遁げた山羊',
344 | },
345 | },
346 | {
347 | type: 'paragraph',
348 | data: {
349 | text:
350 | '五月のしまいの日曜でした。わたくしは賑やかな市の教会の鐘の音で眼をさましました。もう日はよほど登って、まわりはみんなきらきらしていました。時計を見るとちょうど六時でした。わたくしはすぐチョッキだけ着て山羊を見に行きました。すると小屋のなかはしんとして藁が凹んでいるだけで、あのみじかい角も白い髯も見えませんでした。',
351 | },
352 | },
353 | {
354 | type: 'paragraph',
355 | data: {
356 | text:
357 | '「あんまりいい天気なもんだから大将ひとりででかけたな。」わたくしは半分わらうように半分つぶやくようにしながら、向うの信号所からいつも放して遊ばせる輪道の内側の野原、ポプラの中から顔をだしている市はずれの白い教会の塔までぐるっと見まわしました。けれどもどこにもあの白い頭もせなかも見えていませんでした。うまやを一まわりしてみましたがやっぱりどこにも居ませんでした。',
358 | },
359 | },
360 | ],
361 | },
362 | });
363 |
364 | mock.onPost(/^\/api\/blog\/\w+\/entry\/\w+\/like$/).reply(200);
365 |
366 | mock.onGet(/^\/api\/blog\/\w+\/entry\/\w+\/comments$/).reply(200, {
367 | data: [
368 | {
369 | comment_id: 'c0000',
370 | entry_id: 'e0000',
371 | blog_id: 'b0000',
372 | title: 'Title #0',
373 | comment: 'Comment for 日記 #0 blog #0',
374 | posted_at: 1585394946024,
375 | commenter: {
376 | user_id: 'u0000',
377 | user_name: 'みゃーもり',
378 | image: 'https://placehold.it/600x600?text=MockUser10',
379 | },
380 | },
381 | {
382 | comment_id: 'c0001',
383 | entry_id: 'e0000',
384 | blog_id: 'b0000',
385 | title: 'Title #1',
386 | comment: 'Comment for 日記 #0 blog #1',
387 | posted_at: 1585394946024,
388 | commenter: {
389 | user_id: 'u0000',
390 | user_name: 'みゃーもり',
391 | image: 'https://placehold.it/600x600?text=MockUser10',
392 | },
393 | },
394 | {
395 | comment_id: 'c0002',
396 | entry_id: 'e0000',
397 | blog_id: 'b0000',
398 | title: 'Title #2',
399 | comment: 'Comment for 日記 #0 blog #2',
400 | posted_at: 1585394946024,
401 | commenter: {
402 | user_id: 'u0000',
403 | user_name: 'みゃーもり',
404 | image: 'https://placehold.it/600x600?text=MockUser10',
405 | },
406 | },
407 | {
408 | comment_id: 'c0003',
409 | entry_id: 'e0000',
410 | blog_id: 'b0000',
411 | title: 'Title #3',
412 | comment: 'Comment for 日記 #0 blog #3',
413 | posted_at: 1585394946024,
414 | commenter: {
415 | user_id: 'u0000',
416 | user_name: 'みゃーもり',
417 | image: 'https://placehold.it/600x600?text=MockUser10',
418 | },
419 | },
420 | {
421 | comment_id: 'c0004',
422 | entry_id: 'e0000',
423 | blog_id: 'b0000',
424 | title: 'Title #4',
425 | comment: 'Comment for 日記 #0 blog #4',
426 | posted_at: 1585394946024,
427 | commenter: {
428 | user_id: 'u0000',
429 | user_name: 'みゃーもり',
430 | image: 'https://placehold.it/600x600?text=MockUser10',
431 | },
432 | },
433 | {
434 | comment_id: 'c0005',
435 | entry_id: 'e0000',
436 | blog_id: 'b0000',
437 | title: 'Title #5',
438 | comment: 'Comment for 日記 #0 blog #5',
439 | posted_at: 1585394946024,
440 | commenter: {
441 | user_id: 'u0000',
442 | user_name: 'みゃーもり',
443 | image: 'https://placehold.it/600x600?text=MockUser10',
444 | },
445 | },
446 | ],
447 | });
448 | }
449 |
450 | export async function fetch(path) {
451 | const requestWithTimeout = timeout(axios.get(path), TIMEOUT);
452 | const res = await requestWithTimeout;
453 |
454 | if (res === 'timeout') {
455 | throw new Error(`Timeout: ${path}`);
456 | }
457 |
458 | const payload = res?.data?.data;
459 |
460 | if (!payload || typeof payload !== 'object') {
461 | throw new Error(`Invalid response for ${path}: ${JSON.stringify(res)}`);
462 | }
463 |
464 | return payload;
465 | }
466 |
467 | export async function post(path, data) {
468 | const requestWithTimeout = timeout(axios.post(path, data), TIMEOUT);
469 | const res = await requestWithTimeout;
470 |
471 | if (res.status !== 200) {
472 | throw new Error(
473 | `Invalid response for ${path} with ${JSON.stringify(data)}: status ${
474 | res.status
475 | }`,
476 | );
477 | }
478 |
479 | return res;
480 | }
481 |
--------------------------------------------------------------------------------