├── test ├── .keep ├── __mocks__ │ └── fileMock.js ├── setup.ts └── utils.tsx ├── utils ├── .keep ├── message.ts └── server │ ├── api-response.ts │ └── api-client.ts ├── app ├── layouts │ ├── .keep │ └── Layout.tsx ├── components │ ├── .keep │ ├── molecules │ │ ├── copyright.tsx │ │ └── formDialog.tsx │ ├── atoms │ │ ├── alert.tsx │ │ ├── copyright.tsx │ │ ├── icon.tsx │ │ └── boardTitle.tsx │ ├── organisms │ │ ├── menuItem.tsx │ │ ├── user-info.tsx │ │ ├── itemcard.tsx │ │ └── dashboard.tsx │ ├── LabeledTextField.tsx │ ├── templates │ │ ├── FavoriteForm.tsx │ │ ├── DialogForm.tsx │ │ ├── ItemlistForm.tsx │ │ └── NotificationForm.tsx │ └── Form.tsx ├── auth │ ├── mutations │ │ ├── logout.ts │ │ ├── login.ts │ │ └── signup.ts │ ├── validations.ts │ ├── pages │ │ ├── login.tsx │ │ └── signup.tsx │ ├── auth-utils.ts │ └── components │ │ ├── SignupForm.tsx │ │ └── LoginForm.tsx ├── config.ts ├── hooks │ ├── useCurrentUser.ts │ ├── useHelloMessage.ts │ ├── useNotifications.ts │ ├── useNotificationCount.ts │ └── useAllData.ts ├── users │ └── queries │ │ └── getCurrentUser.ts ├── item │ ├── validataion.ts │ ├── mutations │ │ ├── createItem.ts │ │ └── updateItem.ts │ └── queries │ │ └── getItems.ts ├── notification │ ├── mutations │ │ └── updateNotification.ts │ └── queries │ │ ├── getNotificationCount.ts │ │ └── getNotifications.ts ├── pages │ ├── 404.tsx │ ├── healthcheck.tsx │ ├── index.test.tsx │ ├── top.tsx │ ├── favorite.tsx │ ├── notification.tsx │ ├── _document.tsx │ ├── index.tsx │ └── _app.tsx └── hello │ └── queries │ └── getHello.ts ├── integrations └── .keep ├── .nvmrc ├── .npmrc ├── .dockerignore ├── .eslintrc.js ├── public ├── dice.jpeg ├── apple.jpeg ├── arai-uma.png ├── darts.jpeg ├── favicon.ico ├── goods.jpeg ├── arai-uma.jpeg ├── flow-park.jpeg └── flower-park.jpeg ├── .prettierignore ├── babel.config.js ├── db ├── migrations │ └── migration_lock.toml ├── index.ts ├── seeds.ts └── schema.prisma ├── blitz.config.js ├── .env ├── types.ts ├── tsconfig.json ├── docker-compose.yml ├── .gitignore ├── jest.config.js ├── Dockerfile ├── README.md ├── .github └── workflows │ └── dockle-security-scan.yml ├── package.json └── LICENSE /test/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/layouts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /integrations/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.15.1 2 | -------------------------------------------------------------------------------- /app/components/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["blitz"], 3 | } 4 | -------------------------------------------------------------------------------- /test/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub" 2 | -------------------------------------------------------------------------------- /public/dice.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uma-arai/sbcntr-frontend/HEAD/public/dice.jpeg -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitkeep 2 | .env* 3 | *.ico 4 | *.lock 5 | db/migrations 6 | .next 7 | .blitz 8 | -------------------------------------------------------------------------------- /public/apple.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uma-arai/sbcntr-frontend/HEAD/public/apple.jpeg -------------------------------------------------------------------------------- /public/arai-uma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uma-arai/sbcntr-frontend/HEAD/public/arai-uma.png -------------------------------------------------------------------------------- /public/darts.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uma-arai/sbcntr-frontend/HEAD/public/darts.jpeg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uma-arai/sbcntr-frontend/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/goods.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uma-arai/sbcntr-frontend/HEAD/public/goods.jpeg -------------------------------------------------------------------------------- /public/arai-uma.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uma-arai/sbcntr-frontend/HEAD/public/arai-uma.jpeg -------------------------------------------------------------------------------- /public/flow-park.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uma-arai/sbcntr-frontend/HEAD/public/flow-park.jpeg -------------------------------------------------------------------------------- /public/flower-park.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uma-arai/sbcntr-frontend/HEAD/public/flower-park.jpeg -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["blitz/babel"], 3 | plugins: [["styled-components", { ssr: true }]], 4 | } 5 | -------------------------------------------------------------------------------- /db/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /app/auth/mutations/logout.ts: -------------------------------------------------------------------------------- 1 | import { Ctx } from "blitz" 2 | 3 | export default async function logout(_: any, { session }: Ctx) { 4 | return await session.$revoke() 5 | } 6 | -------------------------------------------------------------------------------- /app/config.ts: -------------------------------------------------------------------------------- 1 | export const APP_SERVICE_HOST = process.env.APP_SERVICE_HOST 2 | ? process.env.APP_SERVICE_HOST 3 | : "http://localhost"; 4 | 5 | export const NOTIF_SERVICE_HOST = process.env.NOTIF_SERVICE_HOST 6 | ? process.env.NOTIF_SERVICE_HOST 7 | : "http://localhost"; 8 | 9 | export const DRAWER_WIDTH = 240; 10 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom/extend-expect" 6 | require("dotenv-flow").config({ silent: true }) 7 | -------------------------------------------------------------------------------- /blitz.config.js: -------------------------------------------------------------------------------- 1 | const { sessionMiddleware, simpleRolesIsAuthorized } = require("blitz"); 2 | 3 | module.exports = { 4 | experimental: { 5 | reactMode: "legacy", 6 | }, 7 | middleware: [ 8 | sessionMiddleware({ 9 | sessionExpiryMinutes: 60, 10 | isAuthorized: simpleRolesIsAuthorized, 11 | }), 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /app/hooks/useCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "blitz" 2 | import getCurrentUser from "app/users/queries/getCurrentUser" 3 | 4 | export const useCurrentUser = () => { 5 | const [user] = useQuery(getCurrentUser, null, { suspense: false }) 6 | return user 7 | //return { id: 1, name: "horsewin", email: "horsewin@gmail.com", role: "admin" } 8 | } 9 | -------------------------------------------------------------------------------- /app/components/molecules/copyright.tsx: -------------------------------------------------------------------------------- 1 | import Copyright from "../atoms/copyright"; 2 | import Box from "@material-ui/core/Box"; 3 | import React from "react"; 4 | 5 | const CopyrightComponent = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default CopyrightComponent; 14 | -------------------------------------------------------------------------------- /app/components/atoms/alert.tsx: -------------------------------------------------------------------------------- 1 | import MuiAlert from "@material-ui/lab/Alert"; 2 | import { AlertProps } from "@material-ui/lab/Alert/Alert"; 3 | 4 | /** 5 | * 6 | * @param {AlertProps} props 7 | * @returns {JSX.Element} 8 | * @constructor 9 | */ 10 | export const Alert = (props: AlertProps) => { 11 | return ; 12 | }; 13 | -------------------------------------------------------------------------------- /app/hooks/useHelloMessage.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "blitz"; 2 | import getHello from "app/hello/queries/getHello"; 3 | 4 | export const useHelloMessage = () => { 5 | const [hello] = useQuery(getHello, null, { suspense: false }); 6 | if (hello?.error) { 7 | const error = hello.error; 8 | return error.body.msg; 9 | } 10 | 11 | return hello?.result.data; 12 | }; 13 | -------------------------------------------------------------------------------- /app/hooks/useNotifications.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "blitz"; 2 | import getNotifications from "../notification/queries/getNotifications"; 3 | 4 | export const useNotifications = () => { 5 | const [res] = useQuery(getNotifications, null, { suspense: false }); 6 | if (res?.error) { 7 | throw new Error("Notifications"); 8 | } 9 | 10 | return res?.result.data; 11 | }; 12 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # This env file should be checked into source control 2 | # This is the place for default values that should be used in all environments 3 | TZ=Asia/Tokyo 4 | DATABASE_URL=mysql://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:3306/${DB_NAME} 5 | 6 | ## HACK: サンプルではHTTPでウェブアプリケーションをホストするためにSession managementに利用する 7 | ## secure cookieの設定内容を変更しておく必要がある 8 | DISABLE_SECURE_COOKIES=true 9 | 10 | -------------------------------------------------------------------------------- /app/hooks/useNotificationCount.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "blitz"; 2 | import getNotificationCount from "app/notification/queries/getNotificationCount"; 3 | 4 | export const useNotificationCount = () => { 5 | const [count] = useQuery(getNotificationCount, null, { suspense: false }); 6 | if (count?.error) { 7 | throw new Error("count"); 8 | } 9 | 10 | return count?.result.data; 11 | }; 12 | -------------------------------------------------------------------------------- /app/users/queries/getCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { Ctx } from "blitz" 2 | import db from "db" 3 | 4 | const getCurrentUser = async (_ = null, { session }: Ctx) => { 5 | if (!session.userId) return null 6 | 7 | return await db.user.findFirst({ 8 | where: { id: session.userId }, 9 | select: { id: true, name: true, email: true, role: true }, 10 | }) 11 | } 12 | 13 | export default getCurrentUser 14 | -------------------------------------------------------------------------------- /app/components/atoms/copyright.tsx: -------------------------------------------------------------------------------- 1 | import Typography from "@material-ui/core/Typography" 2 | import React from "react" 3 | 4 | type CopyrightProps = {title: string} 5 | 6 | const Copyright:React.FC = ({title}) => { 7 | return ( 8 | 9 | {title} 10 | 11 | ) 12 | } 13 | 14 | export default Copyright; 15 | -------------------------------------------------------------------------------- /app/item/validataion.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | import { ERROR_MESSAGES } from "utils/message"; 3 | 4 | export const ItemInput = z.object({ 5 | title: z 6 | .string() 7 | .nonempty(ERROR_MESSAGES.REQUIRED) 8 | .max(15, ERROR_MESSAGES.ITEM.MAX_TITLE), 9 | name: z.string().nonempty(ERROR_MESSAGES.REQUIRED), 10 | img: z.string(), 11 | }); 12 | export type DataInputType = z.infer; 13 | -------------------------------------------------------------------------------- /app/item/mutations/createItem.ts: -------------------------------------------------------------------------------- 1 | import { resolver } from "blitz"; 2 | import db from "db"; 3 | import * as z from "zod"; 4 | import { ItemInput } from "../validataion"; 5 | 6 | const CreateItem = z.object(ItemInput.shape).nonstrict(); 7 | 8 | export default resolver.pipe( 9 | resolver.zod(CreateItem), 10 | resolver.authorize(), 11 | async (input) => { 12 | //@ts-ignore 13 | return await db.item.create({ data: input }); 14 | } 15 | ); 16 | -------------------------------------------------------------------------------- /app/notification/mutations/updateNotification.ts: -------------------------------------------------------------------------------- 1 | import { resolver } from "blitz"; 2 | import { apiClient } from "../../../utils/server/api-client"; 3 | import { NOTIF_SERVICE_HOST } from "../../config"; 4 | 5 | export default resolver.pipe(resolver.authorize(), async () => { 6 | return await apiClient<{ data: string }>( 7 | `/v1/Notifications/Read`, 8 | "POST", 9 | undefined, 10 | undefined, 11 | NOTIF_SERVICE_HOST 12 | ); 13 | }); 14 | -------------------------------------------------------------------------------- /app/hooks/useAllData.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "blitz"; 2 | import getData from "app/item/queries/getItems"; 3 | 4 | export const useAllData = (conditions: { favorite: boolean } | null) => { 5 | const params = conditions 6 | ? { 7 | where: { 8 | favorite: conditions.favorite || false, 9 | }, 10 | } 11 | : {}; 12 | const [data] = useQuery(getData, params, { suspense: false }); 13 | 14 | // TODO: res?.result.data; 15 | return data; 16 | }; 17 | -------------------------------------------------------------------------------- /utils/message.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_MESSAGES = { 2 | EMAIL: "メールアドレスの形式で入力してください。", 3 | PASSWORD_LENGTH: "8文字以上25文字以内で入力してください。", 4 | SIGNUP: { 5 | DUPLICATED_ADDRESS: "すでに登録されているメールアドレスです。", 6 | }, 7 | LOGIN: { 8 | INVALID_PASSWORD: "無効なパスワードです。", 9 | NO_USER: 10 | "対象のユーザは存在しない可能性があります。入力値をご確認ください。", 11 | }, 12 | REQUIRED: "必須入力です。", 13 | ITEM: { 14 | MAX_TITLE: "タイトルは15文字以内にしてください。", 15 | FAILED_TO_ADD: "新しいアイテムの登録に失敗しました。", 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /app/layouts/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | import { Head } from "blitz" 3 | 4 | type LayoutProps = { 5 | title?: string 6 | children: ReactNode 7 | } 8 | 9 | const Layout = ({ title, children }: LayoutProps) => { 10 | return ( 11 | <> 12 | 13 | {title || "sbcntr-frontend"} 14 | 15 | 16 | 17 | {children} 18 | 19 | ) 20 | } 21 | 22 | export default Layout 23 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import { User } from "db"; 2 | import { DefaultCtx, SessionContext, SimpleRolesIsAuthorized } from "blitz"; 3 | 4 | export type Role = "admin" | "user"; 5 | 6 | declare module "blitz" { 7 | export interface Ctx extends DefaultCtx { 8 | session: SessionContext; 9 | } 10 | export interface Session { 11 | isAuthorized: SimpleRolesIsAuthorized; 12 | PublicData: { 13 | userId: User["id"]; 14 | role: string; 15 | orgId: number; 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/components/atoms/icon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { IconContext, IconType } from "react-icons" 3 | 4 | type Props = { 5 | size?: string 6 | color?: string 7 | iconType: IconType 8 | } 9 | 10 | const IconParts = ({ size = "1rem", color = "primary", iconType }: Props) => { 11 | const PropsIcon = iconType 12 | return ( 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export default IconParts 20 | -------------------------------------------------------------------------------- /app/auth/validations.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod" 2 | import { ERROR_MESSAGES } from "utils/message" 3 | 4 | export const SignupInput = z.object({ 5 | email: z.string().email(ERROR_MESSAGES.EMAIL), 6 | password: z.string().min(8, ERROR_MESSAGES.PASSWORD_LENGTH).max(25, ERROR_MESSAGES.PASSWORD_LENGTH) 7 | }) 8 | export type SignupInputType = z.infer 9 | 10 | export const LoginInput = z.object({ 11 | email: z.string().email(ERROR_MESSAGES.EMAIL), 12 | password: z.string() 13 | }) 14 | export type LoginInputType = z.infer 15 | -------------------------------------------------------------------------------- /app/components/atoms/boardTitle.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import Typography from "@material-ui/core/Typography"; 3 | 4 | type TitleProps = { 5 | children: ReactNode; 6 | }; 7 | 8 | /** 9 | * 10 | * @param {React.PropsWithChildren} props 11 | * @returns {JSX.Element} 12 | * @constructor 13 | */ 14 | const BoardTitle: React.FC = ({ children }) => { 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | }; 21 | 22 | export default BoardTitle; 23 | -------------------------------------------------------------------------------- /app/notification/queries/getNotificationCount.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "utils/server/api-client"; 2 | import { Ctx } from "blitz"; 3 | import { NOTIF_SERVICE_HOST } from "../../config"; 4 | 5 | const getNotificationCount = async (_ = null, { session }: Ctx) => { 6 | //return { 7 | // result: { 8 | // data: 3, 9 | // }, 10 | // error: null, 11 | //}; 12 | return await apiClient<{ data: string }>( 13 | `/v1/Notifications/Count`, 14 | "GET", 15 | undefined, 16 | undefined, 17 | NOTIF_SERVICE_HOST 18 | ); 19 | }; 20 | 21 | export default getNotificationCount; 22 | -------------------------------------------------------------------------------- /app/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Head, ErrorComponent } from "blitz"; 2 | 3 | // ------------------------------------------------------ 4 | // This page is rendered if a route match is not found 5 | // ------------------------------------------------------ 6 | export default function Page404() { 7 | const statusCode = 404; 8 | const title = "対象のページが見つかりませんでした。"; 9 | return ( 10 | <> 11 | 12 | 13 | {statusCode}: {title} 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/auth/mutations/login.ts: -------------------------------------------------------------------------------- 1 | import { Ctx } from "blitz" 2 | import { authenticateUser } from "app/auth/auth-utils" 3 | import { LoginInput, LoginInputType } from "../validations" 4 | 5 | export default async function login(input: LoginInputType, { session }: Ctx) { 6 | // This throws an error if input is invalid 7 | const { email, password } = LoginInput.parse(input) 8 | 9 | // This throws an error if credentials are invalid 10 | const user = await authenticateUser(email, password) 11 | 12 | await session.$create({ userId: user.id, role: user.role, orgId: 1 }) 13 | 14 | return user 15 | } 16 | -------------------------------------------------------------------------------- /app/hello/queries/getHello.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "utils/server/api-client"; 2 | import { Ctx } from "blitz"; 3 | 4 | const getHello = async (_ = null, { session }: Ctx) => { 5 | try { 6 | return await apiClient<{ data: string }>(`/v1/helloworld`, "GET"); 7 | } catch (e) { 8 | console.log(e); 9 | return { 10 | statusCode: 999, 11 | result: null, 12 | error: { 13 | meta: { 14 | code: 88, 15 | title: "エラー", 16 | }, 17 | body: { 18 | msg: "Hello worldの取得に失敗", 19 | }, 20 | }, 21 | }; 22 | } 23 | }; 24 | 25 | export default getHello; 26 | -------------------------------------------------------------------------------- /app/item/queries/getItems.ts: -------------------------------------------------------------------------------- 1 | import { Ctx } from "blitz"; 2 | import { apiClient } from "../../../utils/server/api-client"; 3 | import { Item, Prisma } from "@prisma/client"; 4 | import { APP_SERVICE_HOST } from "../../config"; 5 | 6 | interface GetItemInput extends Pick {} 7 | 8 | const getItems = async ({ where }: GetItemInput, { session }: Ctx) => { 9 | const fav = where?.favorite; 10 | return await apiClient<{ data: Item[] }>( 11 | `/v1/Items?${fav ? "favorite=" + fav : ""}`, 12 | "GET", 13 | undefined, 14 | undefined, 15 | APP_SERVICE_HOST 16 | ); 17 | }; 18 | 19 | export default getItems; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "baseUrl": "./", 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "strictNullChecks": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "tsBuildInfoFile": ".tsbuildinfo" 20 | }, 21 | "exclude": ["node_modules"], 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 23 | } 24 | -------------------------------------------------------------------------------- /app/pages/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | import { BlitzPage } from "blitz"; 2 | import Layout from "app/layouts/Layout"; 3 | import React from "react"; 4 | import Container from "@material-ui/core/Container"; 5 | import CopyrightComponent from "../components/molecules/copyright"; 6 | import CssBaseline from "@material-ui/core/CssBaseline"; 7 | 8 | const Healthcheck: BlitzPage = () => { 9 | return ( 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | Healthcheck.redirectAuthenticatedTo = "/top"; 18 | Healthcheck.getLayout = (page) => {page}; 19 | 20 | export default Healthcheck; 21 | -------------------------------------------------------------------------------- /app/pages/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "test/utils"; 3 | import Home from "./index"; 4 | import { useCurrentUser } from "app/hooks/useCurrentUser"; 5 | 6 | jest.mock("app/hooks/useCurrentUser"); 7 | const mockUseCurrentUser = useCurrentUser as jest.MockedFunction< 8 | typeof useCurrentUser 9 | >; 10 | 11 | test.skip("renders blitz documentation link", () => { 12 | mockUseCurrentUser.mockReturnValue({ 13 | id: 1, 14 | name: "User", 15 | email: "user@email.com", 16 | role: "user", 17 | }); 18 | 19 | const { getByText } = render(); 20 | const linkElement = getByText(/Documentation/i); 21 | expect(linkElement).toBeInTheDocument(); 22 | }); 23 | -------------------------------------------------------------------------------- /db/index.ts: -------------------------------------------------------------------------------- 1 | //import { PrismaClient } from "@prisma/client" 2 | //export * from "@prisma/client" 3 | // 4 | //let prisma: PrismaClient 5 | // 6 | //if (process.env.NODE_ENV === "production") { 7 | // prisma = new PrismaClient() 8 | //} else { 9 | // // Ensure the prisma instance is re-used during hot-reloading 10 | // // Otherwise, a new client will be created on every reload 11 | // globalThis["prisma"] = globalThis["prisma"] || new PrismaClient() 12 | // prisma = globalThis["prisma"] 13 | //} 14 | // 15 | //export default prisma 16 | import { enhancePrisma } from "blitz" 17 | import { PrismaClient } from "@prisma/client" 18 | 19 | const EnhancedPrisma = enhancePrisma(PrismaClient) 20 | 21 | export * from "@prisma/client" 22 | export default new EnhancedPrisma() 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | db: 4 | image: mysql:5.7 5 | network_mode: "host" 6 | platform: linux/amd64 7 | container_name: mysql_host 8 | volumes: 9 | - ./database:/var/lib/minesql 10 | environment: 11 | MYSQL_USER: sbcntruser 12 | MYSQL_PASSWORD: sbcntrpass 13 | MYSQL_DATABASE: sbcntrapp 14 | MYSQL_ROOT_PASSWORD: root 15 | ports: 16 | - "3306:3306" 17 | app: 18 | image: sbcntr-backend:latest 19 | depends_on: 20 | - "db" 21 | container_name: sbcntr-backend 22 | environment: 23 | DB_HOST: localhost 24 | DB_USERNAME: sbcntruser 25 | DB_PASSWORD: sbcntrpass 26 | DB_NAME: sbcntrapp 27 | network_mode: "host" 28 | ports: 29 | - "3100:80" 30 | -------------------------------------------------------------------------------- /utils/server/api-response.ts: -------------------------------------------------------------------------------- 1 | export type ApiSuccessResponse = T; 2 | export type ApiErrorResponse = { 3 | meta: ErrorMetaInfo; 4 | body: ErrorBody; 5 | }; 6 | 7 | type ErrorMetaInfo = { 8 | code: number; 9 | title: string; 10 | }; 11 | type ErrorBody = { 12 | msg?: string; 13 | }; 14 | 15 | export const createApiResponse = < 16 | T extends Record 17 | >( 18 | input: T | ApiErrorResponse 19 | ) => { 20 | const error: ApiErrorResponse | null = 21 | input && "meta" in input ? input : null; 22 | const result: ApiSuccessResponse | null = !error ? (input as T) : null; 23 | 24 | return { result, error }; 25 | }; 26 | 27 | export const createApiErrorResponse = (input: ApiErrorResponse) => { 28 | return { result: null, error: input }; 29 | }; 30 | -------------------------------------------------------------------------------- /app/pages/top.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { BlitzPage, useRouter } from "blitz"; 3 | import { Dashboard as SbcntrDashboard } from "../components/organisms/dashboard"; 4 | import CopyrightComponent from "../components/molecules/copyright"; 5 | import Layout from "../layouts/Layout"; 6 | import { ItemListForm } from "../components/templates/ItemlistForm"; 7 | 8 | const TopPage: BlitzPage = () => { 9 | const router = useRouter(); 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | TopPage.authenticate = true; 21 | TopPage.getLayout = (page) => {page}; 22 | 23 | export default TopPage; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | .yarn/cache 4 | .yarn/unplugged 5 | .yarn/build-state.yml 6 | .pnp.* 7 | .npm 8 | web_modules/ 9 | 10 | # blitz 11 | /.blitz/ 12 | /.next/ 13 | *.sqlite 14 | .now 15 | .blitz-console-history 16 | blitz-log.log 17 | db/migrations/**/*.md 18 | 19 | # misc 20 | .DS_Store 21 | 22 | # local env files 23 | .env.local 24 | .env.*.local 25 | .envrc 26 | 27 | # Logs 28 | logs 29 | *.log 30 | 31 | # Runtime data 32 | pids 33 | *.pid 34 | *.seed 35 | *.pid.lock 36 | 37 | # Testing 38 | .coverage 39 | *.lcov 40 | .nyc_output 41 | lib-cov 42 | 43 | # Caches 44 | *.tsbuildinfo 45 | .eslintcache 46 | .node_repl_history 47 | .yarn-integrity 48 | 49 | # Serverless directories 50 | .serverless/ 51 | 52 | # Stores VSCode versions used for testing VSCode extensions 53 | .vscode-test 54 | .idea 55 | -------------------------------------------------------------------------------- /app/pages/favorite.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { BlitzPage } from "blitz"; 3 | import CopyrightComponent from "../components/molecules/copyright"; 4 | import Layout from "../layouts/Layout"; 5 | import { Dashboard as SbcntrDashboard } from "../components/organisms/dashboard"; 6 | import { FavoriteForm } from "../components/templates/FavoriteForm"; 7 | 8 | /** 9 | * 10 | * @returns {JSX.Element} 11 | * @constructor 12 | */ 13 | const FavoritePage: BlitzPage = () => { 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | FavoritePage.authenticate = true; 25 | FavoritePage.getLayout = (page) => {page}; 26 | 27 | export default FavoritePage; 28 | -------------------------------------------------------------------------------- /app/auth/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BlitzPage, useRouter } from "blitz"; 3 | import Layout from "app/layouts/Layout"; 4 | import { LoginForm } from "app/auth/components/LoginForm"; 5 | import Container from "@material-ui/core/Container"; 6 | import CssBaseline from "@material-ui/core/CssBaseline"; 7 | import CopyrightComponent from "../../components/molecules/copyright"; 8 | 9 | const LoginPage: BlitzPage = () => { 10 | const router = useRouter(); 11 | 12 | return ( 13 | 14 | 15 | router.push("/top")} /> 16 | 17 | 18 | ); 19 | }; 20 | 21 | LoginPage.getLayout = (page) => {page}; 22 | LoginPage.redirectAuthenticatedTo = "/top"; 23 | 24 | export default LoginPage; 25 | -------------------------------------------------------------------------------- /app/pages/notification.tsx: -------------------------------------------------------------------------------- 1 | import { BlitzPage, useRouter } from "blitz"; 2 | import React from "react"; 3 | import Layout from "../layouts/Layout"; 4 | import { Dashboard as SbcntrDashboard } from "../components/organisms/dashboard"; 5 | import CopyrightComponent from "../components/molecules/copyright"; 6 | import NotificationForm from "../components/templates/NotificationForm"; 7 | 8 | const NotificationPage: BlitzPage = () => { 9 | const router = useRouter(); 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | NotificationPage.authenticate = true; 21 | NotificationPage.getLayout = (page) => {page}; 22 | 23 | export default NotificationPage; 24 | -------------------------------------------------------------------------------- /app/item/mutations/updateItem.ts: -------------------------------------------------------------------------------- 1 | import { resolver } from "blitz"; 2 | import * as z from "zod"; 3 | import { apiClient } from "../../../utils/server/api-client"; 4 | import { APP_SERVICE_HOST } from "../../config"; 5 | 6 | type UpdateInput = { id: number; favorite: boolean }; 7 | 8 | const UpdateItemFavoriteStatus = z 9 | .object({ 10 | id: z.number(), 11 | favorite: z.boolean(), 12 | }) 13 | .nonstrict(); 14 | 15 | export default resolver.pipe( 16 | resolver.zod(UpdateItemFavoriteStatus), 17 | resolver.authorize(), 18 | async ({ id, favorite }: UpdateInput) => { 19 | const payload = { 20 | id, 21 | favorite, 22 | }; 23 | 24 | return await apiClient<{ data: string }>( 25 | `/v1/Item/favorite`, 26 | "POST", 27 | undefined, 28 | JSON.stringify(payload), 29 | APP_SERVICE_HOST 30 | ); 31 | } 32 | ); 33 | -------------------------------------------------------------------------------- /app/auth/pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BlitzPage, useRouter } from "blitz"; 3 | import Layout from "app/layouts/Layout"; 4 | import { SignupForm } from "app/auth/components/SignupForm"; 5 | import Container from "@material-ui/core/Container"; 6 | import CssBaseline from "@material-ui/core/CssBaseline"; 7 | import CopyrightComponent from "../../components/molecules/copyright"; 8 | 9 | const SignupPage: BlitzPage = () => { 10 | const router = useRouter(); 11 | 12 | return ( 13 | 14 | 15 | router.push("/top")} /> 16 | 17 | 18 | ); 19 | }; 20 | 21 | SignupPage.getLayout = (page) => {page}; 22 | SignupPage.redirectAuthenticatedTo = "/top"; 23 | 24 | export default SignupPage; 25 | -------------------------------------------------------------------------------- /app/auth/mutations/signup.ts: -------------------------------------------------------------------------------- 1 | import { Ctx } from "blitz" 2 | import db from "db" 3 | import { hashPassword } from "app/auth/auth-utils" 4 | import { SignupInput, SignupInputType } from "app/auth/validations" 5 | 6 | export default async function signup(input: SignupInputType, ctx: Ctx) { 7 | // This throws an error if input is invalid 8 | // 1. Validate input data 9 | // 2. Validate user credentials 10 | const { email, password } = SignupInput.parse(input) 11 | const hashedPassword = await hashPassword(password) 12 | 13 | // 3. Fetch user data 14 | const user = await db.user.create({ 15 | data: { email: email.toLowerCase(), hashedPassword, role: "user" }, 16 | select: { id: true, name: true, email: true, role: true }, 17 | }) 18 | 19 | // 4. Create a new session (log in) 20 | await ctx.session.$create({ userId: user.id, role: user.role, orgId: 1 }) 21 | 22 | return user 23 | } 24 | -------------------------------------------------------------------------------- /app/components/organisms/menuItem.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "blitz"; 2 | import { ListItem, ListItemIcon, ListItemText } from "@material-ui/core"; 3 | import ShoppingCartIcon from "@material-ui/icons/ShoppingCart"; 4 | import FavoriteIcon from "@material-ui/icons/Favorite"; 5 | import * as React from "react"; 6 | 7 | /** 8 | * 9 | * @returns {JSX.Element} 10 | * @constructor 11 | */ 12 | export const MainListItems = () => { 13 | const router = useRouter(); 14 | 15 | return ( 16 |
17 | router.push("/top")}> 18 | 19 | 20 | 21 | 22 | 23 | router.push("/favorite")}> 24 | 25 | 26 | 27 | 28 | 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /app/notification/queries/getNotifications.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "utils/server/api-client"; 2 | import { Ctx } from "blitz"; 3 | import { NOTIF_SERVICE_HOST } from "../../config"; 4 | import { Notification } from "@prisma/client"; 5 | 6 | const getNotifications = async (_ = null, { session }: Ctx) => { 7 | //return { 8 | // result: { 9 | // data: [ 10 | // { 11 | // id: 1, 12 | // title: "通知1", 13 | // description: "通知ダヨ", 14 | // category: "Information", 15 | // read: true, 16 | // createdAt: "2021/03/17 10:39:06", 17 | // }, 18 | // { 19 | // id: 2, 20 | // title: "通知2", 21 | // description: "通知2ダヨ", 22 | // category: "News", 23 | // read: false, 24 | // createdAt: "2021/04/17 10:39:06", 25 | // }, 26 | // ], 27 | // }, 28 | // error: null, 29 | //}; 30 | return await apiClient<{ data: Notification[] }>( 31 | `/v1/Notifications`, 32 | "GET", 33 | undefined, 34 | undefined, 35 | NOTIF_SERVICE_HOST 36 | ); 37 | }; 38 | 39 | export default getNotifications; 40 | -------------------------------------------------------------------------------- /app/components/organisms/user-info.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "blitz"; 2 | import { Button } from "@material-ui/core"; 3 | import React from "react"; 4 | import { makeStyles } from "@material-ui/core/styles"; 5 | 6 | const useStyles = makeStyles((theme) => ({ 7 | button: { 8 | paddingRight: theme.spacing(2), 9 | paddingLeft: theme.spacing(2), 10 | paddingTop: theme.spacing(1), 11 | paddingBottom: theme.spacing(1), 12 | }, 13 | })); 14 | /** 15 | * 16 | * @returns {JSX.Element} 17 | * @constructor 18 | */ 19 | export const UserInfo = () => { 20 | const classes = useStyles(); 21 | const router = useRouter(); 22 | 23 | return ( 24 | <> 25 | 37 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /app/components/LabeledTextField.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithoutRef } from "react"; 2 | import { useField } from "react-final-form"; 3 | 4 | export interface LabeledTextFieldProps 5 | extends PropsWithoutRef { 6 | /** Field name. */ 7 | name: string; 8 | 9 | /** Field label. */ 10 | label: string; 11 | 12 | /** Field type. Doesn't include radio buttons and checkboxes */ 13 | type?: "text" | "password" | "email" | "number"; 14 | outerProps?: PropsWithoutRef; 15 | } 16 | 17 | export const LabeledTextField = React.forwardRef< 18 | HTMLInputElement, 19 | LabeledTextFieldProps 20 | >(({ name, label, outerProps, ...props }, ref) => { 21 | const { 22 | input, 23 | meta: { touched, error, submitError, submitting }, 24 | } = useField(name); 25 | 26 | const normalizedError = Array.isArray(error) 27 | ? error.join(", ") 28 | : error || submitError; 29 | 30 | return ( 31 |
32 | 36 | 37 | {touched && normalizedError && ( 38 |
39 | {normalizedError} 40 |
41 | )} 42 |
43 | ); 44 | }); 45 | 46 | export default LabeledTextField; 47 | -------------------------------------------------------------------------------- /app/components/templates/FavoriteForm.tsx: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "@material-ui/core/styles"; 2 | import { useAllData } from "../../hooks/useAllData"; 3 | import Container from "@material-ui/core/Container"; 4 | import Grid from "@material-ui/core/Grid"; 5 | import { ItemCard } from "../organisms/itemcard"; 6 | import * as React from "react"; 7 | 8 | const useStyles = makeStyles((theme) => ({ 9 | appBarSpacer: theme.mixins.toolbar, 10 | container: { 11 | paddingTop: theme.spacing(4), 12 | paddingBottom: theme.spacing(4), 13 | }, 14 | })); 15 | 16 | /** 17 | * 18 | * @returns {JSX.Element} 19 | * @constructor 20 | */ 21 | export const FavoriteForm = () => { 22 | const classes = useStyles(); 23 | const items = useAllData({ favorite: true }); 24 | 25 | return ( 26 | <> 27 |
28 | 29 | 30 | {items?.result?.data.map((value) => { 31 | return ( 32 | 40 | ); 41 | })} 42 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /app/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentContext, 3 | Document, 4 | Html, 5 | DocumentHead, 6 | Main, 7 | BlitzScript /*DocumentContext*/, 8 | } from "blitz"; 9 | 10 | import React from "react"; 11 | import { ServerStyleSheets } from "@material-ui/core/styles"; 12 | 13 | class MyDocument extends Document { 14 | static async getInitialProps(ctx: DocumentContext) { 15 | const sheets = new ServerStyleSheets(); 16 | const originalRenderPage = ctx.renderPage; 17 | 18 | ctx.renderPage = () => 19 | originalRenderPage({ 20 | enhanceApp: (App) => (props) => sheets.collect(), 21 | }); 22 | 23 | const initialProps = await Document.getInitialProps(ctx); 24 | return { 25 | ...initialProps, 26 | styles: [ 27 | ...React.Children.toArray(initialProps.styles), 28 | sheets.getStyleElement(), 29 | ], 30 | }; 31 | } 32 | 33 | // Only uncomment if you need to customize this behaviour 34 | // static async getInitialProps(ctx: DocumentContext) { 35 | // const initialProps = await Document.getInitialProps(ctx) 36 | // return {...initialProps} 37 | // } 38 | 39 | render() { 40 | return ( 41 | 42 | 43 | 44 |
45 | 46 | 47 | 48 | ); 49 | } 50 | } 51 | 52 | export default MyDocument; 53 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require("ts-jest/utils") 2 | const { compilerOptions } = require("./tsconfig") 3 | 4 | module.exports = { 5 | setupFilesAfterEnv: ["/test/setup.ts"], 6 | // Add type checking to Typescript test files 7 | preset: "ts-jest", 8 | testEnvironment: "jest-environment-jsdom-fourteen", 9 | // Automatically clear mock calls and instances between every test 10 | clearMocks: true, 11 | testPathIgnorePatterns: ["/node_modules/", "/.blitz/", "/.next/", "/db/migrations"], 12 | transformIgnorePatterns: ["[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$"], 13 | transform: { 14 | "^.+\\.(ts|tsx)$": "babel-jest", 15 | }, 16 | // This makes absolute imports work 17 | moduleDirectories: ["node_modules", ""], 18 | // Ignore the build directories 19 | modulePathIgnorePatterns: ["/.blitz", "/.next"], 20 | moduleNameMapper: { 21 | // This ensures any path aliases in tsconfig also work in jest 22 | ...pathsToModuleNameMapper(compilerOptions.paths || {}), 23 | "\\.(css|less|sass|scss)$": "identity-obj-proxy", 24 | "\\.(gif|ttf|eot|svg|png|jpg|jpeg)$": "/test/__mocks__/fileMock.js", 25 | }, 26 | watchPlugins: ["jest-watch-typeahead/filename", "jest-watch-typeahead/testname"], 27 | // Coverage output 28 | coverageDirectory: ".coverage", 29 | collectCoverageFrom: ["**/*.{js,jsx,ts,tsx}", "!**/*.d.ts", "!**/node_modules/**"], 30 | } 31 | -------------------------------------------------------------------------------- /app/auth/auth-utils.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError } from "blitz" 2 | import SecurePassword from "secure-password" 3 | import db from "db" 4 | 5 | const SP = () => new SecurePassword() 6 | 7 | export const hashPassword = async (password: string) => { 8 | const hashedBuffer = await SP().hash(Buffer.from(password)) 9 | return hashedBuffer.toString("base64") 10 | } 11 | export const verifyPassword = async (hashedPassword: string, password: string) => { 12 | try { 13 | return await SP().verify(Buffer.from(password), Buffer.from(hashedPassword, "base64")) 14 | } catch (error) { 15 | console.error(error) 16 | return false 17 | } 18 | } 19 | 20 | export const authenticateUser = async (email: string, password: string) => { 21 | const user = await db.user.findFirst({ where: { email: email.toLowerCase() } }) 22 | 23 | if (!user || !user.hashedPassword) throw new AuthenticationError() 24 | 25 | switch (await verifyPassword(user.hashedPassword, password)) { 26 | case SecurePassword.VALID: 27 | break 28 | case SecurePassword.VALID_NEEDS_REHASH: 29 | // Upgrade hashed password with a more secure hash 30 | const improvedHash = await hashPassword(password) 31 | await db.user.update({ where: { id: user.id }, data: { hashedPassword: improvedHash } }) 32 | break 33 | default: 34 | throw new AuthenticationError() 35 | } 36 | 37 | const { hashedPassword, ...rest } = user 38 | return rest 39 | } 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.16.0-alpine3.13 AS builder 2 | RUN apk add --no-cache libc6-compat 3 | WORKDIR /app 4 | RUN apk add --update --no-cache --virtual build-deps \ 5 | python3 \ 6 | g++ \ 7 | git \ 8 | libtool \ 9 | automake \ 10 | autoconf 11 | 12 | # Add libvips 13 | RUN apk add --upgrade --no-cache vips-dev build-base --repository https://alpine.global.ssl.fastly.net/alpine/v3.10/community/ 14 | COPY . . 15 | 16 | # install production dependencies 17 | RUN yarn install --pure-lockfile --production 18 | 19 | # Note also that prisma generate is automatically invoked when you're installing the @prisma/client npm package 20 | RUN npx prisma generate 21 | 22 | # Save production depenencies installed so we can later copy them in the production image 23 | RUN cp -R node_modules /tmp/node_modules 24 | 25 | # install all dependencies including devDependencies 26 | RUN yarn install --pure-lockfile 27 | RUN yarn build 28 | 29 | ########### 30 | FROM node:14.16.0-alpine3.13 31 | WORKDIR /app 32 | 33 | RUN apk add --no-cache curl tzdata && \ 34 | cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \ 35 | apk del tzdata 36 | 37 | COPY --from=builder /tmp/node_modules ./node_modules 38 | COPY --from=builder /app/.blitz ./.blitz 39 | COPY --from=builder /app/.next ./.next 40 | COPY --from=builder /app/package.json ./ 41 | COPY --from=builder /app/db ./ 42 | 43 | ENV PORT 80 44 | EXPOSE 80 45 | 46 | CMD [ "npm","run","start:prd" ] 47 | # HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD curl 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sbcntr-frontend 2 | 3 | 書籍「AWSコンテナ設計・構築[本格]入門」のフロントエンドアプリケーション用のダウンロードリポジトリです。 4 | **`main`は初版向けのアプリケーションとなっております。2版向けはブランチを `v2` に切り替えてご利用ください。** 5 | 6 | ## 概要 7 | 8 | 本書を引用する形でサンプルアプリケーションについて説明します。 9 | 10 | 今回はフロントエンドアプリケーションから、ユーザーのサインアップやログインを実施します。 11 | ユーザー情報はデータベースに保管されており、TypeScript の型安全のしくみを十分発揮させるために O/R マッパを導入しています。 12 | 今回は Prisma[^prisma]と呼ばれる O/R マッパを利用します。 13 | Prisma のトップページには、「Next-generation ORM for Node.js and TypeScript」と記載されています。 14 | 今後のサーバーサイドの TypeScript 開発において非常に優秀な O/R マッパとなっています。 15 | データベースのマイグレーション機能も備えており、サンプルアプリケーションのテーブル作成やサンプルデータ投入でも Prisma の機能を利用しています。 16 | 17 | [^prisma]: https://www.prisma.io/ 18 | 19 | フロントエンドアプリケーションはダッシュボード形式の UI としています。 20 | オフィスに導入したアイテムをアイテムリストとして表示して共有して閲覧するような UI をイメージしています。 21 | バックエンドアプリケーションと通信するためのサンプルとして活用するために、アイテムの追加も可能としています。 22 | 本来であれば、ユーザーを新規登録可能とするにはドメイン制御をしたりメール認証等もすべきです。 23 | 今回はサンプルであるため、このような機能要件は省いています。 24 | 備えている画面は次の通りです。 25 | 26 | - ログイン前トップページ(index.tsx) 27 | - DB 接続なしで画面表示をするために用意した画面です。 28 | - いわゆるウェルカムページの役割で、Hello world を表示するために利用します。 29 | - ログインページ(auth/login.tsx)、サインアップページ(auth/signup.tsx) 30 | - ユーザーログイン用途です。 31 | - 現状はログインユーザーごとでログイン後画面の表示制御はしていないですが、認証済ユーザーでないとメインコンテンツページには遷移できないという意図で作成しています。 32 | - アイテムリストページ(top.tsx) 33 | - 認証済ユーザーがデータベースに追加したアイテム一覧を表示するためのページです。 34 | - 気に入ったアイテムはお気に入りマークをつけることができます。**本来、ユーザごとにお気に入りをしたアイテムを分けるべきですが、今回はその部分までつくりこんではおりません**。 35 | - さらに、新しいアイテムが登録できます。 36 | - お気に入りページ(farovite.tsx) 37 | - お気に入りマークがついたアイテムを表示するページです。 38 | - 通知ページ(notification.tsx) 39 | - 認証済ユーザーにお知らせをするための通知ページです。 40 | - 未読通知を既読にできます。 41 | 42 | ## 利用想定 43 | 44 | 本書の内容に沿って、ご利用ください。 45 | -------------------------------------------------------------------------------- /db/seeds.ts: -------------------------------------------------------------------------------- 1 | import db from "./index"; 2 | 3 | /* 4 | * This seed function is executed when you run `blitz db seed`. 5 | * 6 | * Probably you want to use a library like https://chancejs.com 7 | * or https://github.com/Marak/Faker.js to easily generate 8 | * realistic data. 9 | */ 10 | const seed = async () => { 11 | await addItems(); 12 | for (let i = 0; i < 2; i++) { 13 | await db.notification.create({ 14 | data: { 15 | title: `通知${i + 1}`, 16 | description: "コンテナアプリケーションの作成の時間です。", 17 | category: "information", 18 | updatedAt: undefined, 19 | }, 20 | }); 21 | } 22 | }; 23 | 24 | const addItems = async () => { 25 | // TODO: お花系に全部そろえる 26 | await db.item.create({ 27 | data: { 28 | name: "Flower", 29 | title: "Flower", 30 | favorite: true, 31 | img: "/flower-park.jpeg", 32 | }, 33 | }); 34 | await db.item.create({ 35 | data: { 36 | name: "Apple", 37 | title: "My apple", 38 | favorite: false, 39 | img: "/apple.jpeg", 40 | }, 41 | }); 42 | await db.item.create({ 43 | data: { 44 | name: "Goods", 45 | title: "My goods", 46 | favorite: false, 47 | img: "/goods.jpeg", 48 | }, 49 | }); 50 | await db.item.create({ 51 | data: { 52 | name: "Dice", 53 | title: "Cool dices", 54 | favorite: true, 55 | img: "/dice.jpeg", 56 | }, 57 | }); 58 | await db.item.create({ 59 | data: { 60 | name: "Darts", 61 | title: "Antique darts board", 62 | favorite: false, 63 | img: "/darts.jpeg", 64 | }, 65 | }); 66 | }; 67 | 68 | export default seed; 69 | -------------------------------------------------------------------------------- /app/components/molecules/formDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "@material-ui/core/Button"; 3 | import Dialog from "@material-ui/core/Dialog"; 4 | import DialogActions from "@material-ui/core/DialogActions"; 5 | import DialogContent from "@material-ui/core/DialogContent"; 6 | import DialogContentText from "@material-ui/core/DialogContentText"; 7 | import DialogTitle from "@material-ui/core/DialogTitle"; 8 | import DialogForm from "../templates/DialogForm"; 9 | 10 | /** 11 | * 12 | */ 13 | type FormDialogProps = { 14 | title: string; 15 | detail: string; 16 | open: boolean; 17 | onSuccess: (values: any) => Promise; 18 | onClose: () => void; 19 | }; 20 | 21 | /** 22 | * 23 | * @param {string} title 24 | * @param {string} detail 25 | * @param {boolean} open 26 | * @param {() => void} onClose 27 | * @param {(values: any) => Promise} onSuccess 28 | * @returns {JSX.Element} 29 | * @constructor 30 | */ 31 | export const FormDialog = ({ 32 | title, 33 | detail, 34 | open, 35 | onClose, 36 | onSuccess, 37 | }: FormDialogProps) => { 38 | return ( 39 |
40 | 41 | {title} 42 | 43 | {detail} 44 | 45 | 46 | 47 | 50 | 51 | 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /db/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "mysql" 6 | // provider = "postgres" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | generator client { 11 | provider = "prisma-client-js" 12 | } 13 | 14 | // -------------------------------------- 15 | model User { 16 | id Int @default(autoincrement()) @id 17 | createdAt DateTime @default(now()) 18 | updatedAt DateTime @updatedAt 19 | name String? 20 | email String @unique 21 | hashedPassword String? 22 | role String @default("user") 23 | sessions Session[] 24 | } 25 | 26 | model Session { 27 | id Int @default(autoincrement()) @id 28 | createdAt DateTime @default(now()) 29 | updatedAt DateTime @updatedAt 30 | expiresAt DateTime? 31 | handle String @unique 32 | user User? @relation(fields: [userId], references: [id]) 33 | userId Int? 34 | hashedSessionToken String? 35 | antiCSRFToken String? 36 | publicData String? 37 | privateData String? 38 | } 39 | 40 | model Item { 41 | id Int @default(autoincrement()) @id 42 | createdAt DateTime @default(now()) 43 | updatedAt DateTime @updatedAt 44 | name String 45 | title String 46 | img String 47 | favorite Boolean? 48 | } 49 | 50 | model Notification { 51 | id Int @default(autoincrement()) @id 52 | createdAt DateTime @default(now()) 53 | updatedAt DateTime @updatedAt 54 | title String 55 | description String 56 | category String 57 | unread Boolean @default(true) 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/dockle-security-scan.yml: -------------------------------------------------------------------------------- 1 | name: Dockle Security Scan 2 | 3 | on: 4 | pull_request: 5 | branches: [v2] 6 | 7 | env: 8 | DOCKER_BUILDKIT: "1" 9 | APP_NAME: sbcntr-frontend 10 | 11 | jobs: 12 | dockle-scan: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Set image tag 20 | run: | 21 | echo "IMAGE_TAG=$(echo ${{ github.sha }} | cut -c 1-7)" >> $GITHUB_ENV 22 | 23 | - name: Build Docker image 24 | run: | 25 | echo "===== Docker Build started on $(date) =====" 26 | echo "Building the Docker image..." 27 | docker build -f Dockerfile -t ${APP_NAME}:latest . 28 | docker tag ${APP_NAME}:latest ${APP_NAME}:${IMAGE_TAG} 29 | docker image ls 30 | echo "Completed the ${APP_NAME} Docker image build." 31 | 32 | - name: Install Dockle 33 | run: | 34 | VERSION=$(curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') 35 | curl -L -o dockle.deb https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.deb 36 | sudo dpkg -i dockle.deb && rm dockle.deb 37 | 38 | - name: Run Dockle security scan 39 | run: | 40 | echo "===== Dockle security scan started on $(date) =====" 41 | dockle --exit-code 1 --exit-level FATAL ${APP_NAME}:${IMAGE_TAG} 42 | echo "===== Dockle security scan completed on $(date) =====" 43 | 44 | - name: Generate Dockle report 45 | if: always() 46 | run: | 47 | echo "===== Generating Dockle report on $(date) =====" 48 | dockle --format json --output dockle-report.json ${APP_NAME}:${IMAGE_TAG} || true 49 | dockle --format table ${APP_NAME}:${IMAGE_TAG} || true 50 | 51 | - name: Upload Dockle report 52 | if: always() 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: dockle-report 56 | path: dockle-report.json 57 | retention-days: 30 58 | -------------------------------------------------------------------------------- /app/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { BlitzPage } from "blitz"; 2 | import Layout from "app/layouts/Layout"; 3 | import React from "react"; 4 | import Container from "@material-ui/core/Container"; 5 | import CopyrightComponent from "../components/molecules/copyright"; 6 | import CssBaseline from "@material-ui/core/CssBaseline"; 7 | import { useHelloMessage } from "../hooks/useHelloMessage"; 8 | import { UserInfo } from "../components/organisms/user-info"; 9 | import { makeStyles } from "@material-ui/core/styles"; 10 | 11 | const useStyles = makeStyles((theme) => ({ 12 | root: { 13 | minHeight: "80vh", 14 | display: "flex", 15 | flexDirection: "column", 16 | justifyContent: "center", 17 | alignItems: "center", 18 | }, 19 | logo: { 20 | marginBottom: theme.spacing(1), 21 | "& img": { 22 | width: 500, 23 | }, 24 | }, 25 | main: { 26 | padding: theme.spacing(1), 27 | flex: 1, 28 | display: "flex", 29 | flexDirection: "column", 30 | justifyContent: "center", 31 | alignItems: "center", 32 | 33 | "& p": { 34 | fontSize: 32, 35 | }, 36 | }, 37 | buttonContainer: { 38 | marginTop: theme.spacing(1), 39 | marginBottom: theme.spacing(1), 40 | }, 41 | })); 42 | 43 | /** 44 | * 45 | * @returns {JSX.Element} 46 | * @constructor 47 | */ 48 | const Home: BlitzPage = () => { 49 | const classes = useStyles(); 50 | const hello = useHelloMessage(); 51 | 52 | return ( 53 | 54 | 55 |
56 |
57 | sbcntr-frontend 58 |
59 |

60 | {hello} 61 |

62 |
63 | 64 |
65 |
66 | 67 |
68 | ); 69 | }; 70 | 71 | Home.redirectAuthenticatedTo = "/top"; 72 | Home.getLayout = (page) => {page}; 73 | 74 | export default Home; 75 | -------------------------------------------------------------------------------- /app/components/Form.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, PropsWithoutRef } from "react"; 2 | import { 3 | Form as FinalForm, 4 | FormProps as FinalFormProps, 5 | } from "react-final-form"; 6 | import * as z from "zod"; 7 | import Button from "@material-ui/core/Button"; 8 | 9 | export { FORM_ERROR } from "final-form"; 10 | 11 | type FormProps> = { 12 | /** All your form fields */ 13 | children: ReactNode; 14 | /** Text to display in the submit button */ 15 | submitText: string; 16 | schema?: S; 17 | onSubmit: FinalFormProps>["onSubmit"]; 18 | initialValues?: FinalFormProps>["initialValues"]; 19 | } & Omit, "onSubmit">; 20 | 21 | export function Form>({ 22 | children, 23 | submitText, 24 | schema, 25 | initialValues, 26 | onSubmit, 27 | ...props 28 | }: FormProps) { 29 | return ( 30 | { 33 | if (!schema) return; 34 | try { 35 | schema.parse(values); 36 | } catch (error) { 37 | return error.formErrors.fieldErrors; 38 | } 39 | }} 40 | onSubmit={onSubmit} 41 | render={({ handleSubmit, submitting, submitError }) => ( 42 |
43 | {/* Form fields supplied as children are rendered here */} 44 | {children} 45 | 46 | {submitError && ( 47 |
48 | {submitError} 49 |
50 | )} 51 | 52 | 61 | 62 | 67 |
68 | )} 69 | /> 70 | ); 71 | } 72 | 73 | export default Form; 74 | -------------------------------------------------------------------------------- /utils/server/api-client.ts: -------------------------------------------------------------------------------- 1 | // 別エンドポイントを呼び出しする場合のAPIクライアント 2 | import { APP_SERVICE_HOST } from "app/config"; 3 | import fetch from "isomorphic-unfetch"; 4 | import { 5 | ApiErrorResponse, 6 | ApiSuccessResponse, 7 | createApiResponse, 8 | } from "./api-response"; 9 | 10 | type ApiMethod = "GET" | "POST" | "DELETE"; 11 | export type ApiResponse = 12 | | { 13 | statusCode: number; 14 | result: ApiSuccessResponse; 15 | error: null; 16 | } 17 | | { 18 | statusCode: number; 19 | result: null; 20 | error: ApiErrorResponse; 21 | }; 22 | 23 | export const apiClient = async ( 24 | path: string, 25 | method: ApiMethod, 26 | header?: { [key: string]: string }, 27 | payload?: any, 28 | host?: string 29 | ): Promise> => { 30 | const _host = host ?? APP_SERVICE_HOST; 31 | const url = new URL(`${_host}${path}`); 32 | const headers = { 33 | ...(method !== "GET" && { 34 | "Content-Type": "application/json; charset=utf-8", 35 | }), 36 | ...header, 37 | }; 38 | const options: RequestInit = { 39 | method, 40 | headers, 41 | body: payload, 42 | }; 43 | 44 | if (method === "GET") { 45 | if (!header) { 46 | delete options.headers; 47 | } 48 | delete options.body; 49 | } 50 | const res = await fetch(url.toString(), options); 51 | const statusCode = res.status; 52 | const apiReturn = await res.json(); 53 | const { result: originalResult, error: originalError } = createApiResponse( 54 | apiReturn 55 | ); 56 | 57 | const result: ApiSuccessResponse | null = 58 | statusCode === 404 ? null : originalResult; 59 | const error: ApiErrorResponse | null = 60 | statusCode === 404 61 | ? { 62 | meta: { 63 | code: 404, 64 | title: "Not Found", 65 | }, 66 | body: {}, 67 | } 68 | : originalError; 69 | 70 | if (error !== null) { 71 | return { 72 | statusCode, 73 | result: null, 74 | error, 75 | }; 76 | } 77 | 78 | return { 79 | statusCode, 80 | result: result ?? ({} as T), 81 | error: null, 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /app/components/templates/DialogForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Form, FORM_ERROR } from "app/components/Form"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { TextField } from "mui-rff"; 5 | import { ERROR_MESSAGES } from "utils/message"; 6 | import { ItemInput } from "../../item/validataion"; 7 | 8 | export const useStyles = makeStyles((theme) => ({ 9 | container: { 10 | marginTop: theme.spacing(8), 11 | display: "flex", 12 | flexDirection: "column", 13 | alignItems: "center", 14 | }, 15 | })); 16 | 17 | /** 18 | * 19 | */ 20 | type DialogFormProps = { 21 | onSuccess?: (params: any) => void; 22 | onClose: () => void; 23 | }; 24 | 25 | const getImgPath = () => { 26 | const sec = new Date().getSeconds(); 27 | switch (sec % 5) { 28 | case 0: 29 | return "/flower-park.jpeg"; 30 | case 1: 31 | return "/apple.jpeg"; 32 | case 2: 33 | return "/dice.jpeg"; 34 | case 3: 35 | return "/arai-uma.jpeg"; 36 | } 37 | 38 | return "/apple.jpeg"; 39 | }; 40 | 41 | /** 42 | * 43 | * @param {((params: any) => void) | undefined} onSuccess 44 | * @param {() => void} onClose 45 | * @returns {JSX.Element} 46 | * @constructor 47 | */ 48 | export const DialogForm = ({ onSuccess, onClose }: DialogFormProps) => { 49 | const classes = useStyles(); 50 | const img = getImgPath(); 51 | return ( 52 |
53 |
{ 58 | try { 59 | await onSuccess?.(values); 60 | onClose(); 61 | } catch (error) { 62 | return { 63 | [FORM_ERROR]: ERROR_MESSAGES.ITEM.FAILED_TO_ADD, 64 | }; 65 | } 66 | }} 67 | > 68 | 75 | 82 | 83 |
84 | ); 85 | }; 86 | 87 | export default DialogForm; 88 | -------------------------------------------------------------------------------- /app/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AppProps, 3 | ErrorComponent, 4 | useRouter, 5 | AuthenticationError, 6 | AuthorizationError, 7 | } from "blitz"; 8 | import { ErrorBoundary, FallbackProps } from "react-error-boundary"; 9 | import { queryCache } from "react-query"; 10 | import LoginForm from "app/auth/components/LoginForm"; 11 | 12 | import CssBaseline from "@material-ui/core/CssBaseline"; 13 | import { ThemeProvider, createMuiTheme } from "@material-ui/core/styles"; 14 | import React from "react"; 15 | import Container from "@material-ui/core/Container"; 16 | import CopyrightComponent from "../components/molecules/copyright"; 17 | 18 | //You can customize this as you want and even move it out to a separate file 19 | const theme = createMuiTheme({ 20 | palette: { 21 | type: "light", 22 | }, 23 | }); 24 | 25 | export default function App({ Component, pageProps }: AppProps) { 26 | const getLayout = Component.getLayout || ((page) => page); 27 | const router = useRouter(); 28 | 29 | React.useEffect(() => { 30 | // Remove the server-side injected CSS. 31 | const jssStyles = document.querySelector("#jss-server-side"); 32 | if (jssStyles && jssStyles.parentElement) { 33 | jssStyles.parentElement.removeChild(jssStyles); 34 | } 35 | }, []); 36 | 37 | return ( 38 | 39 | 40 | { 44 | // This ensures the Blitz useQuery hooks will automatically refetch 45 | // data any time you reset the error boundary 46 | queryCache.resetErrorBoundaries(); 47 | }} 48 | > 49 | {getLayout()} 50 | 51 | 52 | ); 53 | } 54 | 55 | function RootErrorFallback({ error, resetErrorBoundary }: FallbackProps) { 56 | if (error instanceof AuthenticationError) { 57 | return ( 58 | 59 | 60 | 61 | 62 | ); 63 | } else if (error instanceof AuthorizationError) { 64 | return ( 65 | 69 | ); 70 | } else { 71 | return ( 72 | 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/components/templates/ItemlistForm.tsx: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "@material-ui/core/styles"; 2 | import * as React from "react"; 3 | import { BlitzRouter, useMutation } from "blitz"; 4 | import { useAllData } from "../../hooks/useAllData"; 5 | import { useState } from "react"; 6 | import createItem from "../../item/mutations/createItem"; 7 | import Container from "@material-ui/core/Container"; 8 | import Grid from "@material-ui/core/Grid"; 9 | import { Fab } from "@material-ui/core"; 10 | import AddIcon from "@material-ui/icons/Add"; 11 | import { FormDialog } from "../molecules/formDialog"; 12 | import { ItemCard } from "../organisms/itemcard"; 13 | 14 | const useStyles = makeStyles((theme) => ({ 15 | appBarSpacer: theme.mixins.toolbar, 16 | container: { 17 | paddingTop: theme.spacing(4), 18 | paddingBottom: theme.spacing(4), 19 | }, 20 | floating: { 21 | position: "fixed", 22 | right: theme.spacing(4), 23 | bottom: theme.spacing(4), 24 | }, 25 | })); 26 | 27 | /** 28 | * 29 | */ 30 | type ItemListFormProps = { 31 | router: BlitzRouter; 32 | }; 33 | 34 | /** 35 | * 36 | * @param {BlitzRouter} router 37 | * @returns {JSX.Element} 38 | * @constructor 39 | */ 40 | export const ItemListForm = ({ router }: ItemListFormProps) => { 41 | const classes = useStyles(); 42 | const items = useAllData(null); 43 | 44 | // For ItemAdd Dialog 45 | const [formOpen, setFormOpen] = useState(false); 46 | const [createItemMutation] = useMutation(createItem); 47 | const handleClickOpen = () => { 48 | setFormOpen(true); 49 | }; 50 | 51 | return ( 52 | <> 53 |
54 | 55 | 56 | {items?.result?.data.map((value) => { 57 | return ( 58 | 66 | ); 67 | })} 68 | 69 | 70 | 76 | 77 | 78 | { 85 | setFormOpen(false); 86 | router.reload(); 87 | }} 88 | onSuccess={(values) => createItemMutation(values)} 89 | /> 90 | 91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /app/auth/components/SignupForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useMutation, useRouter } from "blitz"; 3 | import { Form, FORM_ERROR } from "app/components/Form"; 4 | import signup from "app/auth/mutations/signup"; 5 | import { SignupInput } from "app/auth/validations"; 6 | import Typography from "@material-ui/core/Typography"; 7 | import { TextField } from "mui-rff"; 8 | import { useStyles } from "./LoginForm"; 9 | import Avatar from "@material-ui/core/Avatar"; 10 | import AccountCircleOutlined from "@material-ui/icons/AccountCircleOutlined"; 11 | import { ERROR_MESSAGES } from "../../../utils/message"; 12 | import Button from "@material-ui/core/Button"; 13 | 14 | type SignupFormProps = { 15 | onSuccess?: () => void; 16 | }; 17 | 18 | /** 19 | * 20 | * @param {SignupFormProps} props 21 | * @returns {JSX.Element} 22 | * @constructor 23 | */ 24 | export const SignupForm = (props: SignupFormProps) => { 25 | const router = useRouter(); 26 | const [signupMutation] = useMutation(signup); 27 | const classes = useStyles(); 28 | 29 | return ( 30 |
31 | 32 | 33 | 34 | 35 | 新規アカウント作成 36 | 37 |
{ 42 | try { 43 | await signupMutation(values); 44 | props.onSuccess?.(); 45 | } catch (error) { 46 | if ( 47 | error.code === "P2002" && 48 | error.meta?.target?.includes("email") 49 | ) { 50 | // This error comes from Prisma 51 | return { email: ERROR_MESSAGES.SIGNUP.DUPLICATED_ADDRESS }; 52 | } else { 53 | return { [FORM_ERROR]: error.toString() }; 54 | } 55 | } 56 | }} 57 | > 58 | 68 | 79 | 80 | 83 |
84 | ); 85 | }; 86 | 87 | export default SignupForm; 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sbcntr-frontend", 3 | "version": "1.0.1", 4 | "scripts": { 5 | "dev": "npx blitz dev --port 3100", 6 | "start": "npx blitz start --port 3100", 7 | "start:prd": "npx blitz start --port 80", 8 | "studio": "npx blitz console", 9 | "build": "npx blitz build", 10 | "lint": "eslint --ignore-path .gitignore --ext .js,.ts,.tsx .", 11 | "migrate:dev": "npx blitz prisma migrate dev --preview-feature", 12 | "migrate:prd": "npx blitz prisma migrate deploy --preview-feature", 13 | "seed": "npx blitz db seed", 14 | "test": "jest", 15 | "test:watch": "jest --watch" 16 | }, 17 | "browserslist": [ 18 | "defaults" 19 | ], 20 | "prisma": { 21 | "schema": "db/schema.prisma" 22 | }, 23 | "prettier": { 24 | "semi": true, 25 | "printWidth": 80 26 | }, 27 | "lint-staged": { 28 | "*.{js,ts,tsx}": [ 29 | "eslint --fix" 30 | ] 31 | }, 32 | "husky": { 33 | "hooks": { 34 | "pre-commit": "tsc && lint-staged && pretty-quick --staged", 35 | "pre-push": "npm run lint" 36 | } 37 | }, 38 | "dependencies": { 39 | "@material-ui/core": "4.11.3", 40 | "@material-ui/icons": "4.11.2", 41 | "@material-ui/lab": "4.0.0-alpha.57", 42 | "@prisma/client": "3.13.0", 43 | "blitz": "0.33.1", 44 | "final-form": "4.20.1", 45 | "isomorphic-unfetch": "3.1.0", 46 | "mui-rff": "3.0.3", 47 | "prisma": "3.13.0", 48 | "react": "0.0.0-experimental-3310209d0", 49 | "react-dom": "0.0.0-experimental-3310209d0", 50 | "react-error-boundary": "3.1.0", 51 | "react-final-form": "6.5.2", 52 | "react-icons": "4.1.0", 53 | "secure-password": "4.0.0", 54 | "styled-components": "5.2.1", 55 | "typescript": "4.1.2", 56 | "zod": "1.11.11" 57 | }, 58 | "devDependencies": { 59 | "@testing-library/jest-dom": "5.11.6", 60 | "@testing-library/react": "11.2.2", 61 | "@testing-library/react-hooks": "3.5.0", 62 | "@types/jest": "26.0.16", 63 | "@types/react": "17.0.2", 64 | "@types/secure-password": "3.1.0", 65 | "@typescript-eslint/eslint-plugin": "4.9.0", 66 | "@typescript-eslint/parser": "4.9.0", 67 | "babel-eslint": "10.1.0", 68 | "eslint": "7.21.0", 69 | "eslint-config-react-app": "5.2.1", 70 | "eslint-plugin-flowtype": "5.2.0", 71 | "eslint-plugin-import": "2.22.1", 72 | "eslint-plugin-jsx-a11y": "6.4.1", 73 | "eslint-plugin-react": "7.21.5", 74 | "eslint-plugin-react-hooks": "4.2.0", 75 | "husky": "4.3.8", 76 | "jest": "26.6.3", 77 | "jest-environment-jsdom-fourteen": "1.0.1", 78 | "jest-watch-typeahead": "0.6.1", 79 | "lint-staged": "10.5.4", 80 | "prettier": "2.2.1", 81 | "prettier-plugin-prisma": "0.4.0", 82 | "pretty-quick": "3.1.0", 83 | "preview-email": "3.0.3", 84 | "react-test-renderer": "16.14.0", 85 | "ts-jest": "26.4.4" 86 | }, 87 | "private": true 88 | } 89 | -------------------------------------------------------------------------------- /app/auth/components/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { AuthenticationError, Link, useMutation } from "blitz" 3 | import { Form, FORM_ERROR } from "app/components/Form" 4 | import login from "app/auth/mutations/login" 5 | import { LoginInput } from "app/auth/validations" 6 | import Avatar from "@material-ui/core/Avatar" 7 | import LockOutlinedIcon from "@material-ui/icons/LockOutlined" 8 | import Typography from "@material-ui/core/Typography" 9 | import { makeStyles } from "@material-ui/core/styles" 10 | import { TextField } from "mui-rff" 11 | import { ERROR_MESSAGES } from "utils/message" 12 | 13 | export const useStyles = makeStyles((theme) => ({ 14 | container: { 15 | marginTop: theme.spacing(8), 16 | display: "flex", 17 | flexDirection: "column", 18 | alignItems: "center", 19 | }, 20 | login: { 21 | margin: theme.spacing(1), 22 | backgroundColor: theme.palette.secondary.main, 23 | }, 24 | signup: { 25 | margin: theme.spacing(1), 26 | backgroundColor: theme.palette.primary.main, 27 | }, 28 | form: { 29 | width: "100%", // Fix IE 11 issue. 30 | marginTop: theme.spacing(1), 31 | }, 32 | submit: { 33 | margin: theme.spacing(3, 0, 2), 34 | }, 35 | })) 36 | 37 | type LoginFormProps = { 38 | onSuccess?: () => void 39 | } 40 | 41 | /** 42 | * 43 | * @param {(() => void) | undefined} onSuccess 44 | * @returns {JSX.Element} 45 | * @constructor 46 | */ 47 | export const LoginForm = ({ onSuccess }: LoginFormProps) => { 48 | const [loginMutation] = useMutation(login) 49 | const classes = useStyles() 50 | return ( 51 |
52 | 53 | 54 | 55 | 56 | ログイン 57 | 58 |
{ 63 | try { 64 | await loginMutation(values) 65 | onSuccess?.() 66 | } catch (error) { 67 | if (error instanceof AuthenticationError) { 68 | return { [FORM_ERROR]: ERROR_MESSAGES.LOGIN.INVALID_PASSWORD } 69 | } else { 70 | return { 71 | [FORM_ERROR]: ERROR_MESSAGES.LOGIN.NO_USER, 72 | } 73 | } 74 | } 75 | }} 76 | > 77 | 87 | 98 | 99 |
100 | はじめての方はこちら 101 |
102 |
103 | ) 104 | } 105 | 106 | export default LoginForm 107 | -------------------------------------------------------------------------------- /app/components/organisms/itemcard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; 3 | import Card from "@material-ui/core/Card"; 4 | import CardHeader from "@material-ui/core/CardHeader"; 5 | import CardMedia from "@material-ui/core/CardMedia"; 6 | import CardContent from "@material-ui/core/CardContent"; 7 | import Avatar from "@material-ui/core/Avatar"; 8 | import IconButton from "@material-ui/core/IconButton"; 9 | import Typography from "@material-ui/core/Typography"; 10 | import { red } from "@material-ui/core/colors"; 11 | import FavoriteIcon from "@material-ui/icons/Favorite"; 12 | import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder"; 13 | import { useMutation } from "blitz"; 14 | import updateItem from "../../item/mutations/updateItem"; 15 | 16 | type ItemCardProps = { 17 | id: number; 18 | name: string | null; 19 | title: string | null; 20 | date?: string; 21 | img?: string; 22 | favorite: boolean | null; 23 | }; 24 | 25 | const useStyles = makeStyles((theme: Theme) => 26 | createStyles({ 27 | root: { 28 | width: 200, 29 | margin: theme.spacing(2, 2, 0, 0), 30 | }, 31 | media: { 32 | height: 50, 33 | paddingTop: "56.25%", // 16:9 34 | }, 35 | expand: { 36 | transform: "rotate(0deg)", 37 | marginLeft: "auto", 38 | transition: theme.transitions.create("transform", { 39 | duration: theme.transitions.duration.shortest, 40 | }), 41 | }, 42 | expandOpen: { 43 | transform: "rotate(180deg)", 44 | }, 45 | avatar: { 46 | backgroundColor: red[500], 47 | }, 48 | }) 49 | ); 50 | 51 | /** 52 | * 53 | * @returns {JSX.Element} 54 | * @constructor 55 | */ 56 | export const ItemCard = (props: ItemCardProps): JSX.Element => { 57 | const { favorite, date, id } = props; 58 | const name = props.name || "default name"; 59 | const title = props.title || "default title"; 60 | const img = props.img || "/flower-park.jpeg"; 61 | 62 | const classes = useStyles(); 63 | const [itemFavorite, setItemFavorite] = useState(favorite); 64 | const [updateItemMutation] = useMutation(updateItem); 65 | 66 | return ( 67 | 68 | 71 | A 72 | 73 | } 74 | action={ 75 | { 78 | const favorite = !itemFavorite; 79 | setItemFavorite(favorite); 80 | try { 81 | await updateItemMutation({ 82 | id, 83 | favorite, 84 | }); 85 | } catch (e) {} 86 | }} 87 | > 88 | {itemFavorite ? ( 89 | 90 | ) : ( 91 | 92 | )} 93 | 94 | } 95 | title={title} 96 | subheader={date} 97 | /> 98 | 99 | 100 | 101 | {name} 102 | 103 | 104 | 105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /test/utils.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RouterContext, BlitzRouter } from "blitz"; 3 | import { render as defaultRender } from "@testing-library/react"; 4 | import { renderHook as defaultRenderHook } from "@testing-library/react-hooks"; 5 | 6 | export * from "@testing-library/react"; 7 | 8 | // -------------------------------------------------------------------------------- 9 | // This file customizes the render() and renderHook() test functions provided 10 | // by React testing library. It adds a router context wrapper with a mocked router. 11 | // 12 | // You should always import `render` and `renderHook` from this file 13 | // 14 | // This is the place to add any other context providers you need while testing. 15 | // -------------------------------------------------------------------------------- 16 | 17 | // -------------------------------------------------- 18 | // render() 19 | // -------------------------------------------------- 20 | // Override the default test render with our own 21 | // 22 | // You can override the router mock like this: 23 | // 24 | // const { baseElement } = render(, { 25 | // router: { pathname: '/my-custom-pathname' }, 26 | // }); 27 | // -------------------------------------------------- 28 | export function render( 29 | ui: RenderUI, 30 | { wrapper, router, ...options }: RenderOptions = {} 31 | ) { 32 | if (!wrapper) { 33 | // Add a default context wrapper if one isn't supplied from the test 34 | wrapper = ({ children }) => ( 35 | 36 | {children} 37 | 38 | ); 39 | } 40 | return defaultRender(ui, { wrapper, ...options }); 41 | } 42 | 43 | // -------------------------------------------------- 44 | // renderHook() 45 | // -------------------------------------------------- 46 | // Override the default test renderHook with our own 47 | // 48 | // You can override the router mock like this: 49 | // 50 | // const result = renderHook(() => myHook(), { 51 | // router: { pathname: '/my-custom-pathname' }, 52 | // }); 53 | // -------------------------------------------------- 54 | export function renderHook( 55 | hook: RenderHook, 56 | { wrapper, router, ...options }: RenderHookOptions = {} 57 | ) { 58 | if (!wrapper) { 59 | // Add a default context wrapper if one isn't supplied from the test 60 | wrapper = ({ children }) => ( 61 | 62 | {children} 63 | 64 | ); 65 | } 66 | return defaultRenderHook(hook, { wrapper, ...options }); 67 | } 68 | 69 | export const mockRouter: BlitzRouter = { 70 | isLocaleDomain: false, 71 | isPreview: false, 72 | isReady: false, 73 | basePath: "", 74 | pathname: "/", 75 | route: "/", 76 | asPath: "/", 77 | params: {}, 78 | query: {}, 79 | push: jest.fn(), 80 | replace: jest.fn(), 81 | reload: jest.fn(), 82 | back: jest.fn(), 83 | prefetch: jest.fn(), 84 | beforePopState: jest.fn(), 85 | events: { 86 | on: jest.fn(), 87 | off: jest.fn(), 88 | emit: jest.fn(), 89 | }, 90 | isFallback: false, 91 | }; 92 | 93 | type DefaultParams = Parameters; 94 | type RenderUI = DefaultParams[0]; 95 | type RenderOptions = DefaultParams[1] & { router?: Partial }; 96 | 97 | type DefaultHookParams = Parameters; 98 | type RenderHook = DefaultHookParams[0]; 99 | type RenderHookOptions = DefaultHookParams[1] & { 100 | router?: Partial; 101 | }; 102 | -------------------------------------------------------------------------------- /app/components/templates/NotificationForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { makeStyles } from "@material-ui/core/styles"; 3 | import { Button, Snackbar } from "@material-ui/core"; 4 | import List from "@material-ui/core/List"; 5 | import ListItem from "@material-ui/core/ListItem"; 6 | import ListItemText from "@material-ui/core/ListItemText"; 7 | import ListItemAvatar from "@material-ui/core/ListItemAvatar"; 8 | import MailIcon from "@material-ui/icons/Mail"; 9 | import DraftsIcon from "@material-ui/icons/Drafts"; 10 | import Avatar from "@material-ui/core/Avatar"; 11 | import { BlitzRouter, useMutation } from "blitz"; 12 | import updateNotification from "../../notification/mutations/updateNotification"; 13 | import { useNotifications } from "../../hooks/useNotifications"; 14 | import { Alert as SbcntrAlert } from "../atoms/alert"; 15 | 16 | export const useStyles = makeStyles((theme) => ({ 17 | root: { 18 | paddingLeft: theme.spacing(1), 19 | }, 20 | container: { 21 | marginTop: theme.spacing(8), 22 | display: "flex", 23 | flexDirection: "column", 24 | alignItems: "flex-start", 25 | }, 26 | })); 27 | 28 | /** 29 | * 30 | */ 31 | type NotificationFormProps = { 32 | router: BlitzRouter; 33 | }; 34 | 35 | export const NotificationForm = ({ router }: NotificationFormProps) => { 36 | const classes = useStyles(); 37 | let notifications = useNotifications(); 38 | const [readNotifications] = useMutation(updateNotification); 39 | 40 | const [open, setOpen] = useState(false); 41 | const [message, setMessage] = useState({ body: "", status: false }); 42 | const handleBarOpen = () => { 43 | setOpen(true); 44 | }; 45 | const handleBarClose = () => { 46 | setOpen(false); 47 | router.reload(); 48 | }; 49 | 50 | return ( 51 |
52 | 57 | 61 | {message.body} 62 | 63 | 64 | 65 | 66 | 95 | 96 | {notifications?.map((notif, index) => { 97 | return ( 98 | 99 | 100 | {notif.unread ? : } 101 | 102 | 106 | 107 |

{notif.createdAt}

108 |
109 |
110 | ); 111 | })} 112 |
113 |
114 | ); 115 | }; 116 | 117 | export default NotificationForm; 118 | -------------------------------------------------------------------------------- /app/components/organisms/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "@material-ui/core/styles"; 2 | import { DRAWER_WIDTH } from "../../config"; 3 | import { useMutation, useRouter } from "blitz"; 4 | import logout from "../../auth/mutations/logout"; 5 | import * as React from "react"; 6 | import { useState } from "react"; 7 | import Grid from "@material-ui/core/Grid"; 8 | import CssBaseline from "@material-ui/core/CssBaseline"; 9 | import AppBar from "@material-ui/core/AppBar"; 10 | import clsx from "clsx"; 11 | import Toolbar from "@material-ui/core/Toolbar"; 12 | import IconButton from "@material-ui/core/IconButton"; 13 | import MenuIcon from "@material-ui/icons/Menu"; 14 | import Typography from "@material-ui/core/Typography"; 15 | import Badge from "@material-ui/core/Badge"; 16 | import NotificationsIcon from "@material-ui/icons/Notifications"; 17 | import Drawer from "@material-ui/core/Drawer"; 18 | import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; 19 | import Divider from "@material-ui/core/Divider"; 20 | import List from "@material-ui/core/List"; 21 | import { MainListItems } from "./menuItem"; 22 | import { ListItem, ListItemIcon, ListItemText } from "@material-ui/core"; 23 | import ExitToAppIcon from "@material-ui/icons/ExitToApp"; 24 | import { useNotificationCount } from "../../hooks/useNotificationCount"; 25 | 26 | const useStyles = makeStyles((theme) => ({ 27 | root: { 28 | display: "flex", 29 | }, 30 | title: { 31 | padding: theme.spacing(2), 32 | }, 33 | toolbar: { 34 | display: "flex", 35 | justifyContent: "space-between", 36 | paddingRight: theme.spacing(3), // keep right padding when drawer closed 37 | }, 38 | toolbarIcon: { 39 | display: "flex", 40 | alignItems: "center", 41 | justifyContent: "flex-end", 42 | padding: "0 8px", 43 | ...theme.mixins.toolbar, 44 | }, 45 | appBar: { 46 | zIndex: theme.zIndex.drawer + 1, 47 | transition: theme.transitions.create(["width", "margin"], { 48 | easing: theme.transitions.easing.sharp, 49 | duration: theme.transitions.duration.leavingScreen, 50 | }), 51 | }, 52 | appBarShift: { 53 | marginLeft: DRAWER_WIDTH, 54 | width: `calc(100% - ${DRAWER_WIDTH}px)`, 55 | transition: theme.transitions.create(["width", "margin"], { 56 | easing: theme.transitions.easing.sharp, 57 | duration: theme.transitions.duration.enteringScreen, 58 | }), 59 | }, 60 | menuButton: { 61 | marginRight: 36, 62 | }, 63 | menuButtonHidden: { 64 | display: "none", 65 | }, 66 | drawerPaper: { 67 | position: "relative", 68 | whiteSpace: "nowrap", 69 | width: DRAWER_WIDTH, 70 | transition: theme.transitions.create("width", { 71 | easing: theme.transitions.easing.sharp, 72 | duration: theme.transitions.duration.enteringScreen, 73 | }), 74 | }, 75 | drawerPaperClose: { 76 | overflowX: "hidden", 77 | transition: theme.transitions.create("width", { 78 | easing: theme.transitions.easing.sharp, 79 | duration: theme.transitions.duration.leavingScreen, 80 | }), 81 | width: theme.spacing(7), 82 | [theme.breakpoints.up("sm")]: { 83 | width: theme.spacing(9), 84 | }, 85 | }, 86 | content: { 87 | flexGrow: 1, 88 | height: "100vh", 89 | overflow: "auto", 90 | }, 91 | })); 92 | 93 | type DashboardProps = { 94 | title: string; 95 | }; 96 | 97 | export const Dashboard: React.FC = ({ title, children }) => { 98 | const classes = useStyles(); 99 | const [logoutMutation] = useMutation(logout); 100 | const router = useRouter(); 101 | const count = useNotificationCount(); 102 | 103 | // For Drawer 104 | const [open, setOpen] = useState(false); 105 | const handleDrawerOpen = () => { 106 | setOpen(true); 107 | }; 108 | const handleDrawerClose = () => { 109 | setOpen(false); 110 | }; 111 | 112 | return ( 113 | 114 | 115 | 119 | 120 | 130 | 131 | 132 | 139 | {title} 140 | 141 | router.push("/notification")} 144 | > 145 | 146 | 147 | 148 | 149 | 150 | 151 | 158 |
159 | 160 | 161 | 162 |
163 | 164 | 165 | 166 | 167 | 168 | 169 |
170 | { 173 | await logoutMutation(); 174 | router.push("/"); 175 | }} 176 | > 177 | 178 | 179 | 180 | 181 | 182 |
183 |
184 |
185 |
{children}
186 |
187 | ); 188 | }; 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 uma-arai 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. --------------------------------------------------------------------------------