diff --git a/backend/.env.example b/backend/.env.example index eaf191e..29bc3c2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,3 +13,7 @@ GEMINI_API_KEY= S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_BUCKET_NAME= + +CURRENTS_API_KEY= +NEWS_POST_API_KEY= +OUR_ID= diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 33dc14c..2549835 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -8,6 +8,9 @@ const envSchema = z.object({ FRONTEND_URL: z.string().url(), PORT: z.coerce.number().optional(), GEMINI_API_KEY: z.string(), + CURRENTS_API_KEY: z.string(), + NEWS_POST_API_KEY: z.string(), + NEWS_USER_ID: z.string(), RDB_HOST: z.string(), RDB_USER: z.string(), RDB_PASSWORD: z.string(), @@ -18,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); diff --git a/backend/src/controllers/Miyabi/createMiyabiHandler.ts b/backend/src/controllers/Miyabi/createMiyabiHandler.ts index 9b4b178..68f60c8 100644 --- a/backend/src/controllers/Miyabi/createMiyabiHandler.ts +++ b/backend/src/controllers/Miyabi/createMiyabiHandler.ts @@ -40,7 +40,7 @@ const createMiyabiHandler: RouteHandler = async (c 200 ); } catch (err) { - console.log('雅に失敗しました.'); + console.log('雅に失敗しました.' + err); return c.json( { message: '雅に失敗しました.', diff --git a/backend/src/controllers/Miyabi/deleteMiyabiHandler.ts b/backend/src/controllers/Miyabi/deleteMiyabiHandler.ts index 4dd38c8..e55f0ec 100644 --- a/backend/src/controllers/Miyabi/deleteMiyabiHandler.ts +++ b/backend/src/controllers/Miyabi/deleteMiyabiHandler.ts @@ -40,7 +40,7 @@ const deleteMiyabiHandler: RouteHandler = async (c 200 ); } catch (err) { - console.log('雅の削除に失敗しました.'); + console.log('雅の削除に失敗しました.' + err); return c.json( { message: '雅の削除に失敗しました.', diff --git a/backend/src/controllers/Miyabi/getMiyabiRankingHandler.ts b/backend/src/controllers/Miyabi/getMiyabiRankingHandler.ts index 0e13953..af70050 100644 --- a/backend/src/controllers/Miyabi/getMiyabiRankingHandler.ts +++ b/backend/src/controllers/Miyabi/getMiyabiRankingHandler.ts @@ -61,7 +61,7 @@ const getMiyabiRankingHandler: RouteHandler = 200 ); } catch (err) { - console.log('雅ランキングの取得に失敗しました.'); + console.log('雅ランキングの取得に失敗しました.' + err); return c.json( { message: '雅ランキングの取得に失敗しました.', diff --git a/backend/src/controllers/Post/deletePostHandler.ts b/backend/src/controllers/Post/deletePostHandler.ts index ecb2acb..996e45e 100644 --- a/backend/src/controllers/Post/deletePostHandler.ts +++ b/backend/src/controllers/Post/deletePostHandler.ts @@ -56,7 +56,7 @@ const deletePostHandler: RouteHandler = async (c: Co 200 ); } catch (err) { - console.log('投稿の削除に失敗しました.'); + console.log('投稿の削除に失敗しました.' + err); return c.json( { message: '投稿の削除に失敗しました.', diff --git a/backend/src/controllers/Post/getOnePostHandler.ts b/backend/src/controllers/Post/getOnePostHandler.ts new file mode 100644 index 0000000..63bfe93 --- /dev/null +++ b/backend/src/controllers/Post/getOnePostHandler.ts @@ -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; + +const getOnePostHandler: RouteHandler = async (c: Context) => { + try { + // 受け取ったjsonを各変数に格納 (post_idが指定なしなら,最新の投稿idになる) + let { my_icon = null, post_id } = await c.req.json(); + + // 入力の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; diff --git a/backend/src/controllers/Post/getPostHandler.ts b/backend/src/controllers/Post/getPostHandler.ts index 0d9fee9..decfbfc 100644 --- a/backend/src/controllers/Post/getPostHandler.ts +++ b/backend/src/controllers/Post/getPostHandler.ts @@ -103,7 +103,7 @@ const getPostHandler: RouteHandler = async (c: Context) 200 ); } catch (err) { - console.log('投稿の取得に失敗しました.'); + console.log('投稿の取得に失敗しました.' + err); return c.json( { message: '投稿の取得に失敗しました.', diff --git a/backend/src/controllers/Profile/getProfileHandler.ts b/backend/src/controllers/Profile/getProfileHandler.ts index 8f0d22c..bab62a1 100644 --- a/backend/src/controllers/Profile/getProfileHandler.ts +++ b/backend/src/controllers/Profile/getProfileHandler.ts @@ -54,7 +54,7 @@ const getProfileHandler: RouteHandler = async (c: Co 200 ); } catch (err) { - console.log('プロフィールの取得に失敗しました.'); + console.log('プロフィールの取得に失敗しました.' + err); return c.json( { message: 'プロフィールの取得に失敗しました.', diff --git a/backend/src/controllers/Sample/sampleNewsTankaHandler.ts b/backend/src/controllers/Sample/sampleNewsTankaHandler.ts new file mode 100644 index 0000000..528fa75 --- /dev/null +++ b/backend/src/controllers/Sample/sampleNewsTankaHandler.ts @@ -0,0 +1,23 @@ +import { z, type RouteHandler } from '@hono/zod-openapi'; +import type { Context } from 'hono'; +import { sampleNewsTankaSchema } from '../../schema/Sample/sampleNewsTankaSchema.js'; +import type { sampleNewsTankaRoute } from '../../routes/Sample/sampleNewsTankaRoute.js'; +import postNews from '../../lib/postNews.js'; + +type sampleNewsTankaSchema = z.infer; + +const sampleNewsTankaHandler: RouteHandler = async ( + c: Context +) => { + const { apiKey } = await c.req.json(); + + /* --- 色々処理 --- */ + + const response = await postNews(apiKey); + console.log(response); + + // レスポンス + return c.json(response, 200); +}; + +export default sampleNewsTankaHandler; diff --git a/backend/src/controllers/User/createUserHandler.ts b/backend/src/controllers/User/createUserHandler.ts index dcbd29a..6962263 100644 --- a/backend/src/controllers/User/createUserHandler.ts +++ b/backend/src/controllers/User/createUserHandler.ts @@ -54,7 +54,7 @@ const createUserHandler: RouteHandler = async (c: Co 200 ); } catch (err) { - console.log('ユーザの追加に失敗しました.'); + console.log('ユーザの追加に失敗しました.' + err); return c.json( { message: 'ユーザの追加に失敗しました.', diff --git a/backend/src/controllers/isOurAccountHandler.ts b/backend/src/controllers/isOurAccountHandler.ts new file mode 100644 index 0000000..26815f3 --- /dev/null +++ b/backend/src/controllers/isOurAccountHandler.ts @@ -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; + +const isOurAccountHandler: RouteHandler = async (c: Context) => { + try { + // 受け取ったjsonを変数に格納 + const { icon_url } = await c.req.json(); + + // 数字列の抽出 + 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; diff --git a/backend/src/lib/gemini.ts b/backend/src/lib/gemini.ts index 21c52c8..09c958d 100644 --- a/backend/src/lib/gemini.ts +++ b/backend/src/lib/gemini.ts @@ -106,7 +106,7 @@ const generateTanka = async (originalText: string): Promise => { 1. **五七五七七の形式厳守:** **読み(yomi0~yomi4)をカタカナに変換した上で**、五七五七七の音数になるように調整してください。字余りや字足らずは許されません。 * **特にアルファベットや記号は、カタカナ読みに変換した上で音数を数えてください。** 例:「AI」→「エーアイ」(4音) * どうしても正確な音数に変換できない場合は、近い音に置き換える、または、前後の句で調整するなど、**短歌全体として五七五七七になるように工夫してください。** - * 長音(ー)、促音(っ)、拗音(ゃゅょ)は1音として扱う。 + * 長音(ー)、促音(っ)は1音として扱いますが、拗音(ゃゅょ)は1音として扱いません。 2. **面白さとユニークさの追求:** * **原投稿の要素を活かす:** 投稿内容のキーワード、感情、状況などを取り入れつつ、予想外の展開や表現につなげてください。 * **言葉遊び:** 同音異義語、掛詞、比喩などを積極的に活用し、言葉の面白さを引き出してください。 diff --git a/backend/src/lib/getNews.ts b/backend/src/lib/getNews.ts new file mode 100644 index 0000000..18c7411 --- /dev/null +++ b/backend/src/lib/getNews.ts @@ -0,0 +1,59 @@ +import type { Context } from 'hono'; +import { env } from '../config/env.js'; + +const printLine = (): void => { + console.log('--------------------------------'); +}; + +const getNews = async () => { + try { + const response = await fetch( + `https://api.currentsapi.services/v1/latest-news?language=ja&apiKey=${env.CURRENTS_API_KEY}` + ); + + // HTTPエラーが発生した場合 + if (!response.ok) { + throw new Error(`HTTPエラー: ${response.status}`); + } + + const data = await response.json(); + + console.log(data); + + // ニュースの取得に失敗した場合 + if (data.status !== 'ok') { + throw new Error('ニュースの取得に失敗しました。'); + } + + const newsArray = data.news; + + let news; + for (let i = 0; i < newsArray.length; i++) { + const newsTemp = newsArray[i]; + if (newsTemp.title !== '' && newsTemp.description !== '' && newsTemp.url !== '') { + news = newsTemp; + break; + } + } + + if (!news) { + throw new Error('表示できるニュースがありません。'); + } + return { + isSuccess: true, + news: { + title: news.title, + description: news.description, + url: news.url, + }, + }; + } catch (error: any) { + console.error(error); + return { + isSuccess: false, + message: error.message, + }; + } +}; + +export default getNews; diff --git a/backend/src/lib/postNews.ts b/backend/src/lib/postNews.ts new file mode 100644 index 0000000..b3a0dd4 --- /dev/null +++ b/backend/src/lib/postNews.ts @@ -0,0 +1,95 @@ +import type { Context } from 'hono'; +import { env } from '../config/env.js'; +import getNews from './getNews.js'; +import generateTanka from './gemini.js'; +import createPostHandler from '../controllers/Post/createPostHandler.js'; + +const printLine = (): void => { + console.log('--------------------------------'); +}; + +const postNews = async (requestApiKey: string) => { + if (requestApiKey !== env.NEWS_POST_API_KEY) { + return { + isSuccess: false, + tanka: { + line0: 'APIキーが間違っています', + line1: '', + line2: '', + line3: '', + line4: '', + }, + }; + } + + const newsResponse = await getNews(); + + // ニュースの取得に失敗した場合 + if (!newsResponse.isSuccess || !newsResponse.news) { + return { + isSuccess: false, + tanka: { + line0: 'ニュースの取得に失敗しました', + line1: '', + line2: '', + line3: '', + line4: '', + }, + }; + } + + const news = newsResponse.news; + + // 投稿の原文 + const originalText = `${news.title}\n${news.description}\n${news.url}`; + + console.log('originalText', originalText); + + try { + const formData = new FormData(); + formData.append('original', originalText); + formData.append('user_icon', `https://avatars.githubusercontent.com/u/${env.NEWS_USER_ID}?v=4`); + formData.append('user_name', '風聞'); + + const postResponse = await fetch(`http://localhost:8080/post`, { + method: 'POST', + body: formData, + }); + + console.log('postResponse', postResponse); + + if (!postResponse.ok) { + return { + isSuccess: false, + tanka: { + line0: '', + line1: '', + line2: '', + line3: '', + line4: '', + }, + }; + } + + const json = await postResponse.json(); + + return { + isSuccess: true, + tanka: json.tanka, + }; + } catch (error) { + console.error(error); + return { + isSuccess: false, + tanka: { + line0: '投稿に失敗しました', + line1: '', + line2: '', + line3: '', + line4: '', + }, + }; + } +}; + +export default postNews; diff --git a/backend/src/routes/Post/getOnePostRoute.ts b/backend/src/routes/Post/getOnePostRoute.ts new file mode 100644 index 0000000..2ccc893 --- /dev/null +++ b/backend/src/routes/Post/getOnePostRoute.ts @@ -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; + +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; diff --git a/backend/src/routes/Sample/sampleNewsTankaRoute.ts b/backend/src/routes/Sample/sampleNewsTankaRoute.ts new file mode 100644 index 0000000..a202ef2 --- /dev/null +++ b/backend/src/routes/Sample/sampleNewsTankaRoute.ts @@ -0,0 +1,41 @@ +import { z } from '@hono/zod-openapi'; +import { createRoute } from '@hono/zod-openapi'; +import { + sampleNewsTankaResponseSchema, + sampleNewsTankaSchema, +} from '../../schema/Sample/sampleNewsTankaSchema.js'; +import { errorResponseSchema } from '../../middleware/errorHandler.js'; + +type sampleNewsTankaSchema = z.infer; + +export const sampleNewsTankaRoute = createRoute({ + method: 'post', + path: '/sample/news', + tags: ['sample'], + request: { + body: { + required: true, + content: { + 'application/json': { + schema: sampleNewsTankaSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: sampleNewsTankaResponseSchema, + }, + }, + description: 'Successful response', + }, + }, +}); + +export type SampleNewsTankaRouteResponse200 = z.infer< + (typeof sampleNewsTankaRoute.responses)['200']['content']['application/json']['schema'] +>; + +export type SampleNewsTankaRouteResponseError = z.infer; diff --git a/backend/src/routes/isOurAccountRoute.ts b/backend/src/routes/isOurAccountRoute.ts new file mode 100644 index 0000000..a17c306 --- /dev/null +++ b/backend/src/routes/isOurAccountRoute.ts @@ -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; + +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; diff --git a/backend/src/routes/route.ts b/backend/src/routes/route.ts index ed83f77..ff55834 100644 --- a/backend/src/routes/route.ts +++ b/backend/src/routes/route.ts @@ -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,12 +21,16 @@ 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'; import sampleS3DownloadHandler from '../controllers/sampleS3DownloadHandler.js'; import { sampleGeminiRoute } from './Sample/sampleGeminiRoute.js'; import sampleGeminiHandler from '../controllers/Sample/sampleGeminiHandler.js'; +import { sampleNewsTankaRoute } from './Sample/sampleNewsTankaRoute.js'; +import sampleNewsTankaHandler from '../controllers/Sample/sampleNewsTankaHandler.js'; const router = new OpenAPIHono(); @@ -33,13 +39,16 @@ 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); + .openapi(sampleGeminiRoute, sampleGeminiHandler) + .openapi(sampleNewsTankaRoute, sampleNewsTankaHandler); // .openapi(helloRoute, helloWorldHandler); //こういう感じで足していく diff --git a/backend/src/schema/Post/getOnePostSchema.ts b/backend/src/schema/Post/getOnePostSchema.ts new file mode 100644 index 0000000..df1d172 --- /dev/null +++ b/backend/src/schema/Post/getOnePostSchema.ts @@ -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(), +}); diff --git a/backend/src/schema/Sample/sampleNewsTankaSchema.ts b/backend/src/schema/Sample/sampleNewsTankaSchema.ts new file mode 100644 index 0000000..bee7d03 --- /dev/null +++ b/backend/src/schema/Sample/sampleNewsTankaSchema.ts @@ -0,0 +1,33 @@ +import { z } from '@hono/zod-openapi'; + +export const sampleNewsTankaSchema = z.object({ + apiKey: z.string().openapi({ + example: 'abc123', + description: 'APIキー', + }), +}); + +export const sampleNewsTankaResponseSchema = z.object({ + isSuccess: z.boolean().openapi({ + example: true, + description: '短歌の生成に成功したかどうか', + }), + tanka: z + .object({ + line0: z.string(), + line1: z.string(), + line2: z.string(), + line3: z.string(), + line4: z.string(), + }) + .openapi({ + example: { + line0: '巷説の', + line1: '風の便りと', + line2: '聞きなして', + line3: '都の噂を', + line4: '語り継がんか', + }, + description: 'ニュースを取得し、短歌にした結果', + }), +}); diff --git a/backend/src/schema/isOurAccountSchema.ts b/backend/src/schema/isOurAccountSchema.ts new file mode 100644 index 0000000..7646e66 --- /dev/null +++ b/backend/src/schema/isOurAccountSchema.ts @@ -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(), +}); diff --git a/frontend/.env.exapmle b/frontend/.env.exapmle index 5257ca2..621d518 100644 --- a/frontend/.env.exapmle +++ b/frontend/.env.exapmle @@ -2,4 +2,5 @@ NEXT_PUBLIC_ADOBE_FONTS_KIT_ID= AUTH_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECREAT= -AUTH_URL= \ No newline at end of file +AUTH_URL= +BASE_URL= \ No newline at end of file diff --git a/frontend/public/1.png b/frontend/public/1.png new file mode 100644 index 0000000..3fe3dcb Binary files /dev/null and b/frontend/public/1.png differ diff --git a/frontend/public/10.png b/frontend/public/10.png new file mode 100644 index 0000000..a4be862 Binary files /dev/null and b/frontend/public/10.png differ diff --git a/frontend/public/11.png b/frontend/public/11.png new file mode 100644 index 0000000..f38b621 Binary files /dev/null and b/frontend/public/11.png differ diff --git a/frontend/public/12.png b/frontend/public/12.png new file mode 100644 index 0000000..e15f3e3 Binary files /dev/null and b/frontend/public/12.png differ diff --git a/frontend/public/2.png b/frontend/public/2.png new file mode 100644 index 0000000..57007ef Binary files /dev/null and b/frontend/public/2.png differ diff --git a/frontend/public/3.png b/frontend/public/3.png new file mode 100644 index 0000000..e0efafe Binary files /dev/null and b/frontend/public/3.png differ diff --git a/frontend/public/4.png b/frontend/public/4.png new file mode 100644 index 0000000..95d70ae Binary files /dev/null and b/frontend/public/4.png differ diff --git a/frontend/public/5.png b/frontend/public/5.png new file mode 100644 index 0000000..35824fd Binary files /dev/null and b/frontend/public/5.png differ diff --git a/frontend/public/6.png b/frontend/public/6.png new file mode 100644 index 0000000..f5f6f1d Binary files /dev/null and b/frontend/public/6.png differ diff --git a/frontend/public/7.png b/frontend/public/7.png new file mode 100644 index 0000000..0b42e51 Binary files /dev/null and b/frontend/public/7.png differ diff --git a/frontend/public/8.png b/frontend/public/8.png new file mode 100644 index 0000000..91ac96f Binary files /dev/null and b/frontend/public/8.png differ diff --git a/frontend/public/9.png b/frontend/public/9.png new file mode 100644 index 0000000..3afdd43 Binary files /dev/null and b/frontend/public/9.png differ diff --git a/frontend/public/lottie/hamberger.lottie b/frontend/public/lottie/hamberger.lottie new file mode 100644 index 0000000..c77508c Binary files /dev/null and b/frontend/public/lottie/hamberger.lottie differ diff --git a/frontend/public/syodou_fude.png b/frontend/public/syodou_fude.png new file mode 100644 index 0000000..055731f Binary files /dev/null and b/frontend/public/syodou_fude.png differ diff --git a/frontend/public/syodou_sumi_bou.png b/frontend/public/syodou_sumi_bou.png new file mode 100644 index 0000000..a00307e Binary files /dev/null and b/frontend/public/syodou_sumi_bou.png differ diff --git a/frontend/public/syodou_suzuri.png b/frontend/public/syodou_suzuri.png new file mode 100644 index 0000000..2fba69c Binary files /dev/null and b/frontend/public/syodou_suzuri.png differ diff --git a/frontend/src/app/(main)/layout.tsx b/frontend/src/app/(main)/layout.tsx index a699671..ac25074 100644 --- a/frontend/src/app/(main)/layout.tsx +++ b/frontend/src/app/(main)/layout.tsx @@ -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 ( <> {children} + ); }; diff --git a/frontend/src/app/(main)/page.tsx b/frontend/src/app/(main)/page.tsx index 59eef1e..4452123 100644 --- a/frontend/src/app/(main)/page.tsx +++ b/frontend/src/app/(main)/page.tsx @@ -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 (
{/* タイムライン */}
- - {/* 投稿(詠)ボタン */} - { - if (isLoggedIn) { - router.push('/yomu'); - } else { - setLoginDialogOpen(true); - } - }} - /> - - {/* ログイン確認ダイアログ表示が有効の場合,ダイアログを表示する */} -
); }; diff --git a/frontend/src/app/(main)/post/[postId]/actions/fetchOnePost.ts b/frontend/src/app/(main)/post/[postId]/actions/fetchOnePost.ts new file mode 100644 index 0000000..9294756 --- /dev/null +++ b/frontend/src/app/(main)/post/[postId]/actions/fetchOnePost.ts @@ -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} 投稿データを返すPromise.投稿が存在しない場合はnullを返す. + */ +export const fetchOnePost = async ({ + iconUrl, + postId, +}: { + iconUrl?: string; + postId?: string; +}): Promise => { + 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; diff --git a/frontend/src/app/(main)/post/[postId]/page.tsx b/frontend/src/app/(main)/post/[postId]/page.tsx new file mode 100644 index 0000000..0c644b6 --- /dev/null +++ b/frontend/src/app/(main)/post/[postId]/page.tsx @@ -0,0 +1,58 @@ +'use client'; +import React, { useEffect, useState } from 'react'; +import { useSession } from 'next-auth/react'; +import { useRouter, 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(null); + // セッションの取得 + const session = useSession(); + const router = useRouter(); + + // 投稿IDから投稿をFetchする + useEffect(() => { + const getPost = async () => { + if (!postId) return; + if (session.status === 'loading') return; + const data = await fetchOnePost({ + postId: postId as string, + iconUrl: session.data?.user?.image ?? '', + }); + if (!data) router.push('/post-not-found'); + setPost(data); + }; + getPost(); + }, [postId, session.data?.user?.image, session.status, router]); + + return ( +
+ {!post &&

短歌を取得中...

} + + {post && ( + + {post && router.push('/')} />} + + )} + +
+ ); +}; + +export default PostPage; diff --git a/frontend/src/app/(main)/profile/[userId]/page.tsx b/frontend/src/app/(main)/profile/[userId]/page.tsx index 5c57a37..68144a3 100644 --- a/frontend/src/app/(main)/profile/[userId]/page.tsx +++ b/frontend/src/app/(main)/profile/[userId]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import Image from 'next/image'; import React, { useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; +import { useRouter, useParams } from 'next/navigation'; import Timeline from '@/components/Timeline'; import fetchProfile from './actions/fetchProfile'; import { ProfileTypes } from '@/types/profileTypes'; @@ -15,15 +15,18 @@ import { ProfileTypes } from '@/types/profileTypes'; const Profile = () => { const { userId } = useParams() as { userId: string }; const [profile, setProfile] = useState(null); + const router = useRouter(); // ユーザIDからプロフィールをFetchする useEffect(() => { const getProfile = async () => { + if (!userId) return; const data = await fetchProfile({ userId: userId as string }); + if (!data) router.push('/user-not-found'); setProfile(data); }; getProfile(); - }, [userId]); + }, [userId, router]); // totalPost に応じた背景色のクラスを決定 const getBackgroundClass = () => { @@ -39,30 +42,82 @@ const Profile = () => { return 'from-amber-100'; // デフォルト }; + // totalMiyabi に応じた画像パスを決定 + const getImageSrc = () => { + const totalMiyabi = profile?.totalMiyabi || 0; + + if (totalMiyabi >= 250) return '/1.png'; + if (totalMiyabi >= 200) return '/2.png'; + if (totalMiyabi >= 175) return '/3.png'; + if (totalMiyabi >= 150) return '/4.png'; + if (totalMiyabi >= 125) return '/5.png'; + if (totalMiyabi >= 100) return '/6.png'; + if (totalMiyabi >= 75) return '/7.png'; + if (totalMiyabi >= 50) return '/8.png'; + if (totalMiyabi >= 30) return '/9.png'; + if (totalMiyabi >= 20) return '/10.png'; + if (totalMiyabi >= 10) return '/11.png'; + return '/12.png'; // デフォルト(条件を満たさない場合) + }; + return (
-
+
プロフィール
-
+
- プロフィール画像 +
+ プロフィール画像 + {getImageSrc() && ( + 上の画像 + )} +
+ 筆 + 墨棒 + 硯 +
+
-

雅獲得数: {profile?.totalMiyabi ?? '取得中'}

+

総獲得雅数: {profile?.totalMiyabi ?? '取得中'}

総詠歌数: {profile?.totalPost ? `${profile.totalPost}首` : '取得中'}

diff --git a/frontend/src/components/HambergerButton.tsx b/frontend/src/components/HambergerButton.tsx new file mode 100644 index 0000000..0e6af73 --- /dev/null +++ b/frontend/src/components/HambergerButton.tsx @@ -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(); + + const dotLottieRefCallback = (dotLottie: DotLottie) => { + if (dotLottie) { + setDotlottie(dotLottie); + } + }; + + const openMenuAnimation = () => { + dotlottie?.setMode('forward'); + dotlottie?.play(); + }; + + const closeMenuAnimation = () => { + dotlottie?.setMode('reverse'); + dotlottie?.play(); + }; + + return ( + + ); +}; + +export default HambergerButton; diff --git a/frontend/src/components/HeaderAndMenu.tsx b/frontend/src/components/HeaderAndMenu.tsx index 65a3899..8ec942a 100644 --- a/frontend/src/components/HeaderAndMenu.tsx +++ b/frontend/src/components/HeaderAndMenu.tsx @@ -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 (
@@ -27,7 +29,14 @@ const HeaderAndMenu = () => { }} className='absolute left-3 lg:hidden' /> -
Tankalizer
+
{ + router.push('/'); + }} + className='hover:cursor-pointer hover:underline' + > + Tankalizer +
{/* サイドメニュー */}
diff --git a/frontend/src/components/Post.tsx b/frontend/src/components/Post.tsx index ccc3f6e..84e875a 100644 --- a/frontend/src/components/Post.tsx +++ b/frontend/src/components/Post.tsx @@ -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: , + color: 'black', + }; // ドロップダウンメニューの投稿削除ボタン const dropDownDeleteButton = { label: '投稿を削除', @@ -55,6 +77,8 @@ const Post = ({ post, className, onDelete }: PostProps) => { icon: , 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 /> + {/* リンクをコピーした場合,トーストを表示する */} + + {toastOpen && ( + +

リンクをコピーしました

+
+ )} +
{/* ログイン確認ダイアログ表示が有効の場合,ダイアログを表示する */}
diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index e250881..3c016dc 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -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 (
{posts.map((post, i) => ( - + + + ))}
); diff --git a/frontend/src/components/SideMenu.tsx b/frontend/src/components/SideMenu.tsx index e7ce01e..a3f9305 100644 --- a/frontend/src/components/SideMenu.tsx +++ b/frontend/src/components/SideMenu.tsx @@ -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) => { 雅ランキング
-
{ - 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' - }`} - > - - 設定 -
{!isLoggedIn && (
signIn()} diff --git a/frontend/src/components/YomuButton.tsx b/frontend/src/components/YomuButton.tsx new file mode 100644 index 0000000..813daad --- /dev/null +++ b/frontend/src/components/YomuButton.tsx @@ -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 ( +
+ {/* 投稿(詠)ボタン */} + { + if (isLoggedIn) { + router.push('/yomu'); + } else { + setLoginDialogOpen(true); + } + onClick?.(); + }} + className={className ? className : ''} + /> + + {/* ログイン確認ダイアログ表示が有効の場合,ダイアログを表示する */} + +
+ ); +}; + +export default YomuButton;