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 (
+
+ );
+}
+
+/**
+ * プロフィールを編集するモーダル
+ * @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);
+ }}
+ />
+
+
+ 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,
+ );
+};