diff --git a/app/(v2)/settings/IconUpdateController.stories.tsx b/app/(v2)/settings/IconUpdateController.stories.tsx new file mode 100644 index 000000000..670e5fa4b --- /dev/null +++ b/app/(v2)/settings/IconUpdateController.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import IconUpdateController from "./IconUpdateController"; +import { mockUpdateIconSucceeded } from "./IconUpdateModal.stories"; + +const meta = { + component: IconUpdateController, + args: { + style: { + width: 640, + height: 500, + }, + }, + parameters: { + msw: { + handlers: { + primary: [mockUpdateIconSucceeded], + }, + }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; +export const Primary: Story = {}; diff --git a/app/(v2)/settings/IconUpdateController.tsx b/app/(v2)/settings/IconUpdateController.tsx new file mode 100644 index 000000000..262089ef2 --- /dev/null +++ b/app/(v2)/settings/IconUpdateController.tsx @@ -0,0 +1,86 @@ +"use client"; + +import clsx from "clsx"; +import React, { useState } from "react"; +import Dropzone from "react-dropzone"; + +import useToaster from "~/components/Toaster/useToaster"; +import { graphql } from "~/gql"; + +import IconUpdateModal from "./IconUpdateModal"; + +export const UpdateProfileIconMutation = graphql(` + mutation SettingsPage_ChangeUserIcon($changeTo: File!) { + changeUserIcon(changeTo: $changeTo) { + __typename + ... on ChangeUserIconSucceededSuccess { + user { + id + icon + } + } + } + } +`); +export default function IconUpdateController({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + const [original, setOriginal] = useState(null); + const toast = useToaster(); + + return ( + <> + { + if (acceptedFiles.length === 1) { + setOriginal(URL.createObjectURL(acceptedFiles[0])); + } + }} + > + {({ getRootProps, getInputProps }) => ( +
+ +

アイコンを選択

+
+ )} +
+ {original && ( +
+
{ + setOriginal(null); + }} + className={clsx("absolute inset-0 z-0 bg-black/75")} + >
+ { + toast("アイコンを変更しました"); + setOriginal(null); + }} + handleFailed={() => { + toast("アイコンの変更に失敗しました", { type: "error" }); + }} + handleCancel={() => { + setOriginal(null); + }} + className={clsx("relative z-1 h-[360px] w-[480px]")} + /> +
+ )} + + ); +} diff --git a/app/(v2)/settings/IconUpdateModal.stories.tsx b/app/(v2)/settings/IconUpdateModal.stories.tsx new file mode 100644 index 000000000..853e65018 --- /dev/null +++ b/app/(v2)/settings/IconUpdateModal.stories.tsx @@ -0,0 +1,50 @@ +import { action } from "@storybook/addon-actions"; +import { Meta, StoryObj } from "@storybook/react"; +import { within } from "@storybook/test"; +import { graphql as mswGql } from "msw"; + +import IconUpdateModal, { UpdateProfileIconMutation } from "./IconUpdateModal"; + +export const mockUpdateIconSucceeded = mswGql.mutation( + UpdateProfileIconMutation, + (req, res, ctx) => + res( + ctx.data({ + changeUserIcon: { + __typename: "ChangeUserIconSucceededSuccess", + user: { + id: "user:1", + icon: "/icon.png", + }, + }, + }) + ) +); + +const meta = { + component: IconUpdateModal, + args: { + style: { + width: 640, + height: 500, + }, + original: "/icon.png", + handleSuccess: action("success"), + }, + parameters: { + msw: { + handlers: { + primary: [mockUpdateIconSucceeded], + }, + }, + }, + excludeStories: /^mock/, +} satisfies Meta; +export default meta; + +type Story = StoryObj; +export const Primary: Story = { + async play({ canvasElement }) { + const canvas = within(canvasElement); + }, +}; diff --git a/app/(v2)/settings/IconUpdateModal.tsx b/app/(v2)/settings/IconUpdateModal.tsx new file mode 100644 index 000000000..697c1fece --- /dev/null +++ b/app/(v2)/settings/IconUpdateModal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import clsx from "clsx"; +import React from "react"; +import Cropper, { CropperProps } from "react-easy-crop"; +import { useForm } from "react-hook-form"; +import { useMutation } from "urql"; + +import Button from "~/components/Button"; +import { PlusPictogram } from "~/components/Pictogram"; +import { graphql } from "~/gql"; +import cropImage from "~/utils/crop"; + +export const UpdateProfileIconMutation = graphql(` + mutation SettingsPage_ChangeUserIcon($changeTo: File!) { + changeUserIcon(changeTo: $changeTo) { + __typename + ... on ChangeUserIconSucceededSuccess { + user { + id + icon + } + } + } + } +`); +export default function IconUpdateModal({ + className, + style, + original, + handleSuccess, + handleFailed, + handleCancel, +}: { + className?: string; + style?: React.CSSProperties; + original: string; + handleSuccess(): void; + handleFailed(): void; + handleCancel(): void; +}) { + const [{ fetching }, mutate] = useMutation(UpdateProfileIconMutation); + + const { setValue, handleSubmit } = useForm<{ changeTo: File }>({}); + + const [crop, setCrop] = React.useState({ x: 0, y: 0 }); + const [zoom, setZoom] = React.useState(1); + + return ( +
{ + const data = await mutate({ changeTo }); + if ( + !data || + data.data?.changeUserIcon.__typename !== + "ChangeUserIconSucceededSuccess" + ) + return handleFailed(); + return handleSuccess(); + })} + style={style} + className={clsx( + className, + "flex flex-col gap-y-4 rounded-md border border-obsidian-lighter bg-obsidian-primary p-4" + )} + > +
+ { + const file = await cropImage(original, c2); + if (file) setValue("changeTo", file); + }} + classes={{ + containerClassName: "", + }} + /> +
+
+
+
+ ); +} diff --git a/app/(v2)/settings/page.tsx b/app/(v2)/settings/page.tsx index d3320e057..a8cc406b1 100644 --- a/app/(v2)/settings/page.tsx +++ b/app/(v2)/settings/page.tsx @@ -1,6 +1,7 @@ import { withPageAuthRequired } from "@auth0/nextjs-auth0"; import clsx from "clsx"; +import IconUpdateController from "./IconUpdateController"; import RenameForm from "./RenameForm"; export default withPageAuthRequired(async function Page() { @@ -24,6 +25,16 @@ export default withPageAuthRequired(async function Page() { +
+

+ ユーザーアイコンを変える +

+ +
); diff --git a/app/globals.css b/app/globals.css index 9cecc1621..feaed8af6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,5 @@ +@import "react-image-crop/dist/ReactCrop.css"; + @tailwind base; @tailwind components; @tailwind utilities; diff --git a/codegen.ts b/codegen.ts index 979936052..d45f33218 100644 --- a/codegen.ts +++ b/codegen.ts @@ -11,6 +11,7 @@ const config: CodegenConfig = { dedupeFragments: true, // https://zenn.dev/link/comments/94104de0ddecfc scalars: { DateTime: "string", + File: "File", }, }, presetConfig: {}, diff --git a/package.json b/package.json index 952a06687..0494ed3f3 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "pixi.js": "^7.3.2", "react": "18.2.0", "react-dom": "18.2.0", + "react-dropzone": "^14.2.3", + "react-easy-crop": "^5.0.4", "react-hook-form": "7.49.2", "react-use": "^17.4.0", "sass": "^1.69.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2df04d63..30092c4ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,12 @@ dependencies: react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + react-dropzone: + specifier: ^14.2.3 + version: 14.2.3(react@18.2.0) + react-easy-crop: + specifier: ^5.0.4 + version: 5.0.4(react-dom@18.2.0)(react@18.2.0) react-hook-form: specifier: 7.49.2 version: 7.49.2(react@18.2.0) @@ -3167,7 +3173,7 @@ packages: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: graphql: 16.8.1 - tslib: 2.5.3 + tslib: 2.6.2 dev: true /@graphql-tools/prisma-loader@8.0.2(@types/node@18.18.14)(encoding@0.1.13)(graphql@16.8.1): @@ -3212,7 +3218,7 @@ packages: '@ardatan/relay-compiler': 12.0.0(encoding@0.1.13)(graphql@16.8.1) '@graphql-tools/utils': 10.0.11(graphql@16.8.1) graphql: 16.8.1 - tslib: 2.5.3 + tslib: 2.6.2 transitivePeerDependencies: - encoding - supports-color @@ -7619,6 +7625,11 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: true + /attr-accept@2.2.2: + resolution: {integrity: sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==} + engines: {node: '>=4'} + dev: false + /auto-bind@4.0.0: resolution: {integrity: sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==} engines: {node: '>=8'} @@ -10176,6 +10187,13 @@ packages: flat-cache: 3.2.0 dev: true + /file-selector@0.6.0: + resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} + engines: {node: '>= 12'} + dependencies: + tslib: 2.6.2 + dev: false + /file-system-cache@2.3.0: resolution: {integrity: sha512-l4DMNdsIPsVnKrgEXbJwDJsA5mB8rGwHYERMgqQx/xAUtChPJMre1bXBzDEqqVbWv9AIbFezXMxeEkZDSrXUOQ==} dependencies: @@ -10667,7 +10685,7 @@ packages: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: graphql: 16.8.1 - tslib: 2.5.3 + tslib: 2.6.2 dev: true /graphql-ws@5.14.2(graphql@16.8.1): @@ -13081,6 +13099,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /normalize-wheel@1.0.1: + resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} + dev: false + /npm-normalize-package-bin@3.0.1: resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -14216,6 +14238,30 @@ packages: react: 18.2.0 scheduler: 0.23.0 + /react-dropzone@14.2.3(react@18.2.0): + resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + dependencies: + attr-accept: 2.2.2 + file-selector: 0.6.0 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /react-easy-crop@5.0.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-JfzSk4cBHoksgAtgWUHR/jDYretebMxS0rpAlltP1LeELGMj4WTa420m4PsYFpgQXoJZV0DXmINUlBWAoAD/PQ==} + peerDependencies: + react: '>=16.4.0' + react-dom: '>=16.4.0' + dependencies: + normalize-wheel: 1.0.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tslib: 2.0.1 + dev: false + /react-element-to-jsx-string@15.0.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==} peerDependencies: @@ -15977,6 +16023,10 @@ packages: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} dev: true + /tslib@2.0.1: + resolution: {integrity: sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==} + dev: false + /tslib@2.4.1: resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} dev: true diff --git a/schema.graphql b/schema.graphql index 0a0a90b4a..551e66a66 100644 --- a/schema.graphql +++ b/schema.graphql @@ -152,7 +152,7 @@ type BilibiliMadRequestedTimelineEvent implements TimelineEvent { type BilibiliOriginalSource { """ bilibili側のオリジナルの画像URL - + bilibili側の制約上、別のオリジンから直接埋め込んだりすることはほとんど出来ないことに注意. """ originalThumbnailUrl: String! @@ -505,14 +505,14 @@ input FindMadsByOffsetInput { input FindMadsByOffsetInputFilter { """ (ソース元の投稿時刻) ≧ (入力) - + *ソース元の投稿時刻が不明な場合,このフィルターを入れると全て無視されることに注意.* """ registeredAtGte: DateTime """ (ソース元の投稿時刻) ≦ (入力) - + *ソース元の投稿時刻が不明な場合,このフィルターを入れると全て無視されることに注意.* """ registeredAtLte: DateTime @@ -783,13 +783,18 @@ type MadRegisteredTimelineEvent implements TimelineEvent { video: Video! } +scalar File + type Mutation { addMylistToMylistGroup(input: AddMylistToMylistGroupInput!): AddMylistToMylistGroupPayload! addSemitagToVideo(input: AddSemitagToVideoInput!): AddSemitagToVideoPayload! addTagToVideo(input: AddTagToVideoInput!): AddTagToVideoPayload! addVideoToMylist(input: AddVideoToMylistInput!): AddVideoToMylistReturnUnion! changeMylistShareRange(input: ChangeMylistShareRangeInput!): ChangeMylistShareReturnUnion! + changeUserDisplayName(renameTo: String!): ChangeUserDisplayNameReturnUnion! + changeUserIcon(changeTo: File!): ChangeUserIconReturnPayload! + createMylist(input: CreateMylistInput!): CreateMylistReturnUnion! createMylistGroup(input: CreateMylistGroupInput!): CreateMylistGroupPayload! excludeTagToGroup(input: ExcludeTagToGroupInput!): ExcludeTagToGroupPayload! @@ -2108,7 +2113,7 @@ type SoundcloudRegistrationRequest implements Node & RegistrationRequest { """ OtoMADB側でプロキシしているサムネイル画像のURL - + **無い場合は適当にダミー画像を返す.** """ thumbnailUrl(scale: RegistrationRequestThumbnailScale! = LARGE): String! @@ -3045,4 +3050,10 @@ type YoutubeVideoSourceEventEdge { input YoutubeVideoSourceEventsOrderBy { createdAt: SortOrder -} \ No newline at end of file +} + +union ChangeUserIconReturnPayload = ChangeUserIconSucceededSuccess + +type ChangeUserIconSucceededSuccess { + user: User! +} diff --git a/utils/crop.ts b/utils/crop.ts new file mode 100644 index 000000000..0306eb389 --- /dev/null +++ b/utils/crop.ts @@ -0,0 +1,41 @@ +export const loadImage = (src: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", (error) => reject(error)); + + image.src = src; + }); + +export default async function cropImage( + src: string, + { + width, + height, + x, + y, + }: { + x: number; + y: number; + width: number; + height: number; + } +) { + const img = await loadImage(src); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) return null; + + canvas.width = width; + canvas.height = height; + + ctx.drawImage(img, x, y, width, height, 0, 0, width, height); + + const a = await new Promise((r: BlobCallback) => canvas.toBlob(r)); + if (!a) return null; + + return new File([a], "image.png", { type: "image/png" }); +}