├── .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 | 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 |
10 | 16 |
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 |
17 | 21 | Tweet 22 | 23 |
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 |
11 |

404 Not Found

12 | 13 | 19 | By William Wolfgang Wunderbar (giphy.com) 20 | 21 |
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 |
10 | 11 |
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 |
25 |
26 |
35 |
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 |
17 | 24 | このエントリーをはてなブックマークに追加 31 | 32 |
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 |
13 |
14 | 15 |
16 |
17 |

18 | {comment.commenter.user_name} 19 |

20 |

{comment.comment}

21 |
22 | 23 | 29 | 30 |
31 |
32 |
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 |
20 | 21 |
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 | 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 |
56 |

記事一覧

57 | 58 |
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 |
29 | 35 |
36 | {caption} 42 |
43 | {caption} 44 |
45 |
46 |
47 |
48 | ); 49 | } 50 | 51 | function Video({ url, width, height }) { 52 | return ( 53 |