diff --git a/.github/workflows/auto_build.yml b/.github/workflows/auto_build.yml new file mode 100644 index 0000000..b695acb --- /dev/null +++ b/.github/workflows/auto_build.yml @@ -0,0 +1,36 @@ +name: Build and Test + +on: + push + + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + + - name: Set environment variables from .env file1 + run: | + echo "${{ secrets.DB_ENV_FILE }}" > .env + + - name: Set environment variables from .env file2 + working-directory: app + run: | + echo "${{ secrets.ENV_FILE }}" > .env + + - name: Build Docker compose up + run: docker compose -f compose.dev.yml up --build -d + + - name: npm install + run: docker compose exec app npm install + + - name: npm run build + run: docker compose exec app npm run build + + - name: Build Docker compose down + run: docker compose down diff --git a/app/package-lock.json b/app/package-lock.json index 1cee9ec..93271ad 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -23,12 +24,14 @@ "lucide-react": "^0.453.0", "minio": "^8.0.2", "next": "14.2.16", + "next-themes": "^0.4.3", "nodemailer": "^6.9.15", "openai": "^4.68.4", "react": "^18", "react-camera-pro": "^1.4.0", "react-dom": "^18", "react-icons": "^5.3.0", + "sonner": "^1.7.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7" }, @@ -37,6 +40,7 @@ "@svgr/webpack": "^8.1.0", "@types/formidable": "^3.4.5", "@types/node": "^20.17.1", + "@types/nodemailer": "^6.4.16", "@types/react": "^18", "@types/react-dom": "^18", "postcss": "^8", @@ -3442,6 +3446,43 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.0.tgz", + "integrity": "sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==", + "dependencies": { + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -3981,6 +4022,16 @@ "node": ">= 6" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", @@ -6753,6 +6804,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.3.tgz", + "integrity": "sha512-nG84VPkTdUHR2YeD89YchvV4I9RbiMAql3GiLEQlPvq1ioaqPaIReK+yMRdg/zgiXws620qS1rU30TiWmmG9lA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -7783,6 +7844,16 @@ "tslib": "^2.0.3" } }, + "node_modules/sonner": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.0.tgz", + "integrity": "sha512-W6dH7m5MujEPyug3lpI2l3TC3Pp1+LTgK0Efg+IHDrBbtEjyCmCHHo6yfNBOsf1tFZ6zf+jceWwB38baC8yO9g==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/app/package.json b/app/package.json index 478e2ad..131428e 100755 --- a/app/package.json +++ b/app/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -27,12 +28,14 @@ "lucide-react": "^0.453.0", "minio": "^8.0.2", "next": "14.2.16", + "next-themes": "^0.4.3", "nodemailer": "^6.9.15", "openai": "^4.68.4", "react": "^18", "react-camera-pro": "^1.4.0", "react-dom": "^18", "react-icons": "^5.3.0", + "sonner": "^1.7.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7" }, @@ -41,6 +44,7 @@ "@svgr/webpack": "^8.1.0", "@types/formidable": "^3.4.5", "@types/node": "^20.17.1", + "@types/nodemailer": "^6.4.16", "@types/react": "^18", "@types/react-dom": "^18", "postcss": "^8", diff --git a/app/src/app/api/assignment/latest/route.ts b/app/src/app/api/assignment/latest/route.ts new file mode 100644 index 0000000..de92452 --- /dev/null +++ b/app/src/app/api/assignment/latest/route.ts @@ -0,0 +1,29 @@ +import type { latestAssignment } from "@/types"; +import { prisma } from "@lib/prisma"; + +// 最新の課題を取得 +export async function GET() { + const assignment = await prisma.assignment.findFirst({ + include: { word: true }, + orderBy: { + date: "desc", + }, + }); + + if (!assignment) { + return new Response(JSON.stringify({ message: "No assignment found for today." }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const latestAssignment: latestAssignment = { + assignmentId: assignment.id, + english: assignment.word.english, + }; + + return new Response(JSON.stringify(latestAssignment), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/app/src/app/api/assignment/today/route.ts b/app/src/app/api/assignment/today/route.ts index d76541e..5d2721d 100644 --- a/app/src/app/api/assignment/today/route.ts +++ b/app/src/app/api/assignment/today/route.ts @@ -1,22 +1,22 @@ -;import { prisma } from "@lib/prisma"; import type { Assignment, todayAssignment } from "@/types"; +import { prisma } from "@lib/prisma"; // 課題新規作成(ランダム) export async function GET() { const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0); // 今日の開始時間を設定 (00:00:00) - + const endOfToday = new Date(); endOfToday.setHours(23, 59, 59, 999); // 今日の終了時間を設定 (23:59:59) const assignments = await prisma.assignment.findMany({ where: { date: { - gte: startOfToday, // 今日の開始時間以上 - lte: endOfToday, // 今日の終了時間以下 + gte: startOfToday, // 今日の開始時間以上 + lte: endOfToday, // 今日の終了時間以下 }, - }, - include: { word: true }, + }, + include: { word: true }, }); console.log(assignments); @@ -24,17 +24,13 @@ export async function GET() { const todayAssignment: todayAssignment = { assignmentId: assignment.id, english: assignment.word.english, + assignTime: assignment.date, }; return todayAssignment; - }) + }); return new Response(JSON.stringify(todayAssignments), { status: 200, headers: { "Content-Type": "application/json" }, }); } - - - - - diff --git a/app/src/app/api/image/route.ts b/app/src/app/api/image/route.ts index c0207c9..ead6808 100644 --- a/app/src/app/api/image/route.ts +++ b/app/src/app/api/image/route.ts @@ -2,8 +2,6 @@ import type { NextApiResponse } from "next"; import type { NextRequest } from "next/server"; import { OpenAI } from "openai"; -import fs from "fs"; -import path from "path"; type ResponseData = { message: string; @@ -13,14 +11,21 @@ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, }); +const BUCKET_NAME = "kz2404"; + export async function GET( req: NextRequest, res: NextApiResponse ) { - return await generateCaption() + // クエリパラメータを取得 + const { searchParams } = new URL(req.url || ""); + const imageName = searchParams.get("imageName") || "default-image-name"; + const imageURL = `${process.env.NEXT_PUBLIC_MINIO_ENDPOINT}${BUCKET_NAME}/${imageName}`; + + return await generateCaption(imageURL) .then((caption) => { console.log(caption); - return new Response(JSON.stringify({ message: caption }), { + return new Response(JSON.stringify({ caption }), { status: 200, headers: { "Content-Type": "application/json" }, }); @@ -35,12 +40,8 @@ export async function GET( } // 画像URLからキャプションを生成する関数 -const generateCaption = async () => { +export const generateCaption = async (imageUrl: string) => { try { - // 猿人の画像URL - const imageUrl = - "https://www.cnn.co.jp/storage/2020/01/31/f85a2dac057ec8b6d8e08e9fec7a49e5/t/768/432/d/180226123522-neanderthal-man-super-169.jpg"; - const completion = await openai.chat.completions.create({ model: "gpt-4o", messages: [ @@ -68,7 +69,7 @@ const generateCaption = async () => { ], }); - const caption = completion.choices[0].message.content; + const caption = await completion.choices[0].message.content; return caption; } catch (error) { console.error("Error generating caption:", error); diff --git a/app/src/app/api/minio/route.ts b/app/src/app/api/minio/route.ts index 0daaff1..f2f04b2 100644 --- a/app/src/app/api/minio/route.ts +++ b/app/src/app/api/minio/route.ts @@ -54,7 +54,7 @@ export async function POST(req: NextRequest) { }; try { - const response = await minioClient.putObject( + await minioClient.putObject( BUCKET_NAME, image.originalFilename, fs.createReadStream(image.filepath), @@ -69,7 +69,7 @@ export async function POST(req: NextRequest) { } }); - return new Response(JSON.stringify({ message: '成功' }), { + return new Response(JSON.stringify({ message: "成功" }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); diff --git a/app/src/app/api/score/assignment/[id]/route.ts b/app/src/app/api/score/assignment/[id]/route.ts index a04137a..2567f02 100644 --- a/app/src/app/api/score/assignment/[id]/route.ts +++ b/app/src/app/api/score/assignment/[id]/route.ts @@ -26,11 +26,32 @@ export async function GET(req: NextRequest) { include: { user: true, assignment: true }, }); + if (!scores[0].assignment){ + return new Response(JSON.stringify({ message: "Not Found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + const word: Word | null = await prisma.word.findFirst({ where: { id: scores[0].assignment.wordId }, }); + + const scoreDetails: ScoreDetail[] = scores.map((score) => { + if(!score.assignment){ + return { + id: 0, + assignment: "", + answerIntervalTime: 0, + userName: "", + imageUrl: "", + point: 0, + similarity: 0, + }; + } + const answerIntervalTimeMilliseconds = score.answerTime.getTime() - score.assignment.date.getTime(); const answerIntervalTimeSeconds = answerIntervalTimeMilliseconds / 1000; diff --git a/app/src/app/api/score/route.ts b/app/src/app/api/score/route.ts index 361fe2e..68c747c 100644 --- a/app/src/app/api/score/route.ts +++ b/app/src/app/api/score/route.ts @@ -3,51 +3,60 @@ import { prisma } from "@lib/prisma"; import type { NextRequest } from "next/server"; const assignmentDate = async function GET() { - const assignment = await prisma.assignment.findFirst({ - select: { date: true }, - }); - const date = assignment?.date ?? null; - - return new Response(JSON.stringify({ date }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + const assignment = await prisma.assignment.findFirst({ + select: { date: true }, + }); + const date = assignment?.date ?? null; + + return new Response(JSON.stringify({ date }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); }; export async function POST(req: NextRequest) { - const reader = req.body?.getReader(); - if (!reader) { - return new Response("リクエストボディが存在しません", { status: 400 }); - } - const { value } = await reader.read(); - const scoreData = new TextDecoder().decode(value); - const score = JSON.parse(scoreData); - // 最大評価一分、最低評価60分 + const body = await req.json(); + + // scoreDataを解構して必要なプロパティを取得 + const { similarity, answerTime, imageUrl, assignmentId, userId } = body.scoreData; + + // 必須データの存在確認 + if ( + similarity == null || + answerTime == null || + imageUrl == null || + assignmentId == null || + userId == null + ) { + return new Response("必要なデータが不足しています", { status: 400 }); + } + + // ポイントの計算 const minTime = 60; const maxTime = 3600; - // assignment.dateをget + const response = await assignmentDate(); const assignmentDateValue = await response.json(); - // ポイントの計算 - const answerIntervalTime = score.answerTime - assignmentDateValue || 0; + + const answerIntervalTime = + new Date(answerTime).getTime() - new Date(assignmentDateValue.date).getTime(); const normalizedTime = Math.max( 0, - Math.min(1, (answerIntervalTime - minTime) / (maxTime - minTime)), + Math.min(1, (answerIntervalTime / 1000 - minTime) / (maxTime - minTime)), ); - const point = score.similarity * 70 + (1 - normalizedTime) * 30; + const point = similarity * 70 + (1 - normalizedTime) * 30; - const registerScore: Score = await prisma.score.create({ + const score: Score = await prisma.score.create({ data: { - id: score.id, point: point, - similarity: score.similarity, - imageUrl: score.imageUrl, - assignmentId: score.assignmentId, - userId: score.userId, + similarity: similarity, + imageUrl: imageUrl, + assignmentId: assignmentId, + userId: userId, }, }); - return new Response(JSON.stringify({ registerScore }), { + return new Response(JSON.stringify({ score }), { status: 200, headers: { "Content-Type": "application/json" }, }); diff --git a/app/src/app/api/user/route.ts b/app/src/app/api/user/route.ts index 6d31b09..dc3f1b2 100644 --- a/app/src/app/api/user/route.ts +++ b/app/src/app/api/user/route.ts @@ -1,5 +1,5 @@ -import type { NextRequest } from 'next/server'; import { prisma } from "@lib/prisma"; +import type { NextRequest } from "next/server"; type ResponseData = { message: string; @@ -23,7 +23,7 @@ export async function GET(req: NextRequest) { }); } - return new Response(JSON.stringify({ message: "すでに登録されています!" }), { + return new Response(JSON.stringify(user), { status: 200, headers: { "Content-Type": "application/json" }, }); diff --git a/app/src/app/camera/page.tsx b/app/src/app/camera/page.tsx index ba9b0f2..cacd50d 100644 --- a/app/src/app/camera/page.tsx +++ b/app/src/app/camera/page.tsx @@ -13,12 +13,16 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { shapeCaption } from "@/functions/shapeCaption"; +import { postSimilarity } from "@/functions/simirality"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import { Camera, type CameraType } from "react-camera-pro"; import AddImageIcon from "../../../public/icons/icon-add-image.svg"; import RotateCameraIcon from "../../../public/icons/icon-rotate-camera.svg"; import ShutterIcon from "../../../public/icons/icon-shutter.svg"; +import { toast } from "sonner" +import { Toaster } from "@/components/ui/sonner" interface ImagePreviewProps { image: string | null; @@ -30,6 +34,16 @@ interface UploadResponse { success: boolean; } +interface ScoreData { + similarity: number; + answerTime: Date; + imageUrl: string; + assignmentId: number; + userId: number; +} + +const BUCKET_NAME = 'kz2404'; + const ImagePreview = ({ image, onClick }: ImagePreviewProps) => (
{ const [tempImage, setTempImage] = useState(null); const camera = useRef(null); const [devices, setDevices] = useState([]); - const [activeDeviceId, setActiveDeviceId] = useState( - undefined, - ); + const [activeDeviceId, setActiveDeviceId] = useState(undefined); const [currentDeviceIndex, setCurrentDeviceIndex] = useState(0); useEffect(() => { @@ -97,9 +109,7 @@ const CameraApp = () => { try { const devices = await navigator.mediaDevices.enumerateDevices(); - const videoDevices = devices.filter( - (device) => device.kind === "videoinput", - ); + const videoDevices = devices.filter((device) => device.kind === "videoinput"); setDevices(videoDevices); if (videoDevices.length > 0) { setActiveDeviceId(videoDevices[0].deviceId); @@ -120,26 +130,28 @@ const CameraApp = () => { } }; - const uploadImage = async (imageData: string): Promise => { + const uploadImage = async ( + imageData: string, + ): Promise<{ imageName: string; data: UploadResponse }> => { setIsUploading(true); try { const base64Response = await fetch(imageData); const blob = await base64Response.blob(); // 拡張子取得 - const Extension = blob.type.split('/')[1]; + const Extension = blob.type.split("/")[1]; // 日付取得 const date = new Date(); const thisMonth = date.getMonth() + 1; - const month = thisMonth < 10 ? '0' + thisMonth : thisMonth; - const day = date.getDate() < 10 ? '0' + date.getDate() : date.getDate(); + const month = thisMonth < 10 ? "0" + thisMonth : thisMonth; + const day = date.getDate() < 10 ? "0" + date.getDate() : date.getDate(); const formattedDate = `${date.getFullYear()}${month}${day}`; // ランダム文字列を生成する関数 const generateRandomString = (charCount = 7): string => { - const str = Math.random().toString(36).substring(2).slice(-charCount); - return str.length < charCount ? str + 'a'.repeat(charCount - str.length) : str; + const str = Math.random().toString(36).substring(2).slice(-charCount); + return str.length < charCount ? str + "a".repeat(charCount - str.length) : str; }; const randomStr = generateRandomString(); @@ -155,24 +167,118 @@ const CameraApp = () => { }); const data = await response.json(); - return data; + + return { imageName, data }; } catch (error) { console.error("画像のアップロードに失敗しました:", error); throw error; - } finally { - setIsUploading(false); } }; + const getCaption = async ( + imageName: string, + ): Promise<{ caption: string }> => { + try { + const response = await fetch(`/api/image?imageName=${imageName}`); + if (!response.ok) { + throw new Error("キャプションの取得に失敗しました"); + } + + return await response.json(); + } + catch (error) { + console.error("キャプションの取得に失敗しました:", error); + throw error; + } + }; + + // スコア計算を行います。 + const similarityRequest = async (caption: string) => { + const words: string[] = shapeCaption(caption); + + const response = await fetch("/api/assignment/latest"); + if (!response.ok) { + throw new Error("データ取得に失敗しました"); + } + + const assignmentData = await response.json(); + const assignmentWord: string = assignmentData.english; + const resSimilarity = await postSimilarity(assignmentWord, words); + return { + similarity: resSimilarity.similarity as number, + assignmentId: assignmentData.assignmentId as number, + }; + }; + + // userIdの取得 + const getUserId = async () => { + const userString = localStorage.getItem("userID"); + if(userString === null) { + return null; + } + const userData = JSON.parse(userString); + + const resUserId = await fetch("/api/user?uid=" + userData.uid, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + return await resUserId.json(); + }; + + // scoreの送信 + const submitScore = async (scoreData: ScoreData) => { + const response = await fetch("/api/score", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ scoreData }), + }); + + if (!response.ok) { + console.error("スコアの送信に失敗しました", response.statusText); + return; + } + + return await response.json(); + }; + const handleConfirm = async () => { if (tempImage) { try { - await uploadImage(tempImage); + const { imageName } = await uploadImage(tempImage); + const imageURL = `${process.env.NEXT_PUBLIC_MINIO_ENDPOINT}${BUCKET_NAME}/${imageName}`; + + const res = await getCaption(imageName); + const caption = res.caption; + setShowConfirmDialog(false); setImage(tempImage); setShowImage(true); setTempImage(null); + + const { similarity, assignmentId } = await similarityRequest(caption); + + const user = await getUserId(); + const userId: number = user.id; + + const scoreData: ScoreData = { + similarity: similarity, + answerTime: new Date(), + imageUrl: imageURL, + assignmentId: assignmentId, + userId: userId, + }; + const response = await submitScore(scoreData); + const score = response.score; + const percentSimilarity = Math.floor(similarity * 100); + const message = `${caption} 類似度 ${percentSimilarity}% スコア: ${score.point} ランキングから順位を確認しましょう!`; + setIsUploading(false); + toast(message) } catch (error) { + setIsUploading(false); console.error("アップロード中にエラーが発生しました:", error); } } @@ -185,9 +291,7 @@ const CameraApp = () => { const handleImageCapture = (capturedImage: string | ImageData) => { const imageStr = - capturedImage instanceof ImageData - ? imageDataToBase64(capturedImage) - : capturedImage; + capturedImage instanceof ImageData ? imageDataToBase64(capturedImage) : capturedImage; setTempImage(imageStr); setShowConfirmDialog(true); @@ -263,9 +367,7 @@ const CameraApp = () => { - - 画像のアップロード確認 - + 画像のアップロード確認 この画像をアップロードしてもよろしいですか? @@ -281,6 +383,7 @@ const CameraApp = () => { {isUploading && } + ); }; diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 4d1f792..87f279a 100755 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -12,8 +12,8 @@ const notoSansJP = Noto_Sans_JP({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Let`s pics", + description: "Let`s pics", }; export default function RootLayout({ diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index b3bb951..d28f6cf 100755 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -2,7 +2,9 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import type { MyScoreDetail } from "@/types"; +import { Progress } from "@/components/ui/progress"; +import Timer from "@/components/view/Timer"; +import type { MyScoreDetail, todayAssignment } from "@/types"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import ClockIcon from "../../public/icons/icon-clock.svg"; @@ -10,6 +12,9 @@ import PhotoCameraIcon from "../../public/icons/icon-photo-camera.svg"; export default function Home() { const [myScore, setMyScore] = useState([]); + const [assignment, setAssignment] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [progressCount, setProgressCount] = useState(0); const router = useRouter(); useEffect(() => { @@ -20,11 +25,13 @@ export default function Home() { } try { + // get api/score/me const userData = JSON.parse(userIdString); const uid = userData.uid; if (!uid) { throw new Error("User ID not found"); } + setProgressCount(33); const response = await fetch(`/api/score/me/${uid}?limit=3`); if (!response.ok) { @@ -32,6 +39,23 @@ export default function Home() { } const data = await response.json(); setMyScore(data); + setProgressCount(66); + + // get api/assignment/today + const resAssignment = await fetch("/api/assignment/today"); + if (!resAssignment.ok) { + throw new Error("データの取得に失敗しました"); + } + const resData: todayAssignment[] = await resAssignment.json(); + if (!resData) { + throw new Error("無効なデータが返されました"); + } + const formattedData = resData.map((item) => ({ + ...item, + assignTime: new Date(item.assignTime), + })); + setAssignment(formattedData); + setIsLoading(false); } catch (error) { console.error("エラーが発生しました:", error); } @@ -41,10 +65,25 @@ export default function Home() { }, []); return ( -
+
+ {isLoading ? ( + +
+ Loading... + +
+
+ ) : ( +
+ {assignment && ( + // fixme [0]番目を参照しているがお題ごとに可変的にする必要あり。 + + )} +
+ )} 今日のお題

撮影してスコアを競おう!

-

Labyrinthine

+

+ {assignment[0]?.english} +

- +

過去のチャレンジ

{myScore.length === 0 ? (
diff --git a/app/src/components/ui/progress.tsx b/app/src/components/ui/progress.tsx new file mode 100644 index 0000000..5731a07 --- /dev/null +++ b/app/src/components/ui/progress.tsx @@ -0,0 +1,25 @@ +"use client"; + +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/app/src/components/ui/sonner.tsx b/app/src/components/ui/sonner.tsx new file mode 100644 index 0000000..452f4d9 --- /dev/null +++ b/app/src/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/app/src/components/view/Timer.tsx b/app/src/components/view/Timer.tsx new file mode 100644 index 0000000..6952ab1 --- /dev/null +++ b/app/src/components/view/Timer.tsx @@ -0,0 +1,57 @@ +import { Card } from "@/components/ui/card"; +import { type FC, useEffect, useState } from "react"; + +interface TimerProps { + assignTime: Date; +} + +const Timer: FC = ({ assignTime }) => { + const [elapsedTime, setElapsedTime] = useState(0); + const startTime = new Date(assignTime).getTime(); + + useEffect(() => { + const interval = setInterval(() => { + const currentTime = new Date(); + const timeElapsed = currentTime.getTime() - startTime; + setElapsedTime(timeElapsed); + }, 1000); + + return () => { + clearInterval(interval); + }; + }, [startTime]); + + // 経過時間を時間、分、秒に変換 + const formatTime = (ms: number) => { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + return { + hh: hours % 24, + mm: minutes % 60, + ss: seconds % 60, + }; + }; + + const { hh, mm, ss } = formatTime(elapsedTime); + + const formattedHours = String(hh).padStart(2, "0"); + const formattedMinutes = String(mm).padStart(2, "0"); + const formattedSeconds = String(ss).padStart(2, "0"); + + return ( + +
+
経過時間
+
+ {formattedHours}: + {formattedMinutes}: + {formattedSeconds} +
+
+
+ ); +}; + +export default Timer; diff --git a/app/src/functions/gpt.ts b/app/src/functions/gpt.ts new file mode 100644 index 0000000..ae46df5 --- /dev/null +++ b/app/src/functions/gpt.ts @@ -0,0 +1,43 @@ + +import { OpenAI } from "openai"; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +// 画像URLからキャプションを生成する関数 +export const generateCaption = async (imageUrl:string) => { + try { + const completion = await openai.chat.completions.create({ + model: "gpt-4o", + messages: [ + { + role: "system", + content: [ + { + type: "text", + text: "A robot that can generate very short captions for images.", + }, + ], + }, + { + role: "user", + content: [ + { + type: "image_url", + image_url: { + url: imageUrl, + detail: "low", + }, + }, + ], + }, + ], + }); + + const caption = await completion.choices[0].message.content; + return caption; + } catch (error) { + console.error("Error generating caption:", error); + } +}; diff --git a/app/src/functions/shapeCaption.ts b/app/src/functions/shapeCaption.ts new file mode 100644 index 0000000..fb03081 --- /dev/null +++ b/app/src/functions/shapeCaption.ts @@ -0,0 +1,12 @@ +// キャプション英文の分割 + +export const shapeCaption = (caption: string) => { + const stopWords = new Set(["a", "is", "the", "in", "and", "of", "to", "for", "on"]); + + // wordの整形,英単語の抽出と不要な単語の削除 + const splitWords = caption.toLowerCase().split(" "); + const filterWords = splitWords.filter((word) => !stopWords.has(word)); + + // note 現在返るのはこれ ["small", "band", "performing", "living", "room", "setting"] + return filterWords; +}; diff --git a/app/src/functions/simirality.ts b/app/src/functions/simirality.ts index 3398518..9d6a3b0 100644 --- a/app/src/functions/simirality.ts +++ b/app/src/functions/simirality.ts @@ -1,19 +1,19 @@ export const postSimilarity = async (assignment: string, words: string[]) => { - // TODO クライアントサイドかサーバーサイドかでURLを変更する - // サーバーサイドの場合は、http://FastAPI:9004/similarity - const response = await fetch('http://localhost:9004/similarity', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ assignmentWord: assignment, words: words }), - }); + // TODO クライアントサイドかサーバーサイドかでURLを変更する + // サーバーサイドの場合は、http://FastAPI:9004/similarity + const response = await fetch("http://localhost:9004/similarity", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ assignmentWord: assignment, words: words }), + }); - if (!response.ok) { - throw new Error('Network response was not ok'); - } + if (!response.ok) { + throw new Error("Network response was not ok"); + } - // { "similarity": 0000] } - const resData = await response.json(); - return resData; -} + // { "similarity": 0000] } + const resData = await response.json(); + return resData; +}; diff --git a/app/src/types/index.tsx b/app/src/types/index.tsx index 97fcbd5..09b3229 100644 --- a/app/src/types/index.tsx +++ b/app/src/types/index.tsx @@ -2,101 +2,108 @@ * @see {@link https://firebase.google.com/docs/reference/js/v8/firebase.User#properties_1}
*/ export type User = { - displayName: string | null; - phoneNumber?: string | null; - email: string | null; - photoURL: string | null; - providerId?: string; - uid: string; + displayName: string | null; + phoneNumber?: string | null; + email: string | null; + photoURL: string | null; + providerId?: string; + uid: string; }; /** * @see {@link https://firebase.google.com/docs/reference/js/v8/firebase.auth.Auth#currentuser}
*/ export type AuthContextState = { - currentUser: User | null | undefined; + currentUser: User | null | undefined; }; export type ReactNodeProps = { - children?: React.ReactNode; + children?: React.ReactNode; }; // prismaの型定義 export type DBUser = { - id: number; - uid: string; - name: string; - email: string; - photoUrl: string; - scores?: Score[]; - createdAt: Date; - updatedAt: Date; + id: number; + uid: string; + name: string; + email: string; + photoUrl: string; + scores?: Score[]; + createdAt: Date; + updatedAt: Date; }; export type Score = { - id: number; - point: number; - answerTime: Date; - similarity: number; - assignmentId: number; - userId: number; - imageUrl: string; - createdAt: Date; - updatedAt: Date; - user?: DBUser; - assignment?: Assignment; + id: number; + point: number; + answerTime: Date; + similarity: number; + assignmentId: number; + userId: number; + imageUrl: string; + createdAt: Date; + updatedAt: Date; + user?: DBUser; + assignment?: Assignment; }; export type Assignment = { - id: number; - wordId: number; - date: Date; - createdAt: Date; - updatedAt: Date; - word?: Word; - scores?: Score[]; + id: number; + wordId: number; + date: Date; + createdAt: Date; + updatedAt: Date; + word?: Word; + scores?: Score[]; }; export type Word = { - id: number; - english: string; - japanese: string; - difficulty: number; - createdAt: Date; - updatedAt: Date; - assignment?: Assignment[]; + id: number; + english: string; + japanese: string; + difficulty: number; + createdAt: Date; + updatedAt: Date; + assignment?: Assignment[]; }; export type ScoreDetail = { - id: number; - assignment: string; - answerIntervalTime: number; - userName: string; - imageUrl: string; - point: number; - similarity: number; + id: number; + assignment: string; + answerIntervalTime: number; + userName: string; + imageUrl: string; + point: number; + similarity: number; }; export type RankingScores = { - id: number; - userName: string; - totalPoint: number; - imageUrl: string; + id: number; + userName: string; + totalPoint: number; + imageUrl: string; }; export type MyScoreDetail = { - id: number; - assignment: string; - answerIntervalTime: number; - userName: string; - imageUrl: string; - point: number; - highestPoint: number; - similarity: number; - answerTime: string; - date: string; - streakDays: number; + id: number; + assignment: string; + answerIntervalTime: number; + userName: string; + imageUrl: string; + point: number; + highestPoint: number; + similarity: number; + answerTime: string; + date: string; + streakDays: number; }; export type todayAssignment = { + assignmentId: number; + english: string; + assignTime: Date; +}; + +export type latestAssignment = { assignmentId: number; english: string; -}; + +} diff --git a/compose.dev.yml b/compose.dev.yml new file mode 100644 index 0000000..31efd93 --- /dev/null +++ b/compose.dev.yml @@ -0,0 +1,40 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: "app" + ports: + - "3000:3000" + - "5555:5555" + volumes: + - ./app:/app + command: sh -c "npm i && npx prisma db push && npx prisma generate && npm run dev" + tty: true + + db: + image: postgres:15 + container_name: "db" + env_file: + - .env + environment: + - TZ=Asia/Tokyo + - POSTGRES_DB=$DB_NAME + - POSTGRES_USER=$DB_USER + - POSTGRES_PASSWORD=$DB_PASS + ports: + - 5432:5432 + volumes: + - db_data:/var/lib/postgresql/data + + similarity_api: + container_name: FastAPI + build: ./simirality + ports: + - "9004:9004" + volumes: + - ./simirality:/simirality + command: uvicorn main:app --reload --host 0.0.0.0 --port 9004 + +volumes: + db_data: diff --git a/compose.yml b/compose.yml index 580ff2b..dd7e2c5 100644 --- a/compose.yml +++ b/compose.yml @@ -9,7 +9,7 @@ services: - "5555:5555" volumes: - ./app:/app - command: sh -c "npm i && npm run dev" + command: sh -c "npm i && npx prisma db push && npx prisma generate && npm run dev" tty: true db: diff --git a/simirality/main.py b/simirality/main.py index cd107fe..22d5cd7 100644 --- a/simirality/main.py +++ b/simirality/main.py @@ -26,7 +26,7 @@ async def root(): class Words(BaseModel): - asignmentWord: str + assignmentWord: str words: List[str] @@ -62,4 +62,4 @@ async def similarity(reqWords: Words): # MEMO こんな感じでPOSTリクエストを送る # curl -X POST "http://localhost:9004/similarity" \ # -H "Content-Type: application/json" \ -# -d '{"asignmentWord": "dog", "words": ["apple", "banana", "cherry"]}' +# -d '{"assignmentWord": "dog", "words": ["apple", "banana", "cherry"]}'