├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── codegen.yml ├── docker-compose.yaml ├── hasura ├── config.yaml ├── metadata │ ├── actions.graphql │ ├── actions.yaml │ ├── allow_list.yaml │ ├── cron_triggers.yaml │ ├── functions.yaml │ ├── query_collections.yaml │ ├── remote_schemas.yaml │ ├── tables.yaml │ └── version.yaml └── migrations │ └── 1598899352025_create-users-articles │ ├── down.sql │ └── up.sql ├── jest.config.js ├── next-env.d.ts ├── package.json ├── public ├── favicon.ico ├── profile.png └── vercel.svg ├── src ├── base.css ├── components │ ├── article │ │ ├── article-footer.tsx │ │ ├── article-header.tsx │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── paragraph.tsx │ ├── button │ │ ├── index.module.css │ │ └── index.tsx │ ├── editor │ │ ├── index.module.css │ │ └── index.tsx │ ├── logo │ │ └── index.tsx │ ├── site-header │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── item.tsx │ └── user-icon │ │ ├── index.module.css │ │ └── index.tsx ├── pages │ ├── [userId] │ │ └── [articleId] │ │ │ ├── get-article.graphql │ │ │ ├── index.module.css │ │ │ └── index.tsx │ ├── _app.tsx │ ├── index.tsx │ └── post │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── post-article.graphql └── utils │ ├── article.ts │ ├── articles.test.ts │ ├── date.test.ts │ ├── date.ts │ └── index.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | src/generated 2 | 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "plugins": ["jest", "react"], 4 | "env": { 5 | "es2020": true, 6 | "jest/globals": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:prettier/recommended", 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:react-hooks/recommended" 14 | ], 15 | "rules": { 16 | "no-mixed-operators": "error", 17 | "no-console": "off", 18 | "no-undef": "off", 19 | "react/jsx-uses-vars": 1, 20 | "@typescript-eslint/explicit-function-return-type": "off", 21 | "@typescript-eslint/explicit-module-boundary-types": "off", 22 | "@typescript-eslint/no-non-null-assertion": "off" 23 | }, 24 | "parserOptions": { 25 | "project": "./tsconfig.json" 26 | }, 27 | "parser": "@typescript-eslint/parser" 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | .env 37 | src/generated 38 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/generated 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true, 4 | "semi": false, 5 | "trailingComma": "all", 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js + Hasura プロトタイピングサンプル 2 | 3 | ## License 4 | 5 | ``` 6 | Copyright 2020 SASAKI Shunsuke 7 | https://github.com/erukiti/prototyping-sample 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining 10 | a copy of this software and associated documentation files (the "Software"), 11 | to deal in the Software without restriction, including without limitation 12 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 13 | and/or sell copies of the Software, and to permit persons to whom the 14 | Software is furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included 17 | in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 20 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 22 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | ``` 27 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | schema: http://localhost:8080/v1/graphql/ 2 | documents: 3 | - ./src/**/*.graphql 4 | overwrite: true 5 | generates: 6 | ./src/generated/graphql.ts: 7 | plugins: 8 | - typescript 9 | - typescript-operations 10 | - typescript-react-apollo 11 | config: 12 | skipTypename: false 13 | withHooks: true 14 | withHOC: false 15 | withComponent: false 16 | scalars: 17 | timestamptz: string 18 | uuid: string 19 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | postgres: 4 | image: postgres:12 5 | restart: always 6 | volumes: 7 | - db_data_prototyping_sample-1:/var/lib/postgresql/data 8 | environment: 9 | POSTGRES_PASSWORD: postgrespassword 10 | graphql-engine: 11 | image: hasura/graphql-engine:v1.3.1 12 | ports: 13 | - "8080:8080" 14 | depends_on: 15 | - "postgres" 16 | restart: always 17 | environment: 18 | HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres 19 | ## enable the console served by server 20 | HASURA_GRAPHQL_ENABLE_CONSOLE: "false" # set to "false" to disable console 21 | ## enable debugging mode. It is recommended to disable this in production 22 | HASURA_GRAPHQL_DEV_MODE: "true" 23 | HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log 24 | ## uncomment next line to set an admin secret 25 | # HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey 26 | volumes: 27 | db_data_prototyping_sample-1: 28 | 29 | -------------------------------------------------------------------------------- /hasura/config.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | endpoint: http://localhost:8080 3 | metadata_directory: metadata 4 | actions: 5 | kind: synchronous 6 | handler_webhook_baseurl: http://localhost:3000 7 | -------------------------------------------------------------------------------- /hasura/metadata/actions.graphql: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /hasura/metadata/actions.yaml: -------------------------------------------------------------------------------- 1 | actions: [] 2 | custom_types: 3 | enums: [] 4 | input_objects: [] 5 | objects: [] 6 | scalars: [] 7 | -------------------------------------------------------------------------------- /hasura/metadata/allow_list.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /hasura/metadata/cron_triggers.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /hasura/metadata/functions.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /hasura/metadata/query_collections.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /hasura/metadata/remote_schemas.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /hasura/metadata/tables.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: articles 4 | configuration: 5 | custom_root_fields: {} 6 | custom_column_names: 7 | author_id: authorId 8 | updated_at: updatedAt 9 | published_at: publishedAt 10 | created_at: createdAt 11 | object_relationships: 12 | - name: user 13 | using: 14 | foreign_key_constraint_on: author_id 15 | - table: 16 | schema: public 17 | name: users 18 | configuration: 19 | custom_root_fields: {} 20 | custom_column_names: 21 | display_name: displayName 22 | display_id: displayId 23 | updated_at: updatedAt 24 | created_at: createdAt 25 | array_relationships: 26 | - name: articles 27 | using: 28 | foreign_key_constraint_on: 29 | column: author_id 30 | table: 31 | schema: public 32 | name: articles 33 | -------------------------------------------------------------------------------- /hasura/metadata/version.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | -------------------------------------------------------------------------------- /hasura/migrations/1598899352025_create-users-articles/down.sql: -------------------------------------------------------------------------------- 1 | 2 | 3 | ALTER TABLE "public"."users" ALTER COLUMN "updated_at" DROP NOT NULL; 4 | 5 | ALTER TABLE "public"."users" ALTER COLUMN "created_at" DROP NOT NULL; 6 | 7 | DROP TRIGGER IF EXISTS "set_public_users_updated_at" ON "public"."users"; 8 | ALTER TABLE "public"."users" DROP COLUMN "updated_at"; 9 | 10 | ALTER TABLE "public"."users" DROP COLUMN "created_at"; 11 | 12 | ALTER TABLE "public"."users" DROP CONSTRAINT "users_display_id_key"; 13 | 14 | DROP TABLE "public"."articles"; 15 | 16 | DROP TABLE "public"."users"; 17 | 18 | ALTER TABLE "public"."users" ALTER COLUMN "updated_at" DROP NOT NULL; 19 | 20 | ALTER TABLE "public"."users" ALTER COLUMN "created_at" DROP NOT NULL; 21 | 22 | DROP TRIGGER IF EXISTS "set_public_users_updated_at" ON "public"."users"; 23 | ALTER TABLE "public"."users" DROP COLUMN "updated_at"; 24 | 25 | ALTER TABLE "public"."users" DROP COLUMN "created_at"; 26 | 27 | ALTER TABLE "public"."users" DROP CONSTRAINT "users_display_id_key"; 28 | 29 | DROP TABLE "public"."articles"; 30 | 31 | DROP TABLE "public"."users"; 32 | -------------------------------------------------------------------------------- /hasura/migrations/1598899352025_create-users-articles/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE TABLE "public"."users"("id" UUID NOT NULL DEFAULT gen_random_uuid(), "display_id" text NOT NULL, "display_name" text NOT NULL, PRIMARY KEY ("id") , UNIQUE ("id")); 3 | 4 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 5 | CREATE TABLE "public"."articles"("id" uuid NOT NULL DEFAULT gen_random_uuid(), "subject" text NOT NULL, "content" text NOT NULL, "author_id" uuid NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "published_at" timestamptz, PRIMARY KEY ("id") , FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id")); 6 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 7 | RETURNS TRIGGER AS $$ 8 | DECLARE 9 | _new record; 10 | BEGIN 11 | _new := NEW; 12 | _new."updated_at" = NOW(); 13 | RETURN _new; 14 | END; 15 | $$ LANGUAGE plpgsql; 16 | CREATE TRIGGER "set_public_articles_updated_at" 17 | BEFORE UPDATE ON "public"."articles" 18 | FOR EACH ROW 19 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 20 | COMMENT ON TRIGGER "set_public_articles_updated_at" ON "public"."articles" 21 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 22 | 23 | ALTER TABLE "public"."users" ADD CONSTRAINT "users_display_id_key" UNIQUE ("display_id"); 24 | 25 | ALTER TABLE "public"."users" ADD COLUMN "created_at" timestamptz NULL DEFAULT now(); 26 | 27 | ALTER TABLE "public"."users" ADD COLUMN "updated_at" timestamptz NULL DEFAULT now(); 28 | 29 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 30 | RETURNS TRIGGER AS $$ 31 | DECLARE 32 | _new record; 33 | BEGIN 34 | _new := NEW; 35 | _new."updated_at" = NOW(); 36 | RETURN _new; 37 | END; 38 | $$ LANGUAGE plpgsql; 39 | CREATE TRIGGER "set_public_users_updated_at" 40 | BEFORE UPDATE ON "public"."users" 41 | FOR EACH ROW 42 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 43 | COMMENT ON TRIGGER "set_public_users_updated_at" ON "public"."users" 44 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 45 | 46 | ALTER TABLE "public"."users" ALTER COLUMN "created_at" SET NOT NULL; 47 | 48 | ALTER TABLE "public"."users" ALTER COLUMN "updated_at" SET NOT NULL; 49 | 50 | 51 | CREATE TABLE "public"."users"("id" UUID NOT NULL DEFAULT gen_random_uuid(), "display_id" text NOT NULL, "display_name" text NOT NULL, PRIMARY KEY ("id") , UNIQUE ("id")); 52 | 53 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 54 | CREATE TABLE "public"."articles"("id" uuid NOT NULL DEFAULT gen_random_uuid(), "subject" text NOT NULL, "content" text NOT NULL, "author_id" uuid NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "published_at" timestamptz, PRIMARY KEY ("id") , FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id")); 55 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 56 | RETURNS TRIGGER AS $$ 57 | DECLARE 58 | _new record; 59 | BEGIN 60 | _new := NEW; 61 | _new."updated_at" = NOW(); 62 | RETURN _new; 63 | END; 64 | $$ LANGUAGE plpgsql; 65 | CREATE TRIGGER "set_public_articles_updated_at" 66 | BEFORE UPDATE ON "public"."articles" 67 | FOR EACH ROW 68 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 69 | COMMENT ON TRIGGER "set_public_articles_updated_at" ON "public"."articles" 70 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 71 | 72 | ALTER TABLE "public"."users" ADD CONSTRAINT "users_display_id_key" UNIQUE ("display_id"); 73 | 74 | ALTER TABLE "public"."users" ADD COLUMN "created_at" timestamptz NULL DEFAULT now(); 75 | 76 | ALTER TABLE "public"."users" ADD COLUMN "updated_at" timestamptz NULL DEFAULT now(); 77 | 78 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 79 | RETURNS TRIGGER AS $$ 80 | DECLARE 81 | _new record; 82 | BEGIN 83 | _new := NEW; 84 | _new."updated_at" = NOW(); 85 | RETURN _new; 86 | END; 87 | $$ LANGUAGE plpgsql; 88 | CREATE TRIGGER "set_public_users_updated_at" 89 | BEFORE UPDATE ON "public"."users" 90 | FOR EACH ROW 91 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 92 | COMMENT ON TRIGGER "set_public_users_updated_at" ON "public"."users" 93 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 94 | 95 | ALTER TABLE "public"."users" ALTER COLUMN "created_at" SET NOT NULL; 96 | 97 | ALTER TABLE "public"."users" ALTER COLUMN "updated_at" SET NOT NULL; 98 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | globals: { 5 | 'ts-jest': { 6 | diagnostics: false, 7 | }, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prototyping-sample", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@apollo/client": "^3.1.4", 12 | "@apollo/react-hooks": "^4.0.0", 13 | "@fortawesome/fontawesome-svg-core": "^1.2.30", 14 | "@fortawesome/free-brands-svg-icons": "^5.14.0", 15 | "@fortawesome/free-solid-svg-icons": "^5.14.0", 16 | "@fortawesome/react-fontawesome": "^0.1.11", 17 | "autosize": "^4.0.2", 18 | "graphql": "^15.3.0", 19 | "minireset.css": "^0.0.6", 20 | "next": "9.5.2", 21 | "react": "16.13.1", 22 | "react-dom": "16.13.1" 23 | }, 24 | "devDependencies": { 25 | "@graphql-codegen/cli": "^1.17.8", 26 | "@graphql-codegen/typescript": "^1.17.9", 27 | "@graphql-codegen/typescript-operations": "^1.17.8", 28 | "@graphql-codegen/typescript-react-apollo": "^2.0.6", 29 | "@types/autosize": "^3.0.7", 30 | "@types/jest": "^26.0.10", 31 | "@types/node": "^14.6.2", 32 | "@types/react": "^16.9.48", 33 | "@types/react-dom": "^16.9.8", 34 | "@typescript-eslint/eslint-plugin": "^4.0.0", 35 | "@typescript-eslint/parser": "^4.0.0", 36 | "eslint": "^7.7.0", 37 | "eslint-config-prettier": "^6.11.0", 38 | "eslint-plugin-jest": "^23.20.0", 39 | "eslint-plugin-prettier": "^3.1.4", 40 | "eslint-plugin-react": "^7.20.6", 41 | "eslint-plugin-react-hooks": "^4.1.0", 42 | "hasura-cli": "^1.3.1", 43 | "jest": "^26.4.2", 44 | "prettier": "^2.1.1", 45 | "ts-jest": "^26.3.0", 46 | "typescript": "^4.0.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erukiti/prototyping-sample/21e3701d1f3035b061a85f4f98fbc6c699df005b/public/favicon.ico -------------------------------------------------------------------------------- /public/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erukiti/prototyping-sample/21e3701d1f3035b061a85f4f98fbc6c699df005b/public/profile.png -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN', 3 | 'Hiragino Sans', Meiryo, sans-serif; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | :focus { 9 | outline: none; 10 | } 11 | 12 | input { 13 | border: none; 14 | } 15 | 16 | textarea { 17 | border: none; 18 | resize: none; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/article/article-footer.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 2 | import { faEllipsisH, faThumbsUp } from '@fortawesome/free-solid-svg-icons' 3 | import { faTwitter, faFacebook } from '@fortawesome/free-brands-svg-icons' 4 | import { Users } from '@/generated/graphql' 5 | import { UserIcon } from '@/components/user-icon' 6 | import { Button } from '@/components/button' 7 | import styles from './index.module.css' 8 | 9 | type ArticleFooterProps = { 10 | user: Pick 11 | } 12 | export const AritcleFooter: React.FC = ({ user }) => { 13 | return ( 14 | <> 15 | {' '} 16 | 17 | 18 | 19 | 65536イイネ! 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {user.displayName} 31 | 32 | ほげほげでふがふがをやっています。 33 | 34 | 35 | 36 | Follow 37 | 38 | 39 | > 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/article/article-header.tsx: -------------------------------------------------------------------------------- 1 | import { Users } from '@/generated/graphql' 2 | import { UserIcon } from '@/components/user-icon' 3 | import { formatDate } from '@/utils/date' 4 | import { Button } from '@/components/button' 5 | import styles from './index.module.css' 6 | 7 | type ArticleHeaderProps = { 8 | subject: string 9 | user: Pick 10 | publishedAt: string 11 | } 12 | 13 | export const ArticleHeader: React.FC = ({ 14 | subject, 15 | user, 16 | publishedAt, 17 | }) => { 18 | const { datetime, isNew } = formatDate(new Date(publishedAt), new Date()) 19 | return ( 20 | <> 21 | {subject} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {user.displayName} Follow 30 | 31 | 32 | {datetime} 33 | {isNew ? New : ''} 34 | 約4分で読めます。 35 | 36 | 37 | 38 | > 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/article/index.module.css: -------------------------------------------------------------------------------- 1 | .paragraph { 2 | margin-top: 1rem; 3 | } 4 | 5 | .subject { 6 | margin-top: 32px; 7 | font-size: 40px; 8 | line-height: 48px; 9 | } 10 | 11 | .userContainer { 12 | margin-top: 16px; 13 | display: flex; 14 | align-items: center; 15 | } 16 | 17 | .userText { 18 | margin-left: 12px; 19 | } 20 | 21 | .userName { 22 | font-size: 16px; 23 | line-height: 16px; 24 | } 25 | 26 | .publishedAt { 27 | font-size: 16px; 28 | opacity: 0.5; 29 | } 30 | 31 | .newContent { 32 | margin-left: 8px; 33 | color: #22c; 34 | } 35 | 36 | .icon { 37 | margin-left: 8px; 38 | } 39 | 40 | .icon + .icon { 41 | margin-left: 4px; 42 | } 43 | 44 | .articleFooter { 45 | margin-top: 16px; 46 | border-top: 1px solid #888; 47 | border-bottom: 1px solid #888; 48 | display: flex; 49 | padding: 16px 0; 50 | } 51 | 52 | .userDescription { 53 | font-size: 14px; 54 | } 55 | 56 | .articleFooterContainer { 57 | margin: 32px 0; 58 | display: flex; 59 | align-items: center; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/article/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { formatArticle } from '@/utils/article' 4 | 5 | import { Paragraph } from './paragraph' 6 | 7 | type Props = { 8 | content: string 9 | } 10 | 11 | export const Article: React.FC = ({ content }) => { 12 | return ( 13 | <> 14 | {formatArticle(content).map((p, i) => ( 15 | 16 | ))} 17 | > 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/components/article/paragraph.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './index.module.css' 4 | 5 | type Props = { 6 | p: string 7 | } 8 | 9 | export const Paragraph: React.FC = ({ p }) => { 10 | return {p} 11 | } 12 | -------------------------------------------------------------------------------- /src/components/button/index.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | border: 1px solid #222; 3 | padding: 4px 12px; 4 | border-radius: 4px; 5 | color: #222; 6 | cursor: pointer; 7 | user-select: none; 8 | } 9 | 10 | .button:hover { 11 | background-color: #49f; 12 | transition-duration: 200ms; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/button/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { useClassNames } from '@/utils' 4 | 5 | import styles from './index.module.css' 6 | 7 | type Props = 8 | | (React.ComponentPropsWithRef<'button'> & { as?: 'button' }) 9 | | (React.ComponentPropsWithRef<'a'> & { as: 'a' }) 10 | 11 | export const Button: React.FC = ({ 12 | className, 13 | children, 14 | as = 'button', 15 | ...rest 16 | }) => { 17 | const _className = useClassNames(styles.button, className) 18 | return React.createElement(as, { ...rest, className: _className }, children) 19 | } 20 | -------------------------------------------------------------------------------- /src/components/editor/index.module.css: -------------------------------------------------------------------------------- 1 | .editor { 2 | width: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/editor/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import autosize from 'autosize' 3 | 4 | import { useClassNames } from '@/utils' 5 | 6 | import styles from './index.module.css' 7 | 8 | type Props = { 9 | value: string 10 | /** onChange だと event を受け取る感じになりそうなので、onEdit に */ 11 | onEdit: (text: string) => void 12 | placeholder?: string 13 | className?: string 14 | } 15 | 16 | export const Editor: React.FC = ({ 17 | className, 18 | placeholder, 19 | value, 20 | onEdit, 21 | }) => { 22 | const ref = React.useRef(null) 23 | 24 | React.useEffect(() => { 25 | if (ref.current) { 26 | autosize(ref.current) 27 | } 28 | }, []) 29 | 30 | const handleChange = React.useCallback( 31 | (ev: React.ChangeEvent) => { 32 | onEdit(ev.target.value) 33 | }, 34 | [onEdit], 35 | ) 36 | 37 | const _className = useClassNames(styles.editor, className) 38 | return ( 39 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/components/logo/index.tsx: -------------------------------------------------------------------------------- 1 | export const Logo: React.FC = () => { 2 | return 凄いブログ 3 | } 4 | -------------------------------------------------------------------------------- /src/components/site-header/index.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | align-items: center; 4 | height: 80px; 5 | } 6 | 7 | .left { 8 | margin: 16px auto 16px 48px; 9 | display: flex; 10 | align-items: center; 11 | } 12 | 13 | .right { 14 | margin: 16px 48px 16px auto; 15 | display: flex; 16 | align-items: center; 17 | } 18 | 19 | .item + .item { 20 | margin-left: 24px; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/site-header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useRouter } from 'next/router' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faEdit } from '@fortawesome/free-solid-svg-icons' 5 | 6 | import { UserIcon } from '@/components/user-icon' 7 | import { Logo } from '@/components/logo' 8 | import { Button } from '@/components/button' 9 | 10 | import styles from './index.module.css' 11 | import { SiteHeaderItem } from './item' 12 | 13 | export { SiteHeaderItem } 14 | 15 | type Props = { 16 | left?: JSX.Element 17 | right?: JSX.Element 18 | } 19 | 20 | export const SiteHeader: React.FC = ({ left, right }) => { 21 | const router = useRouter() 22 | 23 | const handleClickLogo = React.useCallback(() => { 24 | router.push('/') 25 | }, [router]) 26 | 27 | const handleClickPost = React.useCallback(() => { 28 | router.push('/post') 29 | }, [router]) 30 | 31 | const leftElement = left ? ( 32 | left 33 | ) : ( 34 | 35 | 36 | 37 | ) 38 | 39 | const rightElement = right ? ( 40 | right 41 | ) : ( 42 | <> 43 | 44 | 45 | 46 | 47 | 記事作成 48 | 49 | 50 | 51 | 52 | 53 | > 54 | ) 55 | return ( 56 | 57 | {leftElement} 58 | {rightElement} 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/components/site-header/item.tsx: -------------------------------------------------------------------------------- 1 | import { useClassNames } from '@/utils' 2 | 3 | import styles from './index.module.css' 4 | 5 | type Props = { 6 | className?: string 7 | } 8 | 9 | export const SiteHeaderItem: React.FC = ({ className, children }) => { 10 | const _className = useClassNames(styles.item, className) 11 | 12 | return {children} 13 | } 14 | -------------------------------------------------------------------------------- /src/components/user-icon/index.module.css: -------------------------------------------------------------------------------- 1 | .userIcon { 2 | width: 48px; 3 | height: 48px; 4 | border-radius: 24px; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/user-icon/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './index.module.css' 2 | 3 | type Props = { 4 | src: string 5 | } 6 | 7 | export const UserIcon: React.FC = ({ src }) => { 8 | return 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/[userId]/[articleId]/get-article.graphql: -------------------------------------------------------------------------------- 1 | query getArticle($id: uuid!) { 2 | articles_by_pk(id: $id) { 3 | subject 4 | user { 5 | displayId 6 | displayName 7 | } 8 | createdAt 9 | publishedAt 10 | updatedAt 11 | content 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/[userId]/[articleId]/index.module.css: -------------------------------------------------------------------------------- 1 | .contentContainer { 2 | width: 700px; 3 | margin: 0 auto; 4 | font-size: 20px; 5 | line-height: 30px; 6 | } 7 | 8 | .content { 9 | margin-top: 32px; 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/[userId]/[articleId]/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { NextPage } from 'next' 3 | import Error from 'next/error' 4 | 5 | import { useGetArticleQuery } from '@/generated/graphql' 6 | import { Article } from '@/components/article' 7 | import { ArticleHeader } from '@/components/article/article-header' 8 | import { AritcleFooter } from '@/components/article/article-footer' 9 | import { SiteHeader } from '@/components/site-header' 10 | 11 | import styles from './index.module.css' 12 | 13 | const ArticlePage: NextPage = () => { 14 | const router = useRouter() 15 | const { articleId } = router.query 16 | 17 | const { loading, error, data } = useGetArticleQuery({ 18 | variables: { 19 | id: articleId as string, 20 | }, 21 | }) 22 | 23 | if (loading) { 24 | return ...loading 25 | } 26 | if (error) { 27 | return {error.toString()} 28 | } 29 | 30 | if (!data || !data.articles_by_pk) { 31 | return 32 | } 33 | 34 | const { user, subject, content, publishedAt } = data.articles_by_pk 35 | if (!publishedAt) { 36 | return 37 | } 38 | const articleHeaderProps = { user, subject, publishedAt } 39 | 40 | return ( 41 | <> 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | > 51 | ) 52 | } 53 | 54 | export default ArticlePage 55 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app' 2 | import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client' 3 | import { ApolloProvider } from '@apollo/react-hooks' 4 | 5 | import 'minireset.css' 6 | import '../base.css' 7 | 8 | const createApolloClient = () => { 9 | return new ApolloClient({ 10 | link: new HttpLink({ 11 | uri: 'http://localhost:8080/v1/graphql', 12 | }), 13 | cache: new InMemoryCache(), 14 | }) 15 | } 16 | 17 | const MyApp = ({ Component, pageProps }: AppProps) => { 18 | const client = createApolloClient() 19 | 20 | return ( 21 | 22 | 23 | 24 | ) 25 | } 26 | 27 | export default MyApp 28 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next' 2 | 3 | const Index: NextPage = () => { 4 | return hoge 5 | } 6 | 7 | export default Index 8 | -------------------------------------------------------------------------------- /src/pages/post/index.module.css: -------------------------------------------------------------------------------- 1 | .editContent { 2 | width: 640px; 3 | margin: 0 auto; 4 | } 5 | 6 | .subject { 7 | font-size: 36px; 8 | width: 100%; 9 | } 10 | 11 | .editor { 12 | margin-top: 24px; 13 | font-size: 24px; 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/post/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next' 2 | import { useState, useCallback } from 'react' 3 | import { useRouter } from 'next/router' 4 | 5 | import { Editor } from '@/components/editor' 6 | import { SiteHeader, SiteHeaderItem } from '@/components/site-header' 7 | import { Button } from '@/components/button' 8 | import { UserIcon } from '@/components/user-icon' 9 | import { usePostArticleMutation } from '@/generated/graphql' 10 | 11 | import styles from './index.module.css' 12 | 13 | const PostPage: NextPage = () => { 14 | const [subject, setSubject] = useState('') 15 | const [content, setContent] = useState('') 16 | const [postArticle] = usePostArticleMutation() 17 | const [postDisabled, setPostDisabled] = useState(false) 18 | const router = useRouter() 19 | 20 | const handleChangeSubject = useCallback( 21 | (ev: React.ChangeEvent) => { 22 | setSubject(ev.target.value) 23 | }, 24 | [], 25 | ) 26 | 27 | const handlePost = useCallback( 28 | async (ev: React.FormEvent) => { 29 | ev.preventDefault() 30 | if (!content || !subject || postDisabled) { 31 | return 32 | } 33 | 34 | setPostDisabled(true) 35 | const { data } = await postArticle({ 36 | variables: { 37 | // FIXME: authorIdはいったん決め打ちにする 38 | authorId: '417b6cb6-718e-40f2-bf2a-ea8877ac688b', 39 | content, 40 | subject, 41 | publishedAt: 'now()', 42 | }, 43 | }) 44 | if (data && data.insert_articles_one) { 45 | const aritcleId = data.insert_articles_one.id 46 | // FIXME ユーザーID決め打ち 47 | router.push(`/hoge/${aritcleId}`) 48 | setPostDisabled(false) 49 | } else { 50 | console.log('POST unknown state', data) 51 | } 52 | }, 53 | [content, subject, postDisabled, postArticle, router], 54 | ) 55 | 56 | const siteHeaderRight = ( 57 | <> 58 | 59 | 60 | 61 | 投稿する 62 | 63 | 64 | 65 | 66 | 67 | 68 | > 69 | ) 70 | 71 | return ( 72 | <> 73 | 74 | 75 | 82 | 88 | 89 | > 90 | ) 91 | } 92 | 93 | export default PostPage 94 | -------------------------------------------------------------------------------- /src/pages/post/post-article.graphql: -------------------------------------------------------------------------------- 1 | mutation PostArticle( 2 | $authorId: uuid! 3 | $content: String! 4 | $subject: String! 5 | $publishedAt: timestamptz 6 | ) { 7 | insert_articles_one( 8 | object: { 9 | authorId: $authorId 10 | content: $content 11 | subject: $subject 12 | publishedAt: $publishedAt 13 | } 14 | ) { 15 | id 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/article.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 記事データをフォーマットする 3 | */ 4 | export const formatArticle = (content: string) => { 5 | return content.split('\n\n') 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/articles.test.ts: -------------------------------------------------------------------------------- 1 | import { formatArticle } from './article' 2 | 3 | test('formatArticle', () => { 4 | expect(formatArticle('hoge\nfuga\n')).toEqual(['hoge\nfuga\n']) 5 | expect(formatArticle('hoge\n\nfuga\n')).toEqual(['hoge', 'fuga\n']) 6 | }) 7 | -------------------------------------------------------------------------------- /src/utils/date.test.ts: -------------------------------------------------------------------------------- 1 | import { formatDate } from './date' 2 | 3 | describe('formatDate', () => { 4 | const now = new Date('2020-08-25 17:58:53') 5 | 6 | test('作成してすぐ', () => { 7 | expect(formatDate(new Date(now), now)).toEqual({ 8 | datetime: '2020/08/25 17:58', 9 | isNew: true, 10 | }) 11 | }) 12 | 13 | test('一週間経過', () => { 14 | expect(formatDate(new Date('2020/08/18 17:58:53'), now)).toEqual({ 15 | datetime: '2020/08/18 17:58', 16 | isNew: false, 17 | }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | export type FormattedDate = { 2 | datetime: string 3 | isNew: boolean 4 | // FIXME: n年前の記事です、のような情報は後でここに追加する 5 | } 6 | 7 | export const formatDate = (d: Date, now: Date): FormattedDate => { 8 | const dtf = new Intl.DateTimeFormat('ja-JP', { 9 | year: 'numeric', 10 | month: '2-digit', 11 | day: '2-digit', 12 | hour: '2-digit', 13 | minute: '2-digit', 14 | }) 15 | const [ 16 | { value: year }, 17 | , 18 | { value: month }, 19 | , 20 | { value: day }, 21 | , 22 | { value: hour }, 23 | , 24 | { value: minute }, 25 | ] = dtf.formatToParts(d) 26 | 27 | const past = (now.getTime() - d.getTime()) / 1000 28 | const isNew = past < 24 * 60 * 60 * 7 29 | return { 30 | datetime: `${year}/${month}/${day} ${hour}:${minute}`, 31 | isNew, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | 3 | /** 複数のクラス名を合成して、一つの文字列に結合する。undefined が混じってもOK */ 4 | export const useClassNames = (...names: ReadonlyArray) => { 5 | return useMemo(() => names.filter((name) => !!name).join(' '), [names]) 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | }, 7 | "target": "es2020", 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "lib": ["dom", "dom.iterable", "esnext"], 18 | "allowJs": true, 19 | "forceConsistentCasingInFileNames": true 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | --------------------------------------------------------------------------------
32 | ほげほげでふがふがをやっています。 33 |
...loading
{error.toString()}