├── .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 | 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} 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 |