Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Develop #148

Merged
merged 39 commits into from
Feb 22, 2025
Merged
Changes from 20 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
9175697
プロフィールにいろいろ
natanata25 Feb 21, 2025
4bc4650
Merge branch 'develop' of https://github.com/kc3hack/2025_10 into Pro…
natanata25 Feb 21, 2025
084654a
Lottie動かない問題
RAIT-09 Feb 21, 2025
6903520
サイドメニューから設定削除
RAIT-09 Feb 21, 2025
a1b1c89
システムプロンプト修正 拗音(ゃゅょ)は1音として扱わない
yone-al Feb 21, 2025
5943272
ニュースを取得する関数
yone-al Feb 22, 2025
49fd775
Tankalizerロゴクリックでタイムライン遷移
RAIT-09 Feb 22, 2025
74e9a6f
詠ボタンをタイムライン以外にも表示
RAIT-09 Feb 22, 2025
67e2b51
getOnePost APIの追加
TARO-gh Feb 22, 2025
a24e975
Merge remote-tracking branch 'origin/taro/develop' into RAIT-09/featu…
RAIT-09 Feb 22, 2025
4777fb9
Merge remote-tracking branch 'origin/taro/develop' into RAIT-09/featu…
RAIT-09 Feb 22, 2025
be920fd
getOnePost APIの修正
TARO-gh Feb 22, 2025
9ea90e1
Merge remote-tracking branch 'origin/taro/develop' into RAIT-09/featu…
RAIT-09 Feb 22, 2025
9313232
NewsのAPI(仮)をdocsに追加
yone-al Feb 22, 2025
0cbc101
共有機能フロント
RAIT-09 Feb 22, 2025
a5bf07a
env example追加
ABfry Feb 22, 2025
c9e53f8
大きさ変更に対応
natanata25 Feb 22, 2025
97d3289
投稿取得中表示とハンバーガー戻し
RAIT-09 Feb 22, 2025
b46bbc2
Merge pull request #141 from kc3hack/RAIT-09/feature/postpage
RAIT-09 Feb 22, 2025
2b55c49
Merge branch 'develop' of https://github.com/kc3hack/2025_10 into Pro…
natanata25 Feb 22, 2025
a63ee3d
news投稿
ABfry Feb 22, 2025
957320a
Merge pull request #142 from kc3hack/Profilr_UI
natanata25 Feb 22, 2025
b5b862f
私たちのアカウントか判定
TARO-gh Feb 22, 2025
c84dec2
Merge pull request #143 from kc3hack/taro/develop
TARO-gh Feb 22, 2025
773ee73
動的ルーティングNotFound一旦対応
RAIT-09 Feb 22, 2025
4ca2589
投稿単独ページで投稿を削除した場合にタイムラインに遷移
RAIT-09 Feb 22, 2025
707a9cd
Merge branch 'develop' into RAIT-09/develop
RAIT-09 Feb 22, 2025
51f21b5
スキーマ例修正
yone-al Feb 22, 2025
17a8c36
Revert "スキーマ例修正"
yone-al Feb 22, 2025
276ab71
スキーマ例修正
yone-al Feb 22, 2025
b9afe3b
NEWS_USER_ICON環境変数
yone-al Feb 22, 2025
fd9a508
NEWS_USER_ICONー>NEWS_USER_ID
yone-al Feb 22, 2025
5fb7e7e
動的ルーティングページのNotFound改善
RAIT-09 Feb 22, 2025
8480708
Merge branch 'develop' into yone/develop
yone-al Feb 22, 2025
3b2af87
動的ルーティングページのNotFound改善
RAIT-09 Feb 22, 2025
a254154
帽子追加
natanata25 Feb 22, 2025
fe69cb0
Merge pull request #147 from kc3hack/Profilr_UI
ABfry Feb 22, 2025
4c94349
Merge pull request #146 from kc3hack/RAIT-09/develop
ABfry Feb 22, 2025
cfe3f21
Merge pull request #140 from kc3hack/yone/develop
ABfry Feb 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -16,3 +16,4 @@ S3_BUCKET_NAME=

CURRENTS_API_KEY=
NEWS_POST_API_KEY=
OUR_ID=
1 change: 1 addition & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ const envSchema = z.object({
S3_ACCESS_KEY_ID: z.string(),
S3_SECRET_ACCESS_KEY: z.string(),
S3_BUCKET_NAME: z.string(),
OUR_ID: z.string(),
});

export const env = envSchema.parse(process.env);
2 changes: 1 addition & 1 deletion backend/src/controllers/Miyabi/createMiyabiHandler.ts
Original file line number Diff line number Diff line change
@@ -40,7 +40,7 @@ const createMiyabiHandler: RouteHandler<typeof createMiyabiRoute, {}> = async (c
200
);
} catch (err) {
console.log('雅に失敗しました.');
console.log('雅に失敗しました.' + err);
return c.json(
{
message: '雅に失敗しました.',
2 changes: 1 addition & 1 deletion backend/src/controllers/Miyabi/deleteMiyabiHandler.ts
Original file line number Diff line number Diff line change
@@ -40,7 +40,7 @@ const deleteMiyabiHandler: RouteHandler<typeof deleteMiyabiRoute, {}> = async (c
200
);
} catch (err) {
console.log('雅の削除に失敗しました.');
console.log('雅の削除に失敗しました.' + err);
return c.json(
{
message: '雅の削除に失敗しました.',
2 changes: 1 addition & 1 deletion backend/src/controllers/Miyabi/getMiyabiRankingHandler.ts
Original file line number Diff line number Diff line change
@@ -61,7 +61,7 @@ const getMiyabiRankingHandler: RouteHandler<typeof getMiyabiRankingRoute, {}> =
200
);
} catch (err) {
console.log('雅ランキングの取得に失敗しました.');
console.log('雅ランキングの取得に失敗しました.' + err);
return c.json(
{
message: '雅ランキングの取得に失敗しました.',
2 changes: 1 addition & 1 deletion backend/src/controllers/Post/deletePostHandler.ts
Original file line number Diff line number Diff line change
@@ -56,7 +56,7 @@ const deletePostHandler: RouteHandler<typeof deletePostRoute, {}> = async (c: Co
200
);
} catch (err) {
console.log('投稿の削除に失敗しました.');
console.log('投稿の削除に失敗しました.' + err);
return c.json(
{
message: '投稿の削除に失敗しました.',
94 changes: 94 additions & 0 deletions backend/src/controllers/Post/getOnePostHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { z, type RouteHandler } from '@hono/zod-openapi';
import type { Context } from 'hono';
import db from '../db.js';
import { getOnePostSchema } from '../../schema/Post/getOnePostSchema.js';
import type { getOnePostRoute } from '../../routes/Post/getOnePostRoute.js';
import { env } from '../../config/env.js';

type getOnePostSchema = z.infer<typeof getOnePostSchema>;

const getOnePostHandler: RouteHandler<typeof getOnePostRoute, {}> = async (c: Context) => {
try {
// 受け取ったjsonを各変数に格納 (post_idが指定なしなら,最新の投稿idになる)
let { my_icon = null, post_id } = await c.req.json<getOnePostSchema>();

// 入力のpost_idがnullなら最新の投稿から取得,そうでなければその投稿よりも古いものを取得
// sql文中の比較条件切り替え

// 投稿が存在するかチェック
const checkSql = `SELECT * FROM ${env.POSTS_TABLE_NAME} WHERE id = :post_id;`;
const existingPosts = await db.query(checkSql, { post_id });
if (existingPosts.length == 0) {
console.log('投稿が見つかりません.');
return c.json(
{
message: '投稿が見つかりません.',
statusCode: 404,
error: 'Not Found',
},
404
);
}

let results;
// ここからDBのpostテーブルから情報取得
const sql = `
SELECT
post.id,
post.original,
post.tanka,
post.image_path,
post.created_at,
post.user_name,
post.user_icon,
(SELECT COUNT(*) FROM ${env.MIYABI_TABLE_NAME} WHERE post_id = post.id) as miyabi_count,
CASE WHEN miyabi.id IS NULL THEN false ELSE true END as is_miyabi
FROM ${env.POSTS_TABLE_NAME} post
LEFT JOIN ${env.MIYABI_TABLE_NAME} miyabi
ON post.id = miyabi.post_id AND miyabi.user_icon = :my_icon
WHERE post.id = :post_id;
`;

results = await db.query(sql, { my_icon, post_id });

// is_miyabiをtrue or falseで返すための処理
results = results.map((row: any) => ({
...row,
user_id: row.user_icon.match(/\/u\/(\d+)/)[1],
is_miyabi: row.is_miyabi ? true : false,
}));

//console.log(results);

// レスポンス
console.log('投稿を取得しました.');
return c.json(
{
message: '投稿を取得しました.',
id: results[0].id,
original: results[0].original,
tanka: results[0].tanka,
image_path: results[0].image_path,
created_at: results[0].created_at,
user_id: results[0].user_id,
user_name: results[0].user_name,
user_icon: results[0].user_icon,
miyabi_count: results[0].miyabi_count,
is_miyabi: results[0].is_miyabi,
},
200
);
} catch (err) {
console.log('投稿の取得に失敗しました.' + err);
return c.json(
{
message: '投稿の取得に失敗しました.',
statusCode: 500,
error: 'Internal Server Error',
},
500
);
}
};

export default getOnePostHandler;
2 changes: 1 addition & 1 deletion backend/src/controllers/Profile/getProfileHandler.ts
Original file line number Diff line number Diff line change
@@ -54,7 +54,7 @@ const getProfileHandler: RouteHandler<typeof getProfileRoute, {}> = async (c: Co
200
);
} catch (err) {
console.log('プロフィールの取得に失敗しました.');
console.log('プロフィールの取得に失敗しました.' + err);
return c.json(
{
message: 'プロフィールの取得に失敗しました.',
2 changes: 1 addition & 1 deletion backend/src/controllers/User/createUserHandler.ts
Original file line number Diff line number Diff line change
@@ -54,7 +54,7 @@ const createUserHandler: RouteHandler<typeof createUserRoute, {}> = async (c: Co
200
);
} catch (err) {
console.log('ユーザの追加に失敗しました.');
console.log('ユーザの追加に失敗しました.' + err);
return c.json(
{
message: 'ユーザの追加に失敗しました.',
69 changes: 69 additions & 0 deletions backend/src/controllers/isOurAccountHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { z, type RouteHandler } from '@hono/zod-openapi';
import type { Context } from 'hono';
import { isOurAccountSchema } from '../schema/isOurAccountSchema.js';
import type { isOurAccountRoute } from '../routes/isOurAccountRoute.js';
import { env } from '../config/env.js';

type isOurAccountSchema = z.infer<typeof isOurAccountSchema>;

const isOurAccountHandler: RouteHandler<typeof isOurAccountRoute, {}> = async (c: Context) => {
try {
// 受け取ったjsonを変数に格納
const { icon_url } = await c.req.json<isOurAccountSchema>();

// 数字列の抽出
const match = icon_url.match(/\/u\/(\d+)/);

// githubのicon_urlか判定
if (!match) {
console.log('エラー: GitHubのicon_urlを入力してください.');
return c.json(
{
message: 'エラー: GitHubのicon_urlを入力してください.',
statusCode: 500,
error: 'エラー: GitHubのicon_urlを入力してください.',
},
500
);
}

const id = match[1];
const our_id_str = env.OUR_ID;
const our_idArray = our_id_str.split(',').map((item) => String(item.trim()));

// envファイルのour_idに含まれる数字列かどうか判定
if (!our_idArray.includes(id)) {
// レスポンス
console.log('私たちのアカウントではありません.');
return c.json(
{
message: '私たちのアカウントではありません.',
isOurAccount: false,
},
200
);
}

// レスポンス
console.log('私たちのアカウントです.');
return c.json(
{
message: '私たちのアカウントです.',
isOurAccount: true,
},
200
);
} catch (err) {
console.log('アカウントの判定に失敗しました.' + err);
return c.json(
{
message: 'アカウントの判定に失敗しました.',
statusCode: 500,
error: 'アカウントの判定に失敗しました.',
},
500
);
}
};

export default isOurAccountHandler;
67 changes: 67 additions & 0 deletions backend/src/routes/Post/getOnePostRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { z } from '@hono/zod-openapi';
import { createRoute } from '@hono/zod-openapi';
import { getOnePostSchema, getOnePostResponseSchema } from '../../schema/Post/getOnePostSchema.js';

type getOnePostSchema = z.infer<typeof getOnePostSchema>;

const errorResponseSchema = z.object({
message: z.string(),
statusCode: z.number(),
error: z.string(),
});

export const getOnePostRoute = createRoute({
method: 'post',
path: '/share',
tags: ['Post'],
request: {
body: {
required: true,
content: {
'application/json': {
schema: getOnePostSchema,
},
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: getOnePostResponseSchema,
},
},
description: 'Successful response',
},
404: {
content: {
'application/json': {
schema: errorResponseSchema,
},
},
description: 'Not Found',
},
500: {
content: {
'application/json': {
schema: errorResponseSchema,
},
},
description: 'Internal Server Error response',
},
},
});

export type getOnePostRouteResponse200 = z.infer<
(typeof getOnePostRoute.responses)['200']['content']['application/json']['schema']
>;

export type getOnePostRouteResponse404 = z.infer<
(typeof getOnePostRoute.responses)['404']['content']['application/json']['schema']
>;

export type getOnePostRouteResponse500 = z.infer<
(typeof getOnePostRoute.responses)['500']['content']['application/json']['schema']
>;

export type getOnePostRouteResponseError = z.infer<typeof errorResponseSchema>;
55 changes: 55 additions & 0 deletions backend/src/routes/isOurAccountRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { z } from '@hono/zod-openapi';
import { createRoute } from '@hono/zod-openapi';
import { isOurAccountSchema, isOurAccountResponseSchema } from '../schema/isOurAccountSchema.js';

type isOurAccountSchema = z.infer<typeof isOurAccountSchema>;

const errorResponseSchema = z.object({
message: z.string(),
statusCode: z.number(),
error: z.string(),
});

export const isOurAccountRoute = createRoute({
method: 'post',
path: '/isOurAccount',
tags: ['sample'],
request: {
body: {
required: true,
content: {
'application/json': {
schema: isOurAccountSchema,
},
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: isOurAccountResponseSchema,
},
},
description: 'Successful response',
},
500: {
content: {
'application/json': {
schema: errorResponseSchema,
},
},
description: 'Internal Server Error response',
},
},
});

export type isOurAccountRouteResponse200 = z.infer<
(typeof isOurAccountRoute.responses)['200']['content']['application/json']['schema']
>;

export type isOurAccountRouteResponse500 = z.infer<
(typeof isOurAccountRoute.responses)['500']['content']['application/json']['schema']
>;

export type isOurAccountRouteResponseError = z.infer<typeof errorResponseSchema>;
6 changes: 6 additions & 0 deletions backend/src/routes/route.ts
Original file line number Diff line number Diff line change
@@ -7,6 +7,8 @@ import { deletePostRoute } from './Post/deletePostRoute.js';
import deletePostHandler from '../controllers/Post/deletePostHandler.js';
import { getPostRoute } from './Post/getPostRoute.js';
import getPostHandler from '../controllers/Post/getPostHandler.js';
import { getOnePostRoute } from './Post/getOnePostRoute.js';
import getOnePostHandler from '../controllers/Post/getOnePostHandler.js';
import { createMiyabiRoute } from './Miyabi/createMiyabiRoute.js';
import createMiyabiHandler from '../controllers/Miyabi/createMiyabiHandler.js';
import { deleteMiyabiRoute } from './Miyabi/deleteMiyabiRoute.js';
@@ -19,6 +21,8 @@ import { getProfileRoute } from './Profile/getProfileRoute.js';
import getProfileHandler from '../controllers/Profile/getProfileHandler.js';
import { idiconConverterRoute } from './idiconConverterRoute.js';
import idiconConverterHandler from '../controllers/idiconConverterHandler.js';
import { isOurAccountRoute } from './isOurAccountRoute.js';
import isOurAccountHandler from '../controllers/isOurAccountHandler.js';
import { sampleS3UploadRoute } from './sampleS3Route.js';
import sampleS3UploadHandler from '../controllers/sampleS3UploadHandler.js';
import { sampleS3DownloadRoute } from './sampleS3Route.js';
@@ -35,12 +39,14 @@ export default router
.openapi(createPostRoute, createPostHandler)
.openapi(deletePostRoute, deletePostHandler)
.openapi(getPostRoute, getPostHandler)
.openapi(getOnePostRoute, getOnePostHandler)
.openapi(createMiyabiRoute, createMiyabiHandler)
.openapi(deleteMiyabiRoute, deleteMiyabiHandler)
.openapi(getMiyabiRankingRoute, getMiyabiRankingHandler)
.openapi(createUserRoute, createUserHandler)
.openapi(getProfileRoute, getProfileHandler)
.openapi(idiconConverterRoute, idiconConverterHandler)
.openapi(isOurAccountRoute, isOurAccountHandler)
.openapi(sampleS3UploadRoute, sampleS3UploadHandler)
.openapi(sampleS3DownloadRoute, sampleS3DownloadHandler)
.openapi(sampleGeminiRoute, sampleGeminiHandler)
28 changes: 28 additions & 0 deletions backend/src/schema/Post/getOnePostSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from '@hono/zod-openapi';

// リクエストの型
export const getOnePostSchema = z.object({
my_icon: z.string().optional().openapi({
example: 'https://avatars.githubusercontent.com/u/131171129?v=4',
description: 'git hubのアイコンURL',
}),
post_id: z.string().openapi({
example: 'cb3adc47-eba3-11ef-9ce7-0242ac130002',
description: '投稿id',
}),
});

// レスポンスの型
export const getOnePostResponseSchema = z.object({
message: z.string(),
id: z.string(),
original: z.string(),
tanka: z.array(z.string()),
image_path: z.string(),
created_at: z.string(),
user_id: z.string(),
user_name: z.string(),
user_icon: z.string(),
miyabi_count: z.number(),
is_miyabi: z.boolean(),
});
15 changes: 15 additions & 0 deletions backend/src/schema/isOurAccountSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from '@hono/zod-openapi';

// リクエストの型
export const isOurAccountSchema = z.object({
icon_url: z.string().openapi({
example: 'https://avatars.githubusercontent.com/u/131171129?v=4',
description: 'icon_URLを入力',
}),
});

// レスポンスの型
export const isOurAccountResponseSchema = z.object({
message: z.string(),
isOurAccount: z.boolean(),
});
3 changes: 2 additions & 1 deletion frontend/.env.exapmle
Original file line number Diff line number Diff line change
@@ -2,4 +2,5 @@ NEXT_PUBLIC_ADOBE_FONTS_KIT_ID=
AUTH_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECREAT=
AUTH_URL=
AUTH_URL=
BASE_URL=
Binary file added frontend/public/lottie/hamberger.lottie
Binary file not shown.
Binary file added frontend/public/syodou_fude.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/syodou_sumi_bou.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/syodou_suzuri.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions frontend/src/app/(main)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React from 'react';
import HeaderAndMenu from '@/components/HeaderAndMenu';
import MotionWrapper from '@/components/MotionWrapper';
import YomuButton from '@/components/YomuButton';

const MainLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<HeaderAndMenu />
<MotionWrapper>{children}</MotionWrapper>
<YomuButton />
</>
);
};
28 changes: 0 additions & 28 deletions frontend/src/app/(main)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,18 @@
'use client';

import React from 'react';
import { useState } from 'react';
import FloatingActionButton from '@/components/FloatingActionButton';
import { useSession } from 'next-auth/react';
import LoginDialog from '@/components/LoginDialog';
import { useRouter } from 'next/navigation';
import Timeline from '@/components/Timeline';

const LIMIT = 10; // 一度に取得する投稿数
const MAX = 100; // タイムラインに表示できる最大投稿数

const Page = () => {
// セッションの取得
const session = useSession();
// ログイン状態
const isLoggedIn = session.status === 'authenticated';
// ログイン促進ダイアログの開閉状態
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const router = useRouter();

return (
<div className='relative min-h-screen'>
{/* タイムライン */}
<div className='mx-auto max-w-sm lg:max-w-lg'>
<Timeline limit={LIMIT} max={MAX} />
</div>

{/* 投稿(詠)ボタン */}
<FloatingActionButton
char='詠'
onClick={() => {
if (isLoggedIn) {
router.push('/yomu');
} else {
setLoginDialogOpen(true);
}
}}
/>

{/* ログイン確認ダイアログ表示が有効の場合,ダイアログを表示する */}
<LoginDialog isOpen={loginDialogOpen} setIsOpen={setLoginDialogOpen} />
</div>
);
};
65 changes: 65 additions & 0 deletions frontend/src/app/(main)/post/[postId]/actions/fetchOnePost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// サーバアクション
'use server';

import { PostTypes } from '@/types/postTypes';

const backendUrl = process.env.BACKEND_URL ?? 'http://localhost:8080';

/**
* 単体の投稿データを取得する非同期関数
* @async
* @function fetchOnePost
* @param {Object} params - 投稿データ取得のためのパラメータオブジェクト
* @param {string} params.iconUrl - ユーザのアイコン画像URL
* @param {string} params.postId - 取得する投稿のID(オフセット)
* @returns {Promise<PostTypes[]>} 投稿データを返すPromise.投稿が存在しない場合はnullを返す.
*/
export const fetchOnePost = async ({
iconUrl,
postId,
}: {
iconUrl?: string;
postId?: string;
}): Promise<PostTypes | null> => {
try {
const res = await fetch(`${backendUrl}/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
my_icon: iconUrl,
post_id: postId,
}),
});

// エラーがある場合はnullを返す
if (!res.ok) {
console.log(res.statusText);
return null;
}

const json = await res.json();
const post: PostTypes = {
id: json.id,
tanka: json.tanka,
original: json.original,
imageUrl: json.image_path ?? '',
date: new Date(json.created_at),
user: {
name: json.user_name,
iconUrl: json.user_icon,
userId: json.user_id,
},
miyabiCount: json.miyabi_count,
miyabiIsClicked: json.is_miyabi,
rank: json.rank,
};
return post;
} catch (error) {
console.error(error);
return null;
}
};

export default fetchOnePost;
55 changes: 55 additions & 0 deletions frontend/src/app/(main)/post/[postId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { useParams } from 'next/navigation';
import { PostTypes } from '@/types/postTypes';
import Post from '@/components/Post';
import fetchOnePost from './actions/fetchOnePost';
import { AnimatePresence, motion } from 'framer-motion';

/**
* 指定されたIDの投稿を表示する.
* @async
* @function Post
* @returns {JSX.Element} プロフィールを表示するReactコンポーネント
*/
const PostPage = () => {
const { postId } = useParams() as { postId: string };
const [post, setPost] = useState<PostTypes | null>(null);
// セッションの取得
const session = useSession();

// 投稿IDから投稿をFetchする
useEffect(() => {
const getPost = async () => {
if (session.status === 'loading') return;
const data = await fetchOnePost({
postId: postId as string,
iconUrl: session.data?.user?.image ?? '',
});
setPost(data);
};
getPost();
}, [postId, session.data?.user?.image, session.status]);

return (
<div>
{!post && <p className='py-3 text-center'>短歌を取得中...</p>}
<AnimatePresence mode='wait'>
{post && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className='mx-auto max-w-sm pt-5 lg:max-w-lg'
>
{post && <Post post={post} />}
</motion.div>
)}
</AnimatePresence>
</div>
);
};

export default PostPage;
44 changes: 36 additions & 8 deletions frontend/src/app/(main)/profile/[userId]/page.tsx
Original file line number Diff line number Diff line change
@@ -50,19 +50,47 @@ const Profile = () => {
</div>
<div className='space-y-6 p-6'>
<div className='flex flex-col items-center space-y-4'>
<Image
src={profile?.iconUrl ?? '/iconDefault.png'}
alt='プロフィール画像'
width={100}
height={100}
className='rounded-full border-2 border-gray-300'
/>
<div className='relative'>
{/* 中心のプロフィールアイコン */}
<Image
src={profile?.iconUrl ?? '/iconDefault.png'}
alt='プロフィール画像'
width={100}
height={100}
className='z-10 rounded-full border-2 border-gray-300'
/>
{/* 周囲の画像を配置 */}
<div className='absolute inset-0 flex items-center justify-center'>
<Image
src='/syodou_fude.png'
alt='筆'
width={80}
height={80}
className='absolute left-[-80px] top-[-20px] size-[80px] md:left-[-80px] lg:left-[-140px] '
/>
<Image
src='/syodou_sumi_bou.png'
alt='墨棒'
width={100}
height={100}
className='absolute bottom-[-40px] right-[-100px] size-[100px] md:right-[-100px] lg:right-[-140px]'
/>
<Image
src='/syodou_suzuri.png'
alt='硯'
width={80}
height={80}
className='absolute bottom-[-100px] left-[-80px] size-[80px] md:left-[-80px] lg:left-[-140px] '
/>
</div>
</div>

<div className='w-full space-y-2 text-center'>
<label htmlFor='name' className='block text-lg text-gray-700'>
{profile?.name ?? '取得中'}
</label>
<div className='mt-4 text-gray-600'>
<p>雅獲得数: {profile?.totalMiyabi ?? '取得中'}</p>
<p>総獲得雅数: {profile?.totalMiyabi ?? '取得中'}</p>
<p>総詠歌数: {profile?.totalPost ? `${profile.totalPost}首` : '取得中'}</p>
</div>
</div>
58 changes: 58 additions & 0 deletions frontend/src/components/HambergerButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// クライアントコンポーネント
'use client';

import React, { useState } from 'react';
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
import { DotLottie } from '@lottiefiles/dotlottie-react';

export interface HambergerButtonProps {
onClick?: () => void;
isOpen?: boolean;
className?: string;
}

const HambergerButton = ({ onClick, isOpen, className }: HambergerButtonProps): React.ReactNode => {
const [dotlottie, setDotlottie] = useState<DotLottie | undefined>();

const dotLottieRefCallback = (dotLottie: DotLottie) => {
if (dotLottie) {
setDotlottie(dotLottie);
}
};

const openMenuAnimation = () => {
dotlottie?.setMode('forward');
dotlottie?.play();
};

const closeMenuAnimation = () => {
dotlottie?.setMode('reverse');
dotlottie?.play();
};

return (
<button
onClick={() => {
if (isOpen) {
closeMenuAnimation();
} else {
openMenuAnimation();
}
onClick?.();
}}
className={`${className} mx-auto block transition-all duration-300 hover:scale-110`}
>
<DotLottieReact
src={'/lottie/hamberger.lottie'}
segment={[0, 30]}
autoplay={false}
loop={false}
speed={1.0}
mode='forward'
dotLottieRefCallback={dotLottieRefCallback}
/>
</button>
);
};

export default HambergerButton;
13 changes: 11 additions & 2 deletions frontend/src/components/HeaderAndMenu.tsx
Original file line number Diff line number Diff line change
@@ -2,8 +2,9 @@
'use client';

import { useState } from 'react';
import { MdOutlineMenu } from 'react-icons/md';
import { useRouter } from 'next/navigation';
import SideMenu from '@/components/SideMenu';
import { MdOutlineMenu } from 'react-icons/md';
import { motion, AnimatePresence } from 'framer-motion';

/**
@@ -15,6 +16,7 @@ import { motion, AnimatePresence } from 'framer-motion';
const HeaderAndMenu = () => {
// ハンバーガーメニューの開閉状態
const [isMenuOpen, setIsMenuOpen] = useState(false);
const router = useRouter();

return (
<div>
@@ -27,7 +29,14 @@ const HeaderAndMenu = () => {
}}
className='absolute left-3 lg:hidden'
/>
<div className=''>Tankalizer</div>
<div
onClick={() => {
router.push('/');
}}
className='hover:cursor-pointer hover:underline'
>
Tankalizer
</div>
</div>
{/* サイドメニュー */}
<div className='pt-12'>
44 changes: 41 additions & 3 deletions frontend/src/components/Post.tsx
Original file line number Diff line number Diff line change
@@ -9,19 +9,22 @@ import ImageModal from '@/components/ImageModal';
import MiyabiButton from '@/components/MiyabiButton';
import DropDownButton from './DropDownButton';
import { formatDateKanji, toKanjiNumber } from '@/app/(main)/timeline/utils/kanjiNumber';
import { MdDeleteForever } from 'react-icons/md';
import { MdDeleteForever, MdShare } from 'react-icons/md';
import { useSession } from 'next-auth/react';
import Dialog from '@/components/Dialog';
import LoginDialog from './LoginDialog';
import { addMiyabi, removeMiyabi } from '@/app/(main)/timeline/actions/countMiyabi';
import deletePost from '@/app/(main)/timeline/actions/deletePost';
import { useRouter } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';

const baseUrl = process.env.BASE_URL ?? 'http://localhost:3000';

// props の型定義
interface PostProps {
post: PostTypes;
className?: string;
onDelete: (postId: string) => void;
onDelete?: (postId: string) => void;
}

/**
@@ -41,12 +44,31 @@ const Post = ({ post, className, onDelete }: PostProps) => {
const [modalOpen, setModalOpen] = useState(false);
// 削除確認ダイアログの表示状態
const [dialogOpen, setDialogOpen] = useState(false);
// コピートーストの表示状態
const [toastOpen, setToastOpen] = useState(false);
// 削除失敗ダイアログの表示状態
const [deleteFailedDialogOpen, setDeleteFailedDialogOpen] = useState(false);
// ユーザアイコンURLが一致するなら自分の投稿
const isMyPost = useSession().data?.user?.image === post.user.iconUrl;
// ドロップダウンメニューの要素
const dropDownItems = [];
// ドロップダウンメニューの投稿共有ボタン
const dropDownShareButton = {
label: '投稿を共有',
onClick: async () => {
const link = `${baseUrl}/post/${post.id}`;
try {
await navigator.clipboard.writeText(link);
setToastOpen(true);
setTimeout(() => setToastOpen(false), 2000);
} catch (e) {
console.error(e);
}
},
className: '',
icon: <MdShare />,
color: 'black',
};
// ドロップダウンメニューの投稿削除ボタン
const dropDownDeleteButton = {
label: '投稿を削除',
@@ -55,6 +77,8 @@ const Post = ({ post, className, onDelete }: PostProps) => {
icon: <MdDeleteForever />,
color: 'red',
};
// ドロップダウンに共有ボタン追加
dropDownItems.push(dropDownShareButton);
// 自分の投稿ならドロップダウンに削除ボタン追加
if (isMyPost) {
dropDownItems.push(dropDownDeleteButton);
@@ -67,7 +91,7 @@ const Post = ({ post, className, onDelete }: PostProps) => {
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
// 親の持つPostsから自身を削除する
const handleDelete = () => {
onDelete(post.id);
onDelete?.(post.id);
};

const router = useRouter();
@@ -208,6 +232,20 @@ const Post = ({ post, className, onDelete }: PostProps) => {
yesText='はい'
isOnlyOK
/>
{/* リンクをコピーした場合,トーストを表示する */}
<AnimatePresence mode='wait'>
{toastOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className='fixed bottom-5 left-1/2 z-40 -translate-x-1/2 rounded-lg bg-orange-400 p-2 shadow-lg'
>
<p className='text-center text-sm text-white'>リンクをコピーしました</p>
</motion.div>
)}
</AnimatePresence>
{/* ログイン確認ダイアログ表示が有効の場合,ダイアログを表示する */}
<LoginDialog isOpen={loginDialogOpen} setIsOpen={setLoginDialogOpen} />
</div>
11 changes: 10 additions & 1 deletion frontend/src/components/PostList.tsx
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@

import { PostTypes } from '@/types/postTypes';
import Post from '@/components/Post';
import { motion } from 'framer-motion';

// props の型定義
interface PostListProps {
@@ -21,7 +22,15 @@ const PostList = ({ posts, className, onDelete }: PostListProps) => {
return (
<div>
{posts.map((post, i) => (
<Post key={i} post={post} className={className} onDelete={onDelete} />
<motion.div
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<Post post={post} className={className} onDelete={onDelete} />
</motion.div>
))}
</div>
);
18 changes: 1 addition & 17 deletions frontend/src/components/SideMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// クライアントコンポーネント
'use client';

import { CiUser, CiSettings, CiLogout, CiLogin, CiClock2 } from 'react-icons/ci';
import { CiUser, CiLogout, CiLogin, CiClock2 } from 'react-icons/ci';
import { PiRankingLight } from 'react-icons/pi';
import Image from 'next/image';
import { signIn, signOut, useSession } from 'next-auth/react';
@@ -105,22 +105,6 @@ const SideMenu = ({ className, style, setIsOpen }: SideMenuProps) => {
雅ランキング
</a>
</div>
<div
onClick={() => {
if (isLoggedIn) {
if (setIsOpen) setIsOpen(false);
router.push(PATHNAME.SETTINGS);
} else {
setLoginDialogOpen(true);
}
}}
className={`flex items-center rounded-lg hover:cursor-pointer hover:bg-black/5 ${
pathname === PATHNAME.SETTINGS ? 'bg-orange-200' : 'bg-transparent'
}`}
>
<CiSettings size={28} />
<a className={`pl-1 text-xl ${pathname === PATHNAME.SETTINGS ? 'font-bold' : ''}`}>設定</a>
</div>
{!isLoggedIn && (
<div
onClick={() => signIn()}
52 changes: 52 additions & 0 deletions frontend/src/components/YomuButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// クライアントコンポーネント
'use client';

import { useSession } from 'next-auth/react';
import FloatingActionButton from './FloatingActionButton';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import LoginDialog from './LoginDialog';

// props の型定義
interface YomuButtonProps {
className?: string;
onClick?: () => void;
}

/**
* 詠ボタンを表示するコンポーネント
* @component YomuButton
* @return {JSX.Element} 詠ボタンを表示するReactコンポーネント
*/
const YomuButton = ({ className, onClick }: YomuButtonProps) => {
// セッションの取得
const session = useSession();
// ログイン状態
const isLoggedIn = session.status === 'authenticated';
// ログイン促進ダイアログの開閉状態
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const router = useRouter();

return (
<div>
{/* 投稿(詠)ボタン */}
<FloatingActionButton
char='詠'
onClick={() => {
if (isLoggedIn) {
router.push('/yomu');
} else {
setLoginDialogOpen(true);
}
onClick?.();
}}
className={className ? className : ''}
/>

{/* ログイン確認ダイアログ表示が有効の場合,ダイアログを表示する */}
<LoginDialog isOpen={loginDialogOpen} setIsOpen={setLoginDialogOpen} />
</div>
);
};

export default YomuButton;