diff --git a/public/assets/google.png b/public/assets/google.png new file mode 100644 index 0000000..5f71049 Binary files /dev/null and b/public/assets/google.png differ diff --git a/src/app/_children-provider.tsx b/src/app/_children-provider.tsx new file mode 100644 index 0000000..d194d02 --- /dev/null +++ b/src/app/_children-provider.tsx @@ -0,0 +1,38 @@ +"use client"; +import { createContext, useContext, useState } from "react"; + +type ChildrenProviderProps = { + isChildren: boolean; + setIsChildren: React.Dispatch>; +}; + +const ChildrenProviderContext = createContext( + {} as ChildrenProviderProps +); + +export const ChildrenProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }: { children: React.ReactNode }) => { + const [isChildren, setIsChildren] = useState(false); + + return ( + + {children} + + ); +}; + +export function useChildren() { + const context = useContext(ChildrenProviderContext); + if (!context) { + throw new Error( + "useChildren must be used within a ChildrenContextProvider" + ); + } + return context; +} diff --git a/src/app/api/hiragana/route.ts b/src/app/api/hiragana/route.ts new file mode 100644 index 0000000..97b421d --- /dev/null +++ b/src/app/api/hiragana/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import axios from "axios"; + +export interface ChildrenModeRequestBody { + request_id?: string; // オプショナルフィールド + sentence: string; // 必須フィールド + output_type: "hiragana" | "katakana"; // 必須フィールドでリテラル型を使用 +} + +export async function POST(request: NextRequest) { + try { + const body: ChildrenModeRequestBody = await request.json(); + const { request_id, sentence, output_type } = body; + + // 必須パラメータのチェック + if (!sentence || !output_type) { + return NextResponse.json( + { error: "sentence と output_type は必須です。" }, + { status: 400 } + ); + } + + const app_id = process.env.GOO_APP_ID; + if (!app_id) { + return NextResponse.json( + { error: "サーバー設定エラー: GOO_APP_IDが設定されていません。" }, + { status: 500 } + ); + } + + // リクエストIDの生成(省略時の形式) + const generated_request_id: string = + request_id || + `labs.goo.ne.jp\t${new Date().toISOString()}\t${Math.floor( + Math.random() * 100000 + )}`; + + const payload = { + app_id, + request_id: generated_request_id, + sentence, + output_type, + }; + + const response = await axios.post( + "https://labs.goo.ne.jp/api/hiragana", + payload, + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + return NextResponse.json(response.data, { status: 200 }); + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "API呼び出し中にエラーが発生しました。" }, + { status: 500 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fd34a0e..4551c52 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import "@/styles/globals.scss"; import "@radix-ui/themes/styles.css"; import { Theme } from "@radix-ui/themes"; import NextAuthProvider from "./_auth"; +import { ChildrenProvider } from "./_children-provider"; export const metadata: Metadata = { title: "OriCube", @@ -18,7 +19,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/src/components/Header/ChildButton/index.tsx b/src/components/Header/ChildButton/index.tsx new file mode 100644 index 0000000..d2a3c1c --- /dev/null +++ b/src/components/Header/ChildButton/index.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useChildren } from "@/app/_children-provider"; +import { Button } from "@radix-ui/themes"; + +const ChildrenModeButton = () => { + const { isChildren, setIsChildren } = useChildren(); + + const toggleChildrenMode = () => { + setIsChildren(!isChildren); + }; + + return ( + + ); +}; + +export default ChildrenModeButton; diff --git a/src/components/Header/GoogleAuth/Login/index.tsx b/src/components/Header/GoogleAuth/Login/index.tsx index 254e4bd..973125e 100644 --- a/src/components/Header/GoogleAuth/Login/index.tsx +++ b/src/components/Header/GoogleAuth/Login/index.tsx @@ -1,5 +1,7 @@ import React from "react"; import { useSession, signIn } from "next-auth/react"; +import { Button } from "@radix-ui/themes"; +import Image from "next/image"; export const Login = () => { const { status } = useSession(); @@ -7,9 +9,20 @@ export const Login = () => { if (status !== "authenticated") { return (
- +
); } diff --git a/src/components/Header/GoogleAuth/Logout/index.tsx b/src/components/Header/GoogleAuth/Logout/index.tsx index 8fffa46..490f0b7 100644 --- a/src/components/Header/GoogleAuth/Logout/index.tsx +++ b/src/components/Header/GoogleAuth/Logout/index.tsx @@ -1,5 +1,7 @@ import React from "react"; import { useSession, signOut } from "next-auth/react"; +import { Button } from "@radix-ui/themes"; +import Image from "next/image"; export const Logout = () => { const { status } = useSession(); @@ -7,7 +9,20 @@ export const Logout = () => { if (status === "authenticated") { return (
- +
); } diff --git a/src/components/Header/HamburgerMenu/index.module.scss b/src/components/Header/HamburgerMenu/index.module.scss new file mode 100644 index 0000000..313077a --- /dev/null +++ b/src/components/Header/HamburgerMenu/index.module.scss @@ -0,0 +1,86 @@ +/* index.module.scss */ + +.mobile_menu_container { + position: relative; + + .hamburger_button { + padding: 0.5rem; + } + + .menu_icon { + /* 必要に応じて追加のスタイル */ + padding-top: 1rem; + padding-left: 0.5rem; + } + + .menu { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 16rem; // 64px * 4 = 256px = 16rem + background-color: #ffffff; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); + transform: translateX(-100%); + transition: transform 0.3s ease-in-out; + z-index: 1100; + + &.open { + transform: translateX(0); + } + + &.closed { + transform: translateX(-100%); + } + + .menu_nav { + padding-top: 1rem; // pt-16 equivalent + } + + .menu_list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; // space-y-2 equivalent + } + + .menu_item { + /* Optional: Additional styles for list items */ + } + + .menu_link { + display: block; + padding: 0.5rem 1rem; // px-4 py-2 equivalent + font-size: 0.875rem; // text-sm equivalent + color: #4b5563; // text-gray-700 equivalent + text-decoration: none; + border-radius: 0.375rem; // Optional: similar to hover:bg-gray-100 + + &:hover { + background-color: #f3f4f6; // hover:bg-gray-100 equivalent + color: #374151; // Optional: Slightly darker on hover + } + } + + .menu_auth { + padding: 1rem; // px-4 py-4 equivalent + /* Optional: Additional styles */ + } + .menu_child_mode { + margin-bottom: 1rem; + } + } + + .menu_overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); // bg-black bg-opacity-50 equivalent + z-index: 30; + cursor: pointer; + } +} diff --git a/src/components/Header/HamburgerMenu/index.tsx b/src/components/Header/HamburgerMenu/index.tsx new file mode 100644 index 0000000..eceee56 --- /dev/null +++ b/src/components/Header/HamburgerMenu/index.tsx @@ -0,0 +1,94 @@ +// MobileMenu.jsx +"use client"; + +import { useState, useEffect, useRef } from "react"; +import Link from "next/link"; +import { RxHamburgerMenu } from "react-icons/rx"; +import { HiMiniXMark } from "react-icons/hi2"; +import { IconButton } from "@/components/ui/IconButton"; +import { GoogleAuthButton } from "../GoogleAuth"; +import ChildrenModeButton from "../ChildButton"; +import styles from "./index.module.scss"; // Sassファイルをインポート +import { ButtonSizeProp } from "@/types/button"; +import { useSession } from "next-auth/react"; + +export default function MobileMenu() { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + const { status } = useSession(); + const toggleMenu = () => setIsOpen(!isOpen); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + return ( +
+
+ +
+
+
+ +
+ +
+ {isOpen && ( + + ); +} diff --git a/src/components/Header/SearchBox/InputField/index.module.scss b/src/components/Header/SearchBox/InputField/index.module.scss new file mode 100644 index 0000000..5a32066 --- /dev/null +++ b/src/components/Header/SearchBox/InputField/index.module.scss @@ -0,0 +1,3 @@ +.text_field { + height: 44px; +} diff --git a/src/components/Header/SearchBox/InputField/index.tsx b/src/components/Header/SearchBox/InputField/index.tsx index 8fc9aed..98c1372 100644 --- a/src/components/Header/SearchBox/InputField/index.tsx +++ b/src/components/Header/SearchBox/InputField/index.tsx @@ -5,6 +5,8 @@ import { ButtonSizeProp } from "@/types/button"; import { IconButton } from "@/components/ui/IconButton"; import { useOrigamiListPage } from "@/app/_provider"; import { Model } from "@/types/model"; +import { useEffect } from "react"; +import styles from "./index.module.scss"; const ZenMaruFont = Zen_Maru_Gothic({ weight: "500", @@ -35,15 +37,9 @@ export const InputField: React.FC<{ origamiData: Model[] }> = ({ setFilteredOrigamiList(newItems); // フィルタリング結果を更新 }; - const handleKeyDown = (key: string) => { - switch (key) { - case "Enter": - handleSearch(searchKeyword); - break; - default: - break; - } - }; + useEffect(() => { + handleSearch(searchKeyword); + }, [searchKeyword]); const resetSearchKeyword = () => { setSearchKeyword(""); @@ -55,20 +51,23 @@ export const InputField: React.FC<{ origamiData: Model[] }> = ({ placeholder="おりがみのなまえを入力してください 例:つる" value={searchKeyword} onChange={(e) => setSearchKeyword(e.target.value)} - onKeyDown={(e) => handleKeyDown(e.key)} - className={ZenMaruFont.className} + className={`${ZenMaruFont.className} ${styles.text_field}`} > - - - - resetSearchKeyword()} - disable={false} - size={ButtonSizeProp.medium} - /> + + {searchKeyword !== "" ? ( + + resetSearchKeyword()} + disable={false} + size={ButtonSizeProp.medium} + /> + + ) : ( + <> + )} ); diff --git a/src/components/Header/SearchBox/Sp/index.tsx b/src/components/Header/SearchBox/Sp/index.tsx index 5614236..4dfb32c 100644 --- a/src/components/Header/SearchBox/Sp/index.tsx +++ b/src/components/Header/SearchBox/Sp/index.tsx @@ -3,6 +3,7 @@ import { HiMagnifyingGlass, HiOutlineArrowLeft } from "react-icons/hi2"; import { useState } from "react"; import style from "./index.module.scss"; import { IconButton } from "@/components/ui/IconButton"; +import { ButtonSizeProp } from "@/types/button"; export const SearchBoxSp: React.FC<{ children?: React.ReactNode }> = ({ children, @@ -43,6 +44,7 @@ export const SearchBoxSp: React.FC<{ children?: React.ReactNode }> = ({ Icon={HiMagnifyingGlass} handleClick={() => handleClick()} disable={false} + size={ButtonSizeProp.medium} />
)} diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index a46c346..87e0a20 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -1,3 +1,4 @@ +"use client"; import { HeaderPresenter } from "./presenter"; import { Model } from "@/types/model"; diff --git a/src/components/Header/presenter.module.scss b/src/components/Header/presenter.module.scss index ffb080a..fc0d08a 100644 --- a/src/components/Header/presenter.module.scss +++ b/src/components/Header/presenter.module.scss @@ -10,11 +10,36 @@ padding-top: 16px; padding-bottom: 16px; box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.1); - @media (max-width: 768px) { + @media (max-width: 767px) { width: 100vw; padding-left: 16px; padding-right: 16px; padding-top: 8px; padding-bottom: 8px; } + z-index: 100; +} + +.logo { + @media (max-width: 767px) { + position: absolute; + left: 50%; + transform: translateX(-50%); + } +} + +.mobile_menu { + @media (min-width: 768px) { + display: none; + } +} + +.google_auth_button { + @media (max-width: 767px) { + display: none; + } +} + +.children_mode_button { + margin-right: 24px; } diff --git a/src/components/Header/presenter.tsx b/src/components/Header/presenter.tsx index d284f27..f1a4cb4 100644 --- a/src/components/Header/presenter.tsx +++ b/src/components/Header/presenter.tsx @@ -4,15 +4,24 @@ import Image from "next/image"; import style from "./presenter.module.scss"; import { SearchBoxPresenter } from "./SearchBox"; import { GoogleAuthButton } from "./GoogleAuth"; -import { HeaderProps } from "./index"; +import { Model } from "@/types/model"; +import MobileMenu from "./HamburgerMenu"; +import ChildrenModeButton from "./ChildButton"; -export const HeaderPresenter: React.FC = ({ +export type HeaderPresenterProps = + | { enableSearch: true; origamiData: Model[] } // enableSearch が true の場合 + | { enableSearch: false; origamiData?: undefined }; // enableSearch が false の場合 + +export const HeaderPresenter: React.FC = ({ enableSearch, origamiData, -}: HeaderProps) => ( +}: HeaderPresenterProps) => (
- +
+ +
+ ロゴ:OriCube = ({ /> - {enableSearch ? ( - - + + + {enableSearch ? ( + + + + ) : ( +
+ +
+ )} + + - ) : ( - <> - )} - -
diff --git a/src/components/OrigamiDetail/ControlPanel/LoopButton/index.module.scss b/src/components/OrigamiDetail/ControlPanel/LoopButton/index.module.scss new file mode 100644 index 0000000..1a67357 --- /dev/null +++ b/src/components/OrigamiDetail/ControlPanel/LoopButton/index.module.scss @@ -0,0 +1,29 @@ +@use "@/styles/variables" as *; + +.button { + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + transition: background-color 0.2s; + width: 44px; + height: 44px; + background-color: white; + flex: none; + + &:hover { + background-color: $hover-color; + } +} + +.button_active { + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + transition: background-color 0.2s; + width: 44px; + height: 44px; + background-color: #1109ad; + flex: none; +} diff --git a/src/components/OrigamiDetail/ControlPanel/LoopButton/index.tsx b/src/components/OrigamiDetail/ControlPanel/LoopButton/index.tsx new file mode 100644 index 0000000..a0bc401 --- /dev/null +++ b/src/components/OrigamiDetail/ControlPanel/LoopButton/index.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { IconType } from "react-icons"; +import styles from "./index.module.scss"; + +interface Props { + handleClick: () => void; + Icon: IconType; + color?: string; + active: boolean; +} + +export const LoopButton: React.FC = ({ + handleClick, + color = "#000", + Icon, + active, +}) => { + return ( + + ); +}; diff --git a/src/components/OrigamiDetail/ControlPanel/Pagination/index.module.scss b/src/components/OrigamiDetail/ControlPanel/Pagination/index.module.scss index 6bcbeae..188e735 100644 --- a/src/components/OrigamiDetail/ControlPanel/Pagination/index.module.scss +++ b/src/components/OrigamiDetail/ControlPanel/Pagination/index.module.scss @@ -59,6 +59,34 @@ margin-right: 8px; } +.icon_2 { + display: flex; + justify-content: center; + /* 水平方向中央 */ + align-items: center; + /* 垂直方向中央 */ + width: auto; + height: 20px; + padding-left: 48px; + padding-right: 48px; + margin-left: 8px; + margin-right: 8px; +} + +.icon_3 { + display: flex; + justify-content: center; + /* 水平方向中央 */ + align-items: center; + /* 垂直方向中央 */ + width: auto; + height: 20px; + padding-left: 78px; + padding-right: 78px; + margin-left: 8px; + margin-right: 8px; +} + .icon_disable { display: flex; justify-content: center; diff --git a/src/components/OrigamiDetail/ControlPanel/Pagination/index.tsx b/src/components/OrigamiDetail/ControlPanel/Pagination/index.tsx index b7fe7c4..aa6d8b5 100644 --- a/src/components/OrigamiDetail/ControlPanel/Pagination/index.tsx +++ b/src/components/OrigamiDetail/ControlPanel/Pagination/index.tsx @@ -51,7 +51,7 @@ export const Pagination = ({ ); }; const PaginationDots = () => { - return ; + return ; }; const PaginationNum = (i: number) => { return ( @@ -65,6 +65,15 @@ export const Pagination = ({ ); }; + + const PaginationDots_2 = () => { + return ; + }; + + const PaginationDots_3 = () => { + return ; + }; + const numList = []; if (count <= limit) { for (let i = 1; i <= count; i++) { @@ -76,6 +85,20 @@ export const Pagination = ({ numList.push(3); numList.push("..."); numList.push(count); + } else if (currentPage == 3) { + numList.push(1); + numList.push(2); + numList.push(3); + numList.push(4); + numList.push(".."); + numList.push(count); + } else if (currentPage == count - 2) { + numList.push(1); + numList.push(".."); + numList.push(count - 3); + numList.push(count - 2); + numList.push(count - 1); + numList.push(count); } else if (currentPage == count - 1 || currentPage == count) { numList.push(1); numList.push("..."); @@ -83,11 +106,13 @@ export const Pagination = ({ numList.push(count - 1); numList.push(count); } else { - numList.push("..."); + numList.push(1); + numList.push("."); numList.push(currentPage - 1); numList.push(currentPage); numList.push(currentPage + 1); - numList.push("..."); + numList.push("."); + numList.push(count); } return (
@@ -95,8 +120,12 @@ export const Pagination = ({ {numList.map((i, index) => { if (typeof i === "number") { return
{PaginationNum(i)}
; - } else { + } else if (i === ".") { return
{PaginationDots()}
; + } else if (i === "..") { + return
{PaginationDots_2()}
; + } else if (i === "...") { + return
{PaginationDots_3()}
; } })} {PaginationNext()} diff --git a/src/components/OrigamiDetail/ControlPanel/PlayButton/index.module.scss b/src/components/OrigamiDetail/ControlPanel/PlayButton/index.module.scss new file mode 100644 index 0000000..384ae2e --- /dev/null +++ b/src/components/OrigamiDetail/ControlPanel/PlayButton/index.module.scss @@ -0,0 +1,103 @@ +/* IconButton.module.css */ +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} +.button { + position: relative; + border-radius: 50%; + width: 44px; + height: 44px; + border: none; + background: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + flex: none; +} + +.circle { + position: relative; + width: 44px; + height: 44px; + background: #1109ad; + border-radius: 50%; + text-align: center; + overflow: hidden; + z-index: 1; +} + +.circle::before { + content: ""; + display: block; + position: absolute; + top: 0; + left: -22px; + width: 44px; + height: 44px; + background: #dbdbdb; + transform-origin: right 22px; + z-index: 2; + animation: rotate-circle-left 2.5s linear forwards; +} + +.circle::after { + content: ""; + display: block; + position: absolute; + top: 0px; + left: 22px; + width: 44px; + height: 44px; + background: #dbdbdb; + transform-origin: left 22px; + z-index: 3; + animation: rotate-circle-right 2.5s linear forwards; +} + +.circle_inner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 35px; + height: 35px; + background: #fff; + border-radius: 50%; + z-index: 4; + @include flex-center; /* Flexboxで中央配置 */ +} + +@keyframes rotate-circle-right { + 0% { + transform: rotate(0deg); + background: #dbdbdb; + } + 50% { + transform: rotate(180deg); + background: #dbdbdb; + } + 50.01% { + transform: rotate(360deg); + background: #1109ad; + } + 100% { + transform: rotate(360deg); + background: #1109ad; + } +} + +@keyframes rotate-circle-left { + 0% { + transform: rotate(0deg); + } + 50% { + transform: rotate(0deg); + } + 100% { + transform: rotate(180deg); + } +} diff --git a/src/components/OrigamiDetail/ControlPanel/PlayButton/index.tsx b/src/components/OrigamiDetail/ControlPanel/PlayButton/index.tsx new file mode 100644 index 0000000..6a4a716 --- /dev/null +++ b/src/components/OrigamiDetail/ControlPanel/PlayButton/index.tsx @@ -0,0 +1,34 @@ +// PlayButton.tsx + +import React from "react"; +import styles from "./index.module.scss"; // SASS ファイルをインポート +import { HiMiniPlay } from "react-icons/hi2"; +interface Props { + handleClick: () => void; + color?: string; + Icon: React.ComponentType<{ size: number; color: string }>; + disable?: boolean; + isLoopStandby: boolean; +} + +export const PlayButton: React.FC = ({ + handleClick, + color = "#000", + Icon, + disable = false, + isLoopStandby, +}) => { + return ( + + ); +}; diff --git a/src/components/OrigamiDetail/ControlPanel/hooks.ts b/src/components/OrigamiDetail/ControlPanel/hooks.ts new file mode 100644 index 0000000..457d079 --- /dev/null +++ b/src/components/OrigamiDetail/ControlPanel/hooks.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef } from "react"; +import { ControlPanelProps } from "."; +import React from "react"; + +export function useOnSliderMax( + props: ControlPanelProps, + isLoop: boolean, + setIsPlaying: React.Dispatch>, + intervalId: NodeJS.Timeout, + isPlaying: boolean, + isLoopStandby: boolean, + setIsLoopStandby: React.Dispatch> +) { + const timeoutRef = useRef(null); + + useEffect(() => { + if (props.value >= props.maxArg) { + if (!isLoop) { + // Loop が無効の場合、スライダーを停止 + pauseSlider(intervalId); + setIsPlaying(false); + } else { + // Loop が有効の場合、3秒後にスライダーをリセット + setIsLoopStandby(true); + timeoutRef.current = setTimeout(() => { + props.setSliderValue(0); + setIsLoopStandby(false); + }, 2500); // 3000ミリ秒 = 3秒 + } + } + + // クリーンアップ関数: タイムアウトをクリア + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + setIsLoopStandby(false); + } + }; + }, [props.value, isLoop, intervalId, setIsPlaying, props]); + + useEffect(() => { + // isPlaying が false に変更された場合、タイムアウトをクリア + if (!isPlaying && timeoutRef.current && !isLoopStandby) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + props.setSliderValue(0); + } else if (!isPlaying && timeoutRef.current && isLoopStandby) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + setIsLoopStandby(false); + } + }, [isPlaying]); +} + +export function playSlider(props: ControlPanelProps, duration: number) { + const interval = 10; // 10ミリ秒ごとに更新 + const increment = props.maxArg / (duration / interval); + + const intervalId = setInterval(() => { + props.setSliderValue((prevProgress) => { + const newProgress = prevProgress + increment; + if (newProgress >= props.maxArg) { + return props.maxArg; + } + return newProgress; + }); + }, interval); + return intervalId; +} + +export function pauseSlider(intervalId: NodeJS.Timeout) { + if (intervalId) { + clearInterval(intervalId); + } +} diff --git a/src/components/OrigamiDetail/ControlPanel/index.tsx b/src/components/OrigamiDetail/ControlPanel/index.tsx index b04b0a9..edb76d0 100644 --- a/src/components/OrigamiDetail/ControlPanel/index.tsx +++ b/src/components/OrigamiDetail/ControlPanel/index.tsx @@ -2,21 +2,25 @@ import { useState, useEffect } from "react"; import { ControlPanelPresenter } from "./presenter"; +import { useOnSliderMax, pauseSlider, playSlider } from "./hooks"; -interface ControlPanelProps { - stepNum: number; - value: number; +export interface ControlPanelProps { + stepNum: number; //一度に表示する個数 + value: number; //プログレスバーのパーセント setSliderValue: React.Dispatch>; - maxArg: number; - procedureIndex: number; + maxArg: number; //プログレスバーの最大値 + procedureIndex: number; //現在のステップ数 setProcedureIndex: React.Dispatch>; - procedureLength: number; + procedureLength: number; //ステップ数の総数 } export const ControlPanel: React.FC = ( props: ControlPanelProps ) => { const [isPlaying, setIsPlaying] = useState(true); + const [isLoop, setIsLoop] = useState(false); + const [isLoopStandby, setIsLoopStandby] = useState(false); + const [intervalId, setIntervalId] = useState(); const duration = 2500; // 再生時間 const sliderValueChanged = (value: number) => { @@ -24,72 +28,50 @@ export const ControlPanel: React.FC = ( }; const switchPlaying = () => { - if (isPlaying) { + if (isPlaying && !isLoopStandby) { setIsPlaying(false); - } else { + pauseSlider(intervalId!); + } else if (!isLoopStandby && !isPlaying) { if (props.value >= props.maxArg) { props.setSliderValue(0); } setIsPlaying(true); + const newIntervalId = playSlider(props, duration); + setIntervalId(newIntervalId); + } else if (isLoopStandby) { + props.setSliderValue(0); } }; - // Slide barの再生・停止を制御 - useEffect(() => { - let intervalId: NodeJS.Timeout; - if (isPlaying) { - const interval = 10; // 10ミリ秒ごとに更新 - const increment = props.maxArg / (duration / interval); - intervalId = setInterval(() => { - props.setSliderValue((prevProgress) => { - if (prevProgress + increment >= props.maxArg) { - clearInterval(intervalId); - setTimeout(() => { - setIsPlaying(false); - }, 100); - return props.maxArg; - } - return prevProgress + increment; - }); - }, interval); - } - return () => clearInterval(intervalId); // クリーンアップ - }, [isPlaying]); - - function checkIsPlay(resetSlider: NodeJS.Timeout) { - const timerId = setTimeout(() => { - clearInterval(intervalId); // setIntervalの停止 - }, 2000); - // ここで定期的な処理を開始する(例えば、毎100ミリ秒で実行) - const intervalId = setInterval(() => { - setIsPlaying((isPlaying) => { - if (isPlaying) { - clearInterval(resetSlider); - } - return isPlaying; - }); - }, 100); - void timerId; - void intervalId; - } - - // ループ時に、再生ボタンを押してもバグが発生しないように、ループ開始・終了をstateに保存 - useEffect(() => { - if (props.value >= props.maxArg) { - const resetSlider = setTimeout(() => { - props.setSliderValue(0); - setIsPlaying(true); - }, 2000); - checkIsPlay(resetSlider); - void resetSlider; - } - }, [props.value]); + useOnSliderMax( + props, + isLoop, + setIsPlaying, + intervalId!, + isPlaying, + isLoopStandby, + setIsLoopStandby + ); // 折り方のindexが切り替わったときに、自動再生する useEffect(() => { + if (intervalId) { + pauseSlider(intervalId); + } setIsPlaying(true); + const newIntervalId = playSlider(props, duration); + setIntervalId(newIntervalId); + return () => { + pauseSlider(newIntervalId); + }; }, [props.procedureIndex]); + const onLoopClick = () => { + setIsLoop((prevLoop) => { + return !prevLoop; + }); + }; + return ( = ( procedureIndex={props.procedureIndex} setProcedureIndex={props.setProcedureIndex} procedureLength={props.procedureLength} + isLoop={isLoop} + onLoopClick={onLoopClick} + isLoopStandby={isLoopStandby} /> ); }; diff --git a/src/components/OrigamiDetail/ControlPanel/presenter.module.scss b/src/components/OrigamiDetail/ControlPanel/presenter.module.scss index a1daba4..62e1cc2 100644 --- a/src/components/OrigamiDetail/ControlPanel/presenter.module.scss +++ b/src/components/OrigamiDetail/ControlPanel/presenter.module.scss @@ -5,6 +5,7 @@ height: 120px; box-shadow: 0px 10px 10px -5px rgba(0, 0, 0, 0.1); border-radius: 16px; + border: 1px solid #aeaeae; @media (max-width: 767px) { display: none; } @@ -30,6 +31,10 @@ .control_panel_sp { min-width: 320px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; // 必要に応じて調整 @media (min-width: 768px) { display: none; } @@ -37,23 +42,23 @@ .controller_container_sp { margin-top: 16px; + width: 60%; .slider { width: 300px; } - .box { + .controller_sp { display: flex; - justify-content: center; align-items: center; + justify-content: center; // または center + height: 46px; + gap: 20px; } - - .controller_sp { - width: 60%; - } - - z-index: 1000; } +.step_num_sp { + font-size: 20px; +} .button_disable { pointer-events: none; } diff --git a/src/components/OrigamiDetail/ControlPanel/presenter.tsx b/src/components/OrigamiDetail/ControlPanel/presenter.tsx index 8fe6635..c5b8b8a 100644 --- a/src/components/OrigamiDetail/ControlPanel/presenter.tsx +++ b/src/components/OrigamiDetail/ControlPanel/presenter.tsx @@ -11,6 +11,9 @@ import { } from "react-icons/hi2"; import { Pagination } from "./Pagination"; import { IconButton } from "../../ui/IconButton"; +import { HiArrowPathRoundedSquare } from "react-icons/hi2"; +import { PlayButton } from "./PlayButton"; +import { LoopButton } from "./LoopButton"; interface ControlPanelPresenterProps { stepNum: number; @@ -22,6 +25,9 @@ interface ControlPanelPresenterProps { procedureIndex: number; setProcedureIndex: React.Dispatch>; procedureLength: number; + isLoop: boolean; + onLoopClick: () => void; + isLoopStandby: boolean; } export const ControlPanelPresenter: React.FC = ( @@ -38,21 +44,13 @@ export const ControlPanelPresenter: React.FC = ( gap="4" className={style.controller} > - {props.isPlaying ? ( - - ) : ( - - )} + { @@ -64,6 +62,12 @@ export const ControlPanelPresenter: React.FC = ( max={props.maxArg} className={style.slider} /> + = ( max={props.maxArg} />
-
- - { - if (props.procedureIndex != 1) { - props.setProcedureIndex((step) => step - 1); - } - }} - Icon={HiOutlineArrowLeft} - color="#000" - disable={false} - /> - {props.isPlaying ? ( - - ) : ( - - )} - { - if (props.procedureIndex != props.procedureLength) { - props.setProcedureIndex((step) => step + 1); - } - }} - Icon={HiOutlineArrowRight} - color="#000" - disable={false} - /> - -
+ + + + + + { + if (props.procedureIndex != 1) { + props.setProcedureIndex((step) => step - 1); + } + }} + Icon={HiOutlineArrowLeft} + color="#000" + disable={false} + /> +
+ {props.procedureIndex}/{props.procedureLength} +
+ { + if (props.procedureIndex != props.procedureLength) { + props.setProcedureIndex((step) => step + 1); + } + }} + Icon={HiOutlineArrowRight} + color="#000" + disable={false} + /> +
diff --git a/src/components/OrigamiDetail/OrigamiTitle/presenter.tsx b/src/components/OrigamiDetail/OrigamiTitle/presenter.tsx index 4cc88f3..24bddc5 100644 --- a/src/components/OrigamiDetail/OrigamiTitle/presenter.tsx +++ b/src/components/OrigamiDetail/OrigamiTitle/presenter.tsx @@ -1,5 +1,9 @@ +"use client"; import style from "./presenter.module.scss"; import { Zen_Maru_Gothic } from "next/font/google"; +import { useEffect, useState } from "react"; +import { SetHiragana } from "@/utils/children-mode"; +import { useChildren } from "@/app/_children-provider"; const ZenMaruFont = Zen_Maru_Gothic({ weight: "700", @@ -14,10 +18,20 @@ interface OrigamiTitlePresenterProps { export const OrigamiTitlePresenter: React.FC = ( props: OrigamiTitlePresenterProps ) => { + const { isChildren } = useChildren(); + const [description, setDescription] = useState(""); + useEffect(() => { + if (isChildren) { + SetHiragana(props.description, setDescription); + } else { + setDescription(props.description); + } + }, [props.description, isChildren]); + return (

{props.title}

-

{props.description}

+

{description}

); }; diff --git a/src/components/OrigamiList/OrigamiListItem/index.module.scss b/src/components/OrigamiList/OrigamiListItem/index.module.scss index dde962a..baa5fb8 100644 --- a/src/components/OrigamiList/OrigamiListItem/index.module.scss +++ b/src/components/OrigamiList/OrigamiListItem/index.module.scss @@ -10,6 +10,7 @@ font-weight: bold; padding-bottom: 1rem; transition: background-color 0.2s; + box-shadow: 0px 10px 10px -5px rgba(0, 0, 0, 0.2); border-radius: 0.5rem; &:hover { diff --git a/src/components/ui/IconButton/index.module.scss b/src/components/ui/IconButton/index.module.scss index a93ab16..f06dad1 100644 --- a/src/components/ui/IconButton/index.module.scss +++ b/src/components/ui/IconButton/index.module.scss @@ -6,7 +6,9 @@ align-items: center; border-radius: 50%; transition: background-color 0.2s; - + width: 44px; + height: 44px; + flex: none; &:hover { background-color: $hover-color; } @@ -19,4 +21,7 @@ border-radius: 50%; transition: background-color 0.2s; color: $hover-color; + width: 44px; + height: 44px; + flex: none; } diff --git a/src/components/ui/IconButton/index.tsx b/src/components/ui/IconButton/index.tsx index 83e6d68..29e8df7 100644 --- a/src/components/ui/IconButton/index.tsx +++ b/src/components/ui/IconButton/index.tsx @@ -15,20 +15,15 @@ export const IconButton: React.FC = ({ handleClick, color = "#000", Icon, - size = ButtonSizeProp.medium, disable = false, }) => { return ( ); }; diff --git a/src/models/4 b/src/models/4 new file mode 100644 index 0000000..18ad578 --- /dev/null +++ b/src/models/4 @@ -0,0 +1,99 @@ +{ + "id": 4, + "name": "よっと_漢字", + "imageUrl": "/origami/yot.png", + "searchKeyword": ["よっと", "ヨット", "yot"], + "color": "#42C6FF", + "procedure": { + "1": { + "description": "斜め半分におる", + "fixBoards": [ + [ + [20, 20, 0], + [20, -20, 0], + [-20, -20, 0] + ] + ], + "moveBoards": [ + [ + [20, 20, 0.001], + [-20, -20, 0.001], + [-20, 20, 0.001] + ] + ], + "rotateAxis": [ + [20, 20, 0.001], + [-20, -20, 0.001] + ] + }, + "2": { + "description": "右側の一枚を折る", + "fixBoards": [ + [ + [20, 20, 0], + [20, -20, 0], + [-20, -20, 0] + ], + [ + [20, 20, 0.001], + [-20, -20, 0.001], + [3.431457505076194, -20, 0.001] + ] + ], + "moveBoards": [ + [ + [20, 20, 0.002], + [3.431457505076194, -20, 0.002], + [20, -20, 0.002] + ] + ], + "rotateAxis": [ + [3.431457505076194, -20, 0.002], + [20, 20, 0.002] + ] + }, + "3": { + "description": "下側の半分を折って完成", + "fixBoards": [ + [ + [20, 20, 0], + [20, -17, 0], + [-8.284271247461902, -8.284271247461902, 0] + ], + [ + [20, 20, 0.001], + [-8.284271247461902, -8.284271247461902, 0.001], + [6.4088566732975, -12.8119225474817, 0.001] + ], + [ + [20, 20, 0.002], + [6.4088566732975, -12.8119225474817, 0.002], + [-8.284271247461902, -8.284271247461902, 0.002] + ] + ], + "moveBoards": [ + [ + [20, -17, 0.001], + [20, -20, 0.001], + [-20, -20, 0.001], + [-8.284271247461902, -8.284271247461902, 0.001] + ], + [ + [6.4088566732975, -12.8119225474817, 0.002], + [-8.284271247461902, -8.284271247461902, 0.002], + [-20, -20, 0.002], + [3.431457505076194, -20, 0.002] + ], + [ + [6.4088566732975, -12.8119225474817, 0.003], + [3.431457505076194, -20, 0.003], + [-8.284271247461902, -8.284271247461902, 0.003] + ] + ], + "rotateAxis": [ + [-8.284271247461902, -8.284271247461902, 0.003], + [6.4088566732975, -12.8119225474817, 0.003] + ] + } + } +} diff --git a/src/utils/children-mode.ts b/src/utils/children-mode.ts new file mode 100644 index 0000000..aa798bb --- /dev/null +++ b/src/utils/children-mode.ts @@ -0,0 +1,20 @@ +import axios from "axios"; + +export function SetHiragana( + sentence: string, + setSentence: React.Dispatch> +) { + const outputType = "hiragana"; + const getHiraganaData = async () => { + try { + const response = await axios.post("/api/hiragana", { + sentence, + output_type: outputType, + }); + setSentence(response.data.converted); + } catch (err) { + console.log(err); + } + }; + getHiraganaData(); +}