Skip to content

Commit

Permalink
Merge pull request #53 from tosaken1116/feat/PostTweet-model
Browse files Browse the repository at this point in the history
Feat/post tweet model
  • Loading branch information
tosaken1116 authored Oct 29, 2023
2 parents ea7f5d4 + f5b4d66 commit 498994b
Show file tree
Hide file tree
Showing 16 changed files with 325 additions and 10 deletions.
6 changes: 5 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
NEXT_PUBLIC_BACKEND_URL=
NEXT_PUBLIC_BACKEND_URL=
ACCESS_KEY_ID=
SECRET_ACCESS_KEY=
REGION=
S3_BUCKET_NAME=
Binary file modified bun.lockb
Binary file not shown.
9 changes: 9 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ const nextConfig = {
images: {
domains: ['avatars.githubusercontent.com'],
},
env: {
ACCESS_KEY_ID: process.env.ACCESS_KEY_ID,
SECRET_ACCESS_KEY: process.env.SECRET_ACCESS_KEY,
REGION: process.env.REGION,
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
},
experimental: {
serverActions: true,
},
};

module.exports = nextConfig;
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
},
"dependencies": {
"@aspida/fetch": "^1.14.0",
"@aws-sdk/client-s3": "^3.438.0",
"@aws-sdk/lib-storage": "^3.438.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
Expand Down
2 changes: 1 addition & 1 deletion schema
15 changes: 7 additions & 8 deletions src/api/@types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ export type RegisterPasswordPayload = {
}

export type UpdateAccountPayload = {
name: string
description: string
image_url: string
birth_day: string
website_url: string
name?: string | undefined
description?: string | undefined
image_url?: string | undefined
birth_day?: string | undefined
website_url?: string | undefined
}

export type Account = {
Expand All @@ -42,9 +42,8 @@ export type Account = {
export type CreateTweetPayload = {
content: string
image_url_list: string[]
account_id: string
reply_to: string
root: string
reply_to?: string | null | undefined
root?: string | null | undefined
}

export type CreateTweetResponse = {
Expand Down
71 changes: 71 additions & 0 deletions src/components/model/PostTweet/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { ChangeEvent } from 'react';
import { useState, useTransition } from 'react';

import { useMutation } from '@tanstack/react-query';

import type {
Account,
CreateTweetPayload,
CreateTweetResponse,
} from '@/api/@types';

import { uploadImage } from '@/hooks/useImageUpload';
import { apiClient } from '@/libs/apiClients';

type IUsePostTweet = {
handlePost: () => void;
handleChange: (e: ChangeEvent<HTMLTextAreaElement>) => void;
handleSelect: (e: ChangeEvent<HTMLInputElement>) => void;
pending: boolean;
account: Account;
tweetAble: boolean;
};

const fetcher = async (
body: CreateTweetPayload
): Promise<CreateTweetResponse> => await apiClient.tweets.post.$post({ body });

export const usePostTweet = (): IUsePostTweet => {
const [content, setContent] = useState('');
const [pending, startTransition] = useTransition();
const [file, setFile] = useState<File>();
const account: Account = {
id: 'test_user',
image_url: 'https://avatars.githubusercontent.com/u/94045195?v=4',
name: 'user',
role: 1,
description: '',
birth_day: '2021-09-01',
website_url: 'localhost:3000',
follow_count: 0,
follower_count: 0,
is_following: false,
};
const { mutate: postTweet } = useMutation({ mutationFn: fetcher });
const handlePost = (): void => {
postTweet({ content, image_url_list: [] });
};

const handleChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
setContent(e.target.value);
};

const handleSelect = (e: ChangeEvent<HTMLInputElement>): void => {
setFile(e.target?.files?.[0]);
if (!file) return;

const form = new FormData();

form.append('fileUpload', file);
startTransition(() => uploadImage(form));
};

return {
handlePost,
handleChange,
handleSelect,
pending,
account,
tweetAble: content.length > 0,
};
};
17 changes: 17 additions & 0 deletions src/components/model/PostTweet/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { PostTweetPresentation } from './presentations';

import type { Meta, StoryObj } from '@storybook/react';

const meta: Meta<typeof PostTweetPresentation> = {
component: PostTweetPresentation,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};

export default meta;

type Story = StoryObj<typeof PostTweetPresentation>;

export const Default: Story = {};
9 changes: 9 additions & 0 deletions src/components/model/PostTweet/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PostTweetPresentation } from './presentations';

import { ClientProvider } from '@/components/functional/ClientProvider';

export const PostTweet: React.FC = () => (
<ClientProvider>
<PostTweetPresentation />
</ClientProvider>
);
55 changes: 55 additions & 0 deletions src/components/model/PostTweet/presentations/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client';

import { BsEmojiSmile, BsGeoAlt } from 'react-icons/bs';
import { MdOutlineGifBox } from 'react-icons/md';
import { RiListRadio } from 'react-icons/ri';
import { TbCalendarTime } from 'react-icons/tb';

import { usePostTweet } from '../hooks';

import { CustomFileInput } from './item/ImageInput';

import { Avatar, AvatarImage } from '@/components/ui/Avatar';
import { Button } from '@/components/ui/Button';
import { Separator } from '@/components/ui/Separator';
import { Textarea } from '@/components/ui/Textarea';

export const PostTweetPresentation: React.FC = () => {
const {
handleSelect,
handleChange,
account: { image_url },
tweetAble,
} = usePostTweet();

return (
<div className="flex flex-row gap-4 p-4">
<Avatar className="h-12 w-12">
<AvatarImage src={image_url} alt={`${image_url}のアイコン`} />
</Avatar>
<div className="w-full">
<Textarea
onChange={handleChange}
className=" resize-none border-none bg-transparent text-2xl text-white focus-visible:ring-0"
placeholder="いまどうしてる?"
/>
<Separator orientation="horizontal" className="bg-white-hover" />
<div className="flex flex-row items-center pt-4">
<div className="flex flex-1 flex-row gap-4">
<form>
<CustomFileInput onChange={handleSelect} />
</form>
<MdOutlineGifBox size={24} className="text-primary" />
<RiListRadio size={24} className="text-primary" />
<BsEmojiSmile size={24} className="text-primary" />
<TbCalendarTime size={24} className="text-primary" />
<BsGeoAlt size={24} className="text-primary/40" />
</div>
<Button disabled={!tweetAble} className="rounded-full font-bold">
ツイートする
</Button>
</div>
</div>
</div>
);
};
19 changes: 19 additions & 0 deletions src/components/model/PostTweet/presentations/item/ImageInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ChangeEvent, FC } from 'react';

import { GoImage } from 'react-icons/go';

export const CustomFileInput: FC<{
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}> = ({ onChange }) => (
<div>
<label htmlFor="fileInput" className="cursor-pointer text-primary">
<GoImage size={24} />
</label>
<input
id="fileInput"
type="file"
style={{ display: 'none' }}
onChange={onChange}
/>
</div>
);
22 changes: 22 additions & 0 deletions src/components/ui/Input/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Input } from '.';

import type { Meta, StoryObj } from '@storybook/react';

const meta: Meta<typeof Input> = {
component: Input,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};

export default meta;

type Story = StoryObj<typeof Input>;

export const Default: Story = {
args: {
type: 'email',
placeholder: 'Email',
},
};
27 changes: 27 additions & 0 deletions src/components/ui/Input/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from 'react';

import { cn } from '@/libs/utils';

export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}

const Input = React.forwardRef<
HTMLInputElement,
InputProps & { startIcon: React.ReactNode }
>(({ className, type, startIcon, ...props }, ref) => (
<div className="placeholder:text-muted-foreground ring-offset-background border-input flex h-12 w-full flex-row rounded-full bg-white-hover">
<span className="flex items-center pl-3 text-center">{startIcon}</span>
<input
type={type}
ref={ref}
{...props}
className={cn(
'flex w-full bg-transparent px-5 py-4 text-lg file:border-0 file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
/>
</div>
));
Input.displayName = 'Input';

export { Input };
21 changes: 21 additions & 0 deletions src/components/ui/Textarea/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Textarea } from '.';

import type { Meta, StoryObj } from '@storybook/react';

const meta: Meta<typeof Textarea> = {
component: Textarea,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};

export default meta;

type Story = StoryObj<typeof Textarea>;

export const Default: Story = {
args: {
placeholder: 'Type your message here.',
},
};
22 changes: 22 additions & 0 deletions src/components/ui/Textarea/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as React from 'react';

import { cn } from '@/libs/utils';

export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => (
<textarea
className={cn(
'border-input bg-background placeholder:text-muted-foreground flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
);
Textarea.displayName = 'Textarea';

export { Textarea };
38 changes: 38 additions & 0 deletions src/hooks/useImageUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use server';

import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';

export const uploadImage = async (data: FormData | null): Promise<void> => {
if (data === null) {
return;
}
const file = data.get('fileUpload');
if (file === null) {
return;
}
const s3client = new S3Client({
credentials: {
accessKeyId: process.env['ACCESS_KEY_ID'] || '',
secretAccessKey: process.env['SECRET_ACCESS_KEY'] || '',
},
});
try {
const upload = new Upload({
client: s3client,
params: {
Bucket: `${process.env['S3_BUCKET_NAME']}`,
Key: 'test.png',
Body: file,
ContentType: 'image/jpg',
},
});

console.log('Uploading to S3...');
await upload.done();

console.log('Upload successful!');
} catch (e) {
console.error(e);
}
};

0 comments on commit 498994b

Please sign in to comment.