diff --git a/.storybook/main.ts b/.storybook/main.ts index 4dc884ff..a95f3c9a 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -22,29 +22,31 @@ const config: StorybookConfig = { autodocs: true, }, webpackFinal: async (config) => { - /** - * FIXME 良い方法があればそっちに変更したい。 - * SVGに関するルールを削除 - */ - config.module.rules = config.module.rules.map((rule) => { - if ( - rule && - rule !== '...' && - rule.test instanceof RegExp && - rule.test.test('.svg') - ) { - return undefined; - } - return rule; - }); + if (config.module && config.module.rules) { + /** + * FIXME 良い方法があればそっちに変更したい。 + * SVGに関するルールを削除 + */ + config.module.rules = config.module.rules.map((rule) => { + if ( + rule && + rule !== '...' && + rule.test instanceof RegExp && + rule.test.test('.svg') + ) { + return undefined; + } + return rule; + }); - config.module.rules.push({ - test: /\.svg$/, - issuer: { - and: [/\.(js|ts)x?$/], - }, - use: ['@svgr/webpack'], - }); + config.module.rules.push({ + test: /\.svg$/, + issuer: { + and: [/\.(js|ts)x?$/], + }, + use: ['@svgr/webpack'], + }); + } if (config.resolve?.alias) { config.resolve.alias = { ...config.resolve.alias, diff --git a/.storybook/preview.ts b/.storybook/preview.ts index b00b0f34..4119bd2d 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,5 +1,5 @@ import type { Preview } from '@storybook/react'; -import theme from '@/theme/theme'; +import theme from '../src/theme/theme'; import { withThemeFromJSXProvider } from '@storybook/addon-styling'; import { CssBaseline, ThemeProvider } from '@mui/material'; import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; diff --git a/package-lock.json b/package-lock.json index f26b890a..f5dfb779 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "cuculus", "version": "0.1.0", "dependencies": { - "@cuculus/cuculus-api": "^0.4.0", + "@cuculus/cuculus-api": "^0.4.1", "@ducanh2912/next-pwa": "^9.7.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", @@ -20,6 +20,7 @@ "next": "^13.5.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-easy-crop": "^5.0.2", "swr": "^2.2.1", "virtua": "^0.17.4" }, @@ -4747,9 +4748,9 @@ } }, "node_modules/@cuculus/cuculus-api": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@cuculus/cuculus-api/-/cuculus-api-0.4.0.tgz", - "integrity": "sha512-wgHfW4RfQfINS6/5elfdDcrKGTk/fBe/mqkjzPDJKDepW0wvC2dy0QCjqWFeJ5ePQ+KnJ/NKMhjRvhYkQ8gOqQ==" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@cuculus/cuculus-api/-/cuculus-api-0.4.1.tgz", + "integrity": "sha512-ZL0MFw4J9I+GWtw5Iq/MK6pUTgBVBzGhVa/Duq5jSVP7kcYCJ0Fre82OxQcu6930WFkSD/0EBKOA3bR2nIH8zQ==" }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", @@ -24185,6 +24186,11 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==" + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -25708,6 +25714,24 @@ "react": "^18.2.0" } }, + "node_modules/react-easy-crop": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.0.2.tgz", + "integrity": "sha512-j4A/0s0v/Gx5YGXvw3SOFIMmRk5YCdob2ABL5cD00Q9HQPKIz6tkCYLdj0RMO0REPtCAOsZ2ZZLI6fUofiDP6w==", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-easy-crop/node_modules/tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + }, "node_modules/react-element-to-jsx-string": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz", diff --git a/package.json b/package.json index 79ddd810..c515d43f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ } }, "dependencies": { - "@cuculus/cuculus-api": "^0.4.0", + "@cuculus/cuculus-api": "^0.4.1", "@ducanh2912/next-pwa": "^9.7.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", @@ -32,6 +32,7 @@ "next": "^13.5.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-easy-crop": "^5.0.2", "swr": "^2.2.1", "virtua": "^0.17.4" }, diff --git a/src/app/(menu)/(public)/[username]/_components/ProfilePage.tsx b/src/app/(menu)/(public)/[username]/_components/ProfilePage.tsx index 856c3c4c..241f056f 100644 --- a/src/app/(menu)/(public)/[username]/_components/ProfilePage.tsx +++ b/src/app/(menu)/(public)/[username]/_components/ProfilePage.tsx @@ -12,7 +12,7 @@ type Props = { }; export default function ProfilePage({ fallbackData }: Props) { const { data, isLoading } = useUser(fallbackData.username, fallbackData); - const { data: authId, isLoading: authorizing } = useAuth(); + const { data: authId } = useAuth(); if (!data) { // FIXME 読み込み中 return <>; @@ -20,21 +20,7 @@ export default function ProfilePage({ fallbackData }: Props) { return ( - + {!isLoading && } ); diff --git a/src/app/(menu)/(public)/[username]/_components/elements/FollowButton.stories.ts b/src/app/(menu)/(public)/[username]/_components/elements/FollowButton.stories.ts index 9ac0c847..9b50d55d 100644 --- a/src/app/(menu)/(public)/[username]/_components/elements/FollowButton.stories.ts +++ b/src/app/(menu)/(public)/[username]/_components/elements/FollowButton.stories.ts @@ -13,6 +13,6 @@ type Story = StoryObj; export const NormalFollowButton: Story = { args: { userId: 123, - followStatus: 'NotFollowing', + isFollowing: true, }, }; diff --git a/src/app/(menu)/(public)/[username]/_components/elements/FollowButton.tsx b/src/app/(menu)/(public)/[username]/_components/elements/FollowButton.tsx index 20abd7d0..9f9217dd 100644 --- a/src/app/(menu)/(public)/[username]/_components/elements/FollowButton.tsx +++ b/src/app/(menu)/(public)/[username]/_components/elements/FollowButton.tsx @@ -1,83 +1,21 @@ 'use client'; import CapsuleButton from '@/app/_components/button/CapsuleButton'; -import { ButtonTypeMap } from '@mui/material'; -import { MouseEventHandler } from 'react'; -import { OverridableStringUnion } from '@mui/types'; -import { ButtonPropsVariantOverrides } from '@mui/material/Button'; -// static class propertyにして文字列で持たせる?(interfaceどうするか)(enum使う?) -export type FollowStatus = - | 'NotFollowing' - | 'Following' - | 'Pending' - | 'Blocked' - | 'EditProfile'; - -interface Props { +type Props = { userId: number; - followStatus: FollowStatus; -} - -export function FollowButton({ followStatus }: Props) { - // TODO ボタン処理実装 - const follow: MouseEventHandler = () => { - // doPost(followActionUrl) - }; - - // TODO ボタン処理実装 - const unfollow: MouseEventHandler = () => { - // doDelete(followActionUrl); - }; - - // TODO ボタン処理実装 - const cancelRequest: MouseEventHandler = () => { - // doCancel(???); - }; - - const editProfile: MouseEventHandler = () => { - // editProfile(???); - }; + isFollowing: boolean; +}; - const [color, enabled, text, onClick, variant] = ((): [ - ButtonTypeMap['props']['color'], - boolean, - string, - MouseEventHandler | undefined, - OverridableStringUnion< - 'text' | 'outlined' | 'contained', - ButtonPropsVariantOverrides - >, - ] => { - switch (followStatus) { - case 'NotFollowing': - return ['primary', true, 'フォロー', unfollow, 'contained']; - case 'Following': - return ['primary', true, 'フォロー中', follow, 'outlined']; - case 'Pending': - return ['secondary', true, '承認待ち', cancelRequest, 'outlined']; - case 'Blocked': - return [ - 'warning', - false, - 'ブロックされています', - undefined, - 'outlined', - ]; - case 'EditProfile': - return ['primary', true, 'プロフィールを編集', editProfile, 'outlined']; - default: - return ['error', false, '(invalid value)', undefined, 'outlined']; - } - })(); +export function FollowButton({ isFollowing }: Props) { + const text = isFollowing ? 'フォロー中' : 'フォロー'; return ( {text} diff --git a/src/app/(menu)/(public)/[username]/_components/elements/HeaderImage.tsx b/src/app/(menu)/(public)/[username]/_components/elements/HeaderImage.tsx new file mode 100644 index 00000000..839eab75 --- /dev/null +++ b/src/app/(menu)/(public)/[username]/_components/elements/HeaderImage.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { styled } from '@mui/material'; + +const HeaderImage = styled('div')<{ + image?: string; +}>` + display: block; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + aspect-ratio: 3 / 1; + background-color: ${({ theme }) => theme.palette.primary.light}; + background-image: ${({ image }) => (image ? `url(${image})` : 'none')}; +`; + +export default HeaderImage; diff --git a/src/app/(menu)/(public)/[username]/_components/elements/UserIcon.tsx b/src/app/(menu)/(public)/[username]/_components/elements/UserIcon.tsx new file mode 100644 index 00000000..d861babb --- /dev/null +++ b/src/app/(menu)/(public)/[username]/_components/elements/UserIcon.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { Avatar, styled } from '@mui/material'; + +const UserIcon = styled(Avatar)` + width: 120px; + height: 120px; + + margin-top: -80px; + border-color: ${({ theme }) => theme.palette.background.paper}; + border-style: solid; + + ${({ theme }) => theme.breakpoints.down('tablet')} { + width: 92px; + height: 92px; + margin-top: -61px; + } +`; + +export default UserIcon; diff --git a/src/app/(menu)/(public)/[username]/_components/layouts/EditProfileButton.tsx b/src/app/(menu)/(public)/[username]/_components/layouts/EditProfileButton.tsx new file mode 100644 index 00000000..73a77844 --- /dev/null +++ b/src/app/(menu)/(public)/[username]/_components/layouts/EditProfileButton.tsx @@ -0,0 +1,38 @@ +'use client'; + +import CapsuleButton from '@/app/_components/button/CapsuleButton'; +import ProfileSettingModal from '@/app/(menu)/(public)/[username]/_components/layouts/ProfileSettingModal'; +import { useState } from 'react'; +import { useProfile } from '@/swr/client/auth'; + +export function EditProfileButton() { + const text = 'プロフィールを編集'; + const [open, setOpen] = useState(false); + const { data } = useProfile(); + + if (!data) { + return <>; + } + + return ( + <> + { + setOpen(true); + }} + variant="outlined" + > + {text} + + setOpen(false)} + /> + + ); +} diff --git a/src/app/(menu)/(public)/[username]/_components/layouts/ProfileCard.stories.ts b/src/app/(menu)/(public)/[username]/_components/layouts/ProfileCard.stories.ts index dfad2eae..68cf3f28 100644 --- a/src/app/(menu)/(public)/[username]/_components/layouts/ProfileCard.stories.ts +++ b/src/app/(menu)/(public)/[username]/_components/layouts/ProfileCard.stories.ts @@ -22,13 +22,12 @@ export const NormalProfileCard: Story = { username: 'takecchi', createdAt: new Date('2023-10-04T18:57:44.373Z'), profileImageUrl: '/mock/profileAvatarImage.png', - protected: false, + _protected: false, verified: false, bio: 'こんにちは。', url: '', followersCount: 2, followingCount: 1, authId: undefined, - authorizing: false, }, }; diff --git a/src/app/(menu)/(public)/[username]/_components/layouts/ProfileCard.tsx b/src/app/(menu)/(public)/[username]/_components/layouts/ProfileCard.tsx index c79a5632..203c8d8a 100644 --- a/src/app/(menu)/(public)/[username]/_components/layouts/ProfileCard.tsx +++ b/src/app/(menu)/(public)/[username]/_components/layouts/ProfileCard.tsx @@ -1,12 +1,13 @@ 'use client'; -import { Avatar, Box, Typography, styled } from '@mui/material'; -import { - FollowButton, - FollowStatus, -} from '@/app/(menu)/(public)/[username]/_components/elements/FollowButton'; +import { Box, Typography, styled } from '@mui/material'; +import { FollowButton } from '@/app/(menu)/(public)/[username]/_components/elements/FollowButton'; import UserCount from '@/app/(menu)/(public)/[username]/_components/elements/UserCount'; import { usePathname } from 'next/navigation'; +import HeaderImage from '@/app/(menu)/(public)/[username]/_components/elements/HeaderImage'; +import UserIcon from '@/app/(menu)/(public)/[username]/_components/elements/UserIcon'; +import { UserWithFollows } from '@cuculus/cuculus-api'; +import { EditProfileButton } from '@/app/(menu)/(public)/[username]/_components/layouts/EditProfileButton'; const UnselectableCard = styled('div')` border-bottom: 1px solid ${({ theme }) => theme.palette.grey[100]}; @@ -14,18 +15,6 @@ const UnselectableCard = styled('div')` color: rgba(0, 0, 0, 0.87); `; -const HeaderImage = styled('div')<{ - image?: string; -}>` - display: block; - background-size: cover; - background-repeat: no-repeat; - background-position: center; - aspect-ratio: 3 / 1; - background-color: ${({ theme }) => theme.palette.primary.light}; - background-image: ${({ image }) => (image ? `url(${image})` : 'none')}; -`; - const Flex = styled(Box)` display: flex; flex-wrap: nowrap; @@ -43,21 +32,6 @@ const FillFlex = styled(Box)` flex-grow: 1; `; -const UserIcon = styled(Avatar)` - width: 120px; - height: 120px; - - margin-top: -80px; - border-color: ${({ theme }) => theme.palette.background.paper}; - border-style: solid; - - ${({ theme }) => theme.breakpoints.down('tablet')} { - width: 80px; - height: 80px; - margin-top: -48px; - } -`; - const DisplayName = styled(Typography)` word-wrap: break-word; font-weight: bold; @@ -74,21 +48,9 @@ const Bio = styled(Typography)` margin-bottom: 12px; `; -interface ProfileCardProps { - id: number; - name: string; - username: string; - createdAt: Date; - bio: string; - profileImageUrl: string; - protected: boolean; - url: string; - verified: boolean; - followersCount?: number; - followingCount?: number; +type ProfileCardProps = { authId: number | undefined; - authorizing: boolean; -} +} & UserWithFollows; export default function ProfileCard({ id, @@ -99,16 +61,10 @@ export default function ProfileCard({ followersCount, followingCount, authId, - authorizing, }: ProfileCardProps) { const path = usePathname(); - const getFollowStatus = (): FollowStatus => { - if (id === authId) { - return 'EditProfile'; - } - return 'NotFollowing'; - }; + const isMe = id === authId; return ( <> @@ -153,12 +109,10 @@ export default function ProfileCard({ {/* */} {/*)}*/} {/* フォローボタン */} - {!authorizing && ( - + {authId && !isMe && ( + )} + {authId && isMe && } diff --git a/src/app/(menu)/(public)/[username]/_components/layouts/ProfileSettingModal.tsx b/src/app/(menu)/(public)/[username]/_components/layouts/ProfileSettingModal.tsx new file mode 100644 index 00000000..5324f3cd --- /dev/null +++ b/src/app/(menu)/(public)/[username]/_components/layouts/ProfileSettingModal.tsx @@ -0,0 +1,378 @@ +'use client'; + +import { + Alert, + Box, + Dialog as MuiDialog, + Slider, + Snackbar, + styled, + TextField, +} from '@mui/material'; +import { ChangeEvent, useCallback, useState } from 'react'; +import { + AddAPhoto, + Close, + ArrowBack, + ZoomIn, + ZoomOut, +} from '@mui/icons-material'; +import { IconButton } from '@/app/_components/button/IconButton'; +import HeaderImage from '@/app/(menu)/(public)/[username]/_components/elements/HeaderImage'; +import UserIcon from '@/app/(menu)/(public)/[username]/_components/elements/UserIcon'; +import Cropper, { Area, Point } from 'react-easy-crop'; +import CapsuleButton from '@/app/_components/button/CapsuleButton'; +import { getCroppedImg } from '@/app/(menu)/(public)/[username]/_utils/cropImage'; +import { useProfileMutation } from '@/swr/client/profile'; +import CapsuleLoadingButton from '@/app/_components/button/CapsuleLoadingButton'; + +const HEADER_HEIGHT = '50px'; +const SLIDER_HEIGHT = '50px'; + +const Dialog = styled(MuiDialog)` + top: env(safe-area-inset-top, 0); + + .MuiDialog-paper { + margin: 0; + max-width: 100vw; + max-height: calc( + 100vh - env(safe-area-inset-bottom, 0) - env(safe-area-inset-top, 0) + ); + + ${({ theme }) => theme.breakpoints.down('tablet')} { + border-radius: 0; + } + } +`; + +const Container = styled('div')` + display: flex; + flex-direction: column; + text-align: center; + + ${({ theme }) => theme.breakpoints.down('tablet')} { + width: 100vw; + height: 100vh; + } +`; + +const Header = styled('div')` + display: flex; + align-items: center; + border-style: solid; + border-color: ${({ theme }) => theme.palette.grey[100]}; + border-width: 0; + border-bottom-width: 1px; + color: ${({ theme }) => theme.palette.grey[800]}; + height: ${HEADER_HEIGHT}; + padding: 0 8px; + gap: 12px; +`; + +const Content = styled('div')` + max-width: 598px; + width: 100vw; +`; + +const SliderContainer = styled('div')` + display: flex; + flex-direction: row; + align-items: center; + height: ${SLIDER_HEIGHT}; + padding: 0 30px; + gap: 10px; +`; + +const CropContainer = styled('div')` + position: relative; + height: calc(100vh - ${HEADER_HEIGHT} - ${SLIDER_HEIGHT}); + + ${({ theme }) => theme.breakpoints.up('tablet')} { + max-height: 600px; + } + + .crop-area { + border-radius: 9999px; + border: 3px solid #00a0ff; + } +`; + +const Flex = styled(Box)` + display: flex; + flex-wrap: nowrap; +`; + +const HFlex = styled(Flex)` + flex-direction: row; +`; + +const VFlex = styled(Flex)` + flex-direction: column; +`; + +/** + * アイコンを編集するモーダル + * @param src + * @param onClose + * @param onComplete + * @constructor + */ +function ProfileImageCrop({ + src, + onClose, + onComplete, +}: { + src: string | undefined; + onClose: () => void; + onComplete: (blob: Blob) => void; +}) { + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(); + const [isProcessing, setIsProcessing] = useState(false); + + // 適用処理 + const handleApply = useCallback(async () => { + if (!croppedAreaPixels || !src) return; + setIsProcessing(true); + try { + const croppedImage = await getCroppedImg(src, croppedAreaPixels, 400); + onComplete(croppedImage); + } catch (e) { + console.error(e); + } finally { + setIsProcessing(false); + } + }, [croppedAreaPixels, onComplete, src]); + + return ( + + +
+ + + + メディアを編集 +
+ { + void handleApply(); + }} + > + 適用 + +
+ + {/* TODO ここでCrop出来るようにする */} + + { + setCroppedAreaPixels(area); + }} + showGrid={false} + /> + + + + { + setZoom(newValue as number); + }} + min={1} + max={3} + step={0.1} + /> + + + +
+
+ ); +} + +/** + * プロフィールを編集するモーダル + * @param init + * @constructor + */ +export default function ProfileSettingModal({ + open, + onClose, + src: initSrc, + displayName: initDisplayName, + bio: initBio, +}: { + open: boolean; + onClose: () => void; + src?: string; + displayName: string; + bio: string; +}) { + const [src, setSrc] = useState(initSrc); + const [blob, setBlob] = useState(undefined); + const [displayName, setDisplayName] = useState(initDisplayName); + const [bio, setBio] = useState(initBio); + const [iconSrc, setIconSrc] = useState(undefined); + const { trigger, isMutating } = useProfileMutation(); + + const [errorMessage, setErrorMesssage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + const handleClose = () => { + onClose(); + }; + + const handleFileChange = useCallback((e: ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + const reader = new FileReader(); + reader.addEventListener('load', () => + setIconSrc(reader.result?.toString() || undefined), + ); + reader.readAsDataURL(e.target.files[0]); + } + }, []); + + return ( + <> + { + setIconSrc(undefined); + }} + onComplete={(blob) => { + const croppedImageUrl = URL.createObjectURL(blob); + setSrc(croppedImageUrl); + setBlob(blob); + setIconSrc(undefined); + }} + /> + + +
+ + + + プロフィールを編集 +
+ { + const request = { + bio: initBio !== bio ? bio : undefined, + name: + initDisplayName !== displayName ? displayName : undefined, + profileImage: blob, + }; + const isAllUndefined = Object.values(request).every( + (value) => value === undefined, + ); + if (isAllUndefined) { + handleClose(); + } else { + void trigger(request) + .then(() => { + setSuccessMessage('プロフィールを更新しました。'); + handleClose(); + }) + .catch(() => { + setErrorMesssage('プロフィールの更新に失敗しました。'); + }); + } + }} + loading={isMutating} + > + 保存 + +
+ + +
+ + + + + + + + { + setDisplayName(event.target.value); + }} + /> + { + setBio(event.target.value); + }} + /> + +
+
+
+
+ + setErrorMesssage('')} + autoHideDuration={2_000} + > + {errorMessage} + + setSuccessMessage('')} + autoHideDuration={2_000} + > + {successMessage} + + + ); +} diff --git a/src/app/(menu)/(public)/[username]/_utils/cropImage.ts b/src/app/(menu)/(public)/[username]/_utils/cropImage.ts new file mode 100644 index 00000000..0fe2c9c7 --- /dev/null +++ b/src/app/(menu)/(public)/[username]/_utils/cropImage.ts @@ -0,0 +1,65 @@ +import { Area } from 'react-easy-crop'; + +export async function getCroppedImg( + imageSrc: string, + area: Area, + maxSize: number, +): Promise { + const image = new Image(); + image.src = imageSrc; + await new Promise((resolve) => { + image.onload = resolve; + }); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + // トリミングされた画像のサイズを取得 + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const cropWidth = area.width * scaleX; + const cropHeight = area.height * scaleY; + + // キャンバスのサイズを設定 + const aspectRatio = cropWidth / cropHeight; + if (cropWidth > maxSize) { + canvas.width = maxSize; + canvas.height = maxSize / aspectRatio; + } else if (cropHeight > maxSize) { + canvas.height = maxSize; + canvas.width = maxSize * aspectRatio; + } else { + canvas.width = cropWidth; + canvas.height = cropHeight; + } + + // トリミングされた画像をキャンバスに描画 + if (ctx) { + ctx.drawImage( + image, + area.x * scaleX, + area.y * scaleY, + cropWidth, + cropHeight, + 0, + 0, + canvas.width, + canvas.height, + ); + } + + // キャンバスの内容をBlobとして取得 (PNG形式) + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject( + new Error( + 'Failed to create blob from canvas. Canvas might be empty.', + ), + ); + return; + } + resolve(blob); + }, 'image/png'); + }); +} diff --git a/src/app/_components/button/CapsuleLoadingButton.tsx b/src/app/_components/button/CapsuleLoadingButton.tsx new file mode 100644 index 00000000..c373ab68 --- /dev/null +++ b/src/app/_components/button/CapsuleLoadingButton.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { styled } from '@mui/material'; +import { LoadingButton as MuiLoadingButton } from '@mui/lab'; + +const CapsuleButton = styled(MuiLoadingButton)` + border-radius: 9999px; + box-shadow: none; + + &:hover, + &:focus { + box-shadow: none; + } +`; + +export default CapsuleButton; diff --git a/src/libs/cuculus-client.ts b/src/libs/cuculus-client.ts index 284aef30..b04f447b 100644 --- a/src/libs/cuculus-client.ts +++ b/src/libs/cuculus-client.ts @@ -1,4 +1,5 @@ import { + AccountsApi, AuthApi, Configuration, DefaultApi, @@ -18,6 +19,7 @@ const defaultApi = new DefaultApi(config); const invitationsApi = new InvitationsApi(config); const timelinesApi = new TimelinesApi(config); const postsApi = new PostsApi(config); +const accountsApi = new AccountsApi(config); export { authApi, @@ -26,4 +28,5 @@ export { invitationsApi, timelinesApi, postsApi, + accountsApi, }; diff --git a/src/swr/client/profile.ts b/src/swr/client/profile.ts new file mode 100644 index 00000000..18054656 --- /dev/null +++ b/src/swr/client/profile.ts @@ -0,0 +1,59 @@ +import { getAuthorizationHeader } from '@/libs/auth'; +import { accountsApi } from '@/libs/cuculus-client'; +import { useAuth } from '@/swr/client/auth'; +import useSWRMutation from 'swr/mutation'; +import { UserWithFollows } from '@cuculus/cuculus-api/dist/models'; + +type SWRKey = { + key: string; + authId: number; +}; + +type Arg = { + name?: string; + bio?: string; + profileImage?: Blob; +}; + +const update = async ( + key: SWRKey, + { arg }: { arg: Arg }, +): Promise => { + const headers = await getAuthorizationHeader(key.authId); + + let user: UserWithFollows | undefined = undefined; + + if (arg.bio != undefined || arg.name) { + user = await accountsApi.updateProfile( + { + updateProfile: { name: arg.name, bio: arg.bio }, + }, + { + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + }, + ); + } + if (arg.profileImage) { + user = await accountsApi.updateProfileImage( + { file: arg.profileImage }, + { headers }, + ); + } + if (user) { + return user; + } else { + throw new Error('更新に失敗しました。'); + } +}; + +export const useProfileMutation = () => { + const { data: authId } = useAuth(); + const key = authId ? { key: 'useProfile', authId } : null; + return useSWRMutation( + key, + update, + ); +};