diff --git a/.env.example b/.env.example index 9c2499d..36b38c6 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ NEXT_PUBLIC_SERVICE_NAME=janreco NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= +GOOGLE_CLIENT_ID= +GOOGLE_SECRET= +OPENAI_API_KEY= diff --git a/.eslintrc.json b/.eslintrc.json index 2d7caf8..be6e460 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,7 +22,7 @@ ], "pathGroups": [ { - "pattern": "~/**", + "pattern": "@/**", "group": "internal" }, { @@ -42,6 +42,17 @@ { "namedComponents": "function-declaration" } + ], + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "dayjs", + "message": "Please import from '@/lib/utils/date' instead." + } + ] + } ] } } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 205b589..6ac5690 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,6 +7,10 @@ on: jobs: test: runs-on: ubuntu-22.04 + env: + GOOGLE_CLIENT_ID: "dummy" + GOOGLE_SECRET: "dummy" + OPENAI_API_KEY: "dummy" steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/production.yaml b/.github/workflows/production.yaml index a20cd8b..5ca66d4 100644 --- a/.github/workflows/production.yaml +++ b/.github/workflows/production.yaml @@ -14,6 +14,9 @@ jobs: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} SUPABASE_DB_PASSWORD: ${{ secrets.PRODUCTION_DB_PASSWORD }} PRODUCTION_PROJECT_ID: owagpoywxhbsckytdtoj + GOOGLE_CLIENT_ID: "dummy" + GOOGLE_SECRET: "dummy" + OPENAI_API_KEY: "dummy" steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/staging.yaml b/.github/workflows/staging.yaml index eef438b..bbe5ea8 100644 --- a/.github/workflows/staging.yaml +++ b/.github/workflows/staging.yaml @@ -14,6 +14,9 @@ jobs: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} SUPABASE_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }} STAGING_PROJECT_ID: ggkmppnjhrwzdsamzqbp + GOOGLE_CLIENT_ID: "dummy" + GOOGLE_SECRET: "dummy" + OPENAI_API_KEY: "dummy" steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index 350f1b5..d487f74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules .next .npmrc -.env.local -.vscode +.env* tsconfig.tsbuildinfo diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bb35a4a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "cSpell.words": [ + "fkey", + "janreco", + "nextjs", + "nextui", + "Noto", + "OPENAI", + "Postgrest", + "svgr", + "toastify" + ] +} diff --git a/README.md b/README.md index e7e7732..db8deb3 100644 --- a/README.md +++ b/README.md @@ -70,68 +70,15 @@ supabase studio url: http://localhost:54323 - [supabase](https://supabase.com/docs) -# Sequence - -## Client Component - -```mermaid -sequenceDiagram - box Client - participant CC as Client Component - participant H as Hooks - end - box Next Server - participant AR as Api Routes - participant SC as Server Component - participant SV as Service - participant MD as Model - participant RP as Repository - end - box Supabase - participant SB as Supabase - end - - %% server component - SC->>SV: - SV->>RP: - RP->>SB: - SB->>RP: - RP->>SV: - SV->>MD: - MD->>SV: - SV->>SC: -``` -## Server Component - -```mermaid -sequenceDiagram - box Client - participant CC as Client Component - participant H as Hooks - end - box Next Server - participant AR as Api Routes - participant SC as Server Component - participant SV as Service - participant MD as Model - participant RP as Repository - end - box Supabase - participant SB as Supabase - end - - %% server component - CC->>H: - H->>AR: - AR->>SV: - SV->>RP: - RP->>SB: - SB->>RP: - RP->>SV: - SV->>MD: - MD->>SV: - SV->>AR: - AR->>H: - H->>CC: -``` +# Rules + +## Words + +| code | 意味 | +| ---- | ---- | +| user | ログインしているユーザー | +| match | 成績表 | +| game | 半荘 | +| score | ポイント | +| points | 点数| diff --git a/app/(auth)/(routes)/auth-code-error/page.tsx b/app/(auth)/(routes)/auth-code-error/page.tsx new file mode 100644 index 0000000..72e7da9 --- /dev/null +++ b/app/(auth)/(routes)/auth-code-error/page.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; +import { Button } from "@/components/Button"; + +/** + * @see https://supabase.com/docs/guides/auth/server-side/oauth-with-pkce-flow-for-ssr + */ +export default function AuthCodeErrorPage() { + return ( +
+

ログインに失敗しました

+ +
+ ); +} diff --git a/app/(auth)/(routes)/login/(components)/LoginForm/actions.ts b/app/(auth)/(routes)/login/(components)/LoginForm/actions.ts new file mode 100644 index 0000000..2e36496 --- /dev/null +++ b/app/(auth)/(routes)/login/(components)/LoginForm/actions.ts @@ -0,0 +1,84 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { schema } from "@/lib/utils/schema"; +import { createClient } from "@/lib/utils/supabase/server"; +import { getURL } from "@/lib/utils/url"; + +type State = { + errors?: { + base?: string[]; + email?: string[]; + password?: string[]; + }; +}; + +const signInEmailSchema = z.object({ + email: schema.email, + password: schema.password, +}); + +/** + * @see https://supabase.com/docs/guides/auth/server-side/nextjs + */ +export async function signInEmail( + prevState: State, + formData: FormData, +): Promise { + const validatedFields = signInEmailSchema.safeParse({ + email: formData.get("email"), + password: formData.get("password"), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const { email, password } = validatedFields.data; + + const supabase = createClient(); + + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + return { + errors: { + base: ["メールアドレスまたはパスワードが間違っています。"], + }, + }; + } + + revalidatePath("/", "layout"); + redirect("/"); +} + +/** + * @see https://supabase.com/docs/guides/auth/server-side/oauth-with-pkce-flow-for-ssr?queryGroups=environment&environment=server + */ +export async function signInWithGoogle() { + const supabase = createClient(); + const { data } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: `${getURL()}api/auth/callback`, + }, + }); + + if (data.url) { + redirect(data.url); // use the redirect API for your server framework + } +} + +export async function signInAnonymously() { + const supabase = createClient(); + await supabase.auth.signInAnonymously(); + + redirect("/"); +} diff --git a/app/(auth)/(routes)/login/(components)/LoginForm/index.tsx b/app/(auth)/(routes)/login/(components)/LoginForm/index.tsx new file mode 100644 index 0000000..0267a86 --- /dev/null +++ b/app/(auth)/(routes)/login/(components)/LoginForm/index.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useFormState } from "react-dom"; +import { Button } from "@/components/Button"; +import { Input } from "@/components/Input"; +import { signInEmail } from "./actions"; + +/** + * @see https://supabase.com/docs/guides/auth/server-side/nextjs + */ +export function LoginForm({ className }: { className?: string }) { + const [state, formAction] = useFormState(signInEmail, {}); + + return ( +
+
+ + +
+ {state.errors?.base && ( +

{state.errors.base}

+ )} + +
+ ); +} diff --git a/app/(auth)/(routes)/login/(components)/SocialProviders/index.tsx b/app/(auth)/(routes)/login/(components)/SocialProviders/index.tsx new file mode 100644 index 0000000..b1284de --- /dev/null +++ b/app/(auth)/(routes)/login/(components)/SocialProviders/index.tsx @@ -0,0 +1,33 @@ +"use client"; + +import classNames from "classnames"; +import { Button } from "@/components/Button"; +import { GoogleIcon } from "@/components/SocialProviderIcon"; +import { signInWithGoogle } from "../LoginForm/actions"; + +export function SocialProviders({ className }: { className?: string }) { + return ( + + ); +} diff --git a/app/(auth)/(routes)/login/page.tsx b/app/(auth)/(routes)/login/page.tsx new file mode 100644 index 0000000..22fd4f1 --- /dev/null +++ b/app/(auth)/(routes)/login/page.tsx @@ -0,0 +1,30 @@ +import { Metadata } from "next"; +import Link from "next/link"; +import { Divider } from "@/components/Divider"; +import { LoginForm } from "./(components)/LoginForm"; +import { SocialProviders } from "./(components)/SocialProviders"; + +export const metadata: Metadata = { + title: "ログイン", +}; + +export default function LoginPage() { + return ( + <> +

ログイン

+ +
+ + or + +
+ +

+ アカウントをお持ちでない方は + + 新規登録 + +

+ + ); +} diff --git a/app/(auth)/(routes)/register/(components)/RegisterForm/actions.ts b/app/(auth)/(routes)/register/(components)/RegisterForm/actions.ts new file mode 100644 index 0000000..965a984 --- /dev/null +++ b/app/(auth)/(routes)/register/(components)/RegisterForm/actions.ts @@ -0,0 +1,74 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { serverServices } from "@/lib/services/server"; +import { schema } from "@/lib/utils/schema"; +import { createClient } from "@/lib/utils/supabase/server"; + +type State = { + errors?: { + base?: string[]; + name?: string[]; + janrecoId?: string[]; + }; +}; + +const updateProfileSchema = z.object({ + name: schema.name, + janrecoId: schema.janrecoId, +}); + +export async function updateProfile( + userId: string, + prevState: State, + formData: FormData, +): Promise { + const validatedFields = updateProfileSchema.safeParse({ + name: formData.get("name"), + janrecoId: formData.get("janrecoId"), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const { name, janrecoId } = validatedFields.data; + + const { updateUserProfile } = serverServices(); + + const result = await updateUserProfile({ + name, + janrecoId, + }); + + if (!result.success) { + if (result.error.code === "23505") { + return { + errors: { + janrecoId: ["このIDは既に使用されています。"], + }, + }; + } else { + throw result.error; + } + } + + revalidatePath("/"); + redirect("/"); +} + +export async function signOut() { + const supabase = createClient(); + + await supabase.auth.signOut(); + + /** + * @see https://nextjs.org/docs/app/api-reference/functions/revalidatePath#revalidating-all-data + */ + revalidatePath("/", "layout"); + redirect("/login"); +} diff --git a/app/(auth)/(routes)/register/(components)/RegisterForm/index.tsx b/app/(auth)/(routes)/register/(components)/RegisterForm/index.tsx new file mode 100644 index 0000000..e787292 --- /dev/null +++ b/app/(auth)/(routes)/register/(components)/RegisterForm/index.tsx @@ -0,0 +1,58 @@ +"use client"; + +import classNames from "classnames"; +import { useFormState } from "react-dom"; +import { Button } from "@/components/Button"; +import { Input } from "@/components/Input"; +import { + JANRECO_ID_MAX_LENGTH, + JANRECO_ID_MIN_LENGTH, + NAME_MAX_LENGTH, +} from "@/lib/config"; +import { updateProfile } from "./actions"; + +export function RegisterForm({ + className, + userId, +}: { + className?: string; + userId: string; +}) { + const [state, formAction] = useFormState( + updateProfile.bind(null, userId), + {}, + ); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/app/(auth)/(routes)/register/page.tsx b/app/(auth)/(routes)/register/page.tsx new file mode 100644 index 0000000..8db591d --- /dev/null +++ b/app/(auth)/(routes)/register/page.tsx @@ -0,0 +1,43 @@ +import { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { Button } from "@/components/Button"; +import { serverServices } from "@/lib/services/server"; +import { RegisterForm } from "./(components)/RegisterForm"; +import { signOut } from "./(components)/RegisterForm/actions"; + +export const metadata: Metadata = { + title: "ユーザー情報登録", +}; + +export default async function RegisterPage() { + const { getUserProfile } = serverServices(); + const user = await getUserProfile(); + + if (user.name && user.janrecoId) { + redirect("/"); + } + + return ( + <> +

+ ユーザー情報登録 +

+

+ ユーザーIDと名前を決めてください。 +
+ ユーザーIDはユーザー検索に、名前は成績表に使用されます。 +

+ +
+ +
+ + ); +} diff --git a/app/(auth)/(routes)/sign-up/(components)/SignUpForm/actions.ts b/app/(auth)/(routes)/sign-up/(components)/SignUpForm/actions.ts new file mode 100644 index 0000000..91f13d0 --- /dev/null +++ b/app/(auth)/(routes)/sign-up/(components)/SignUpForm/actions.ts @@ -0,0 +1,59 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { schema } from "@/lib/utils/schema"; +import { createClient } from "@/lib/utils/supabase/server"; + +type State = { + errors?: { + base?: string[]; + email?: string[]; + password?: string[]; + }; +}; + +const signUpSchema = z.object({ + email: schema.email, + password: schema.password, +}); + +/** + * @see https://supabase.com/docs/guides/auth/server-side/nextjs + */ +export async function signUp( + prevState: State, + formData: FormData, +): Promise { + const validatedFields = signUpSchema.safeParse({ + email: formData.get("email"), + password: formData.get("password"), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const { email, password } = validatedFields.data; + + const supabase = createClient(); + + const { error } = await supabase.auth.signUp({ + email, + password, + }); + + if (error) { + return { + errors: { + email: ["このメールアドレスは使用できません。"], + }, + }; + } + + revalidatePath("/", "layout"); + redirect("/register"); +} diff --git a/app/(auth)/(routes)/sign-up/(components)/SignUpForm/index.tsx b/app/(auth)/(routes)/sign-up/(components)/SignUpForm/index.tsx new file mode 100644 index 0000000..8132aa9 --- /dev/null +++ b/app/(auth)/(routes)/sign-up/(components)/SignUpForm/index.tsx @@ -0,0 +1,44 @@ +"use client"; + +import classNames from "classnames"; +import { useFormState } from "react-dom"; +import { Button } from "@/components/Button"; +import { Input } from "@/components/Input"; +import { signUp } from "./actions"; + +/** + * @see https://supabase.com/docs/guides/auth/server-side/nextjs + */ +export function SignUpForm({ className }: { className?: string }) { + const [state, formAction] = useFormState(signUp, {}); + + return ( +
+ + + +
+ ); +} diff --git a/app/(auth)/(routes)/sign-up/page.tsx b/app/(auth)/(routes)/sign-up/page.tsx new file mode 100644 index 0000000..60b7c84 --- /dev/null +++ b/app/(auth)/(routes)/sign-up/page.tsx @@ -0,0 +1,22 @@ +import { Metadata } from "next"; +import Link from "next/link"; +import { SignUpForm } from "./(components)/SignUpForm"; + +export const metadata: Metadata = { + title: "新規登録", +}; + +export default function SignUpPage() { + return ( + <> +

新規登録

+ +

+ 既にアカウントをお持ちの方は + + ログイン + +

+ + ); +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index 3654947..9d06298 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,24 +1,28 @@ import Image from "next/image"; -import MahJong1Image from "~/assets/images/mahjong1.jpeg"; -import Logo from "~/components/Logo"; +import MahJong1Image from "@/assets/images/mahjong1.jpeg"; +import Logo from "@/components/Logo"; -export default function Auth({ children }: { children: React.ReactNode }) { +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { return ( <>
mahjong
-
- +
+ {children} -
+
); diff --git a/app/(auth)/login/layout.tsx b/app/(auth)/login/layout.tsx deleted file mode 100644 index 965149a..0000000 --- a/app/(auth)/login/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Metadata } from "next"; - -export const metadata: Metadata = { - title: "ログイン", -}; - -export default function LoginLayout({ - children, -}: { - children: React.ReactNode; -}) { - return children; -} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx deleted file mode 100644 index deda532..0000000 --- a/app/(auth)/login/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import Link from "next/link"; -import { SubmitHandler, useForm } from "react-hook-form"; -import { Button } from "~/components/Button"; -import { Divider } from "~/components/Divider"; -import { Input } from "~/components/Input"; -import { GoogleIcon } from "~/components/SocialProviderIcon"; -import { - useEmailSignIn, - emailSignInSchema, - EmailSignInSchema, - useGoogleSignIn, -} from "~/lib/hooks/auth"; - -export default function Login() { - const { trigger: emailSignIn } = useEmailSignIn(); - const { trigger: googleSignIn } = useGoogleSignIn(); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(emailSignInSchema), - }); - - const onSubmit: SubmitHandler = async (data) => { - await emailSignIn(data); - }; - - const handleGoogleSignInClick = async () => { - await googleSignIn(); - }; - - return ( - <> -

ログイン

-
    - - - -
-
- - or - -
-
- - - -
-

- アカウントをお持ちでないですか? - - 新規登録 - -

- - ); -} - -function SocialProviderItem({ - children, - label, - onClick, -}: { - children: React.ReactNode; - label: string; - onClick: VoidFunction; -}) { - return ( -
  • - -
  • - ); -} diff --git a/app/(auth)/sign-up/layout.tsx b/app/(auth)/sign-up/layout.tsx deleted file mode 100644 index c0acf05..0000000 --- a/app/(auth)/sign-up/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Metadata } from "next"; - -export const metadata: Metadata = { - title: "新規登録", -}; - -export default function SignUpLayout({ - children, -}: { - children: React.ReactNode; -}) { - return children; -} diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx deleted file mode 100644 index 8cb4745..0000000 --- a/app/(auth)/sign-up/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import Link from "next/link"; -import { SubmitHandler, useForm } from "react-hook-form"; -import { Button } from "~/components/Button"; -import { Input } from "~/components/Input"; -import { - emailSignUpSchema, - EmailSignUpSchema, - useEmailSignUp, -} from "~/lib/hooks/auth"; - -export default function Login() { - const { trigger: emailSignUp } = useEmailSignUp(); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(emailSignUpSchema), - }); - - const onSubmit: SubmitHandler = async (data) => { - await emailSignUp(data); - }; - - return ( - <> -

    新規登録

    -
    - - - -
    -

    - 既にアカウントをお持ちですか? - - ログイン - -

    - - ); -} diff --git a/app/(console)/friends/FriendsList.tsx b/app/(console)/friends/FriendsList.tsx deleted file mode 100644 index 1a552db..0000000 --- a/app/(console)/friends/FriendsList.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import classNames from "classnames"; -import { useCallback } from "react"; -import { Button } from "~/components/Button"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownTrigger, -} from "~/components/Dropdown"; -import { Icon } from "~/components/Icon"; -import { User } from "~/components/User"; -import { useDeleteFriend, useFriends } from "~/lib/hooks/api/friends"; -import { Friend } from "~/lib/services/friends"; - -export default function FriendsList({ - defaultValue, - className, -}: { - defaultValue: Friend[]; - className?: string; -}) { - const { data: friends } = useFriends(defaultValue); - - const { trigger: deleteFriend } = useDeleteFriend(); - - const handleMenuAction = useCallback( - async (key: unknown, friendId: string) => { - if (key === "delete") { - await deleteFriend({ profileId: friendId }); - } - }, - [deleteFriend], - ); - - return ( -
      - {friends?.map((friend) => ( -
    • - - - - - - handleMenuAction(key, friend.id)} - > - 削除 - - -
    • - ))} -
    - ); -} diff --git a/app/(console)/friends/FriendsSearch.tsx b/app/(console)/friends/FriendsSearch.tsx deleted file mode 100644 index 2f3875e..0000000 --- a/app/(console)/friends/FriendsSearch.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { FriendSearchModal } from "~/components/FriendSearchModal"; -import { Icon } from "~/components/Icon"; -import { Input } from "~/components/Input"; -import { useDisclosure } from "~/components/Modal"; - -export function FriendsSearch() { - const friendsSearchModal = useDisclosure(); - - return ( - <> - } - /> - - - ); -} diff --git a/app/(console)/friends/page.tsx b/app/(console)/friends/page.tsx deleted file mode 100644 index 79a0ca7..0000000 --- a/app/(console)/friends/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/serverComponentClient"; -import FriendsList from "./FriendsList"; -import { FriendsSearch } from "./FriendsSearch"; - -export default async function Friends() { - const supabaseClient = createSupabaseClient(); - const { getFriends } = services(supabaseClient); - const friends = await getFriends(); - - return ( -
    -

    フレンド

    - -
    - -
    -
    - ); -} diff --git a/app/(console)/layout.tsx b/app/(console)/layout.tsx deleted file mode 100644 index e6b8613..0000000 --- a/app/(console)/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { redirect } from "next/navigation"; -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/serverComponentClient"; - -export default async function ConsoleLayout({ - children, -}: { - children: React.ReactNode; -}) { - const supabaseClient = createSupabaseClient(); - const { getUserProfile } = services(supabaseClient); - const profile = await getUserProfile(); - if (!profile.janrecoId || !profile.name) { - redirect("/register"); - } - - return
    {children}
    ; -} diff --git a/app/(console)/matches/MatchCreateButton.tsx b/app/(console)/matches/MatchCreateButton.tsx deleted file mode 100644 index d1385e3..0000000 --- a/app/(console)/matches/MatchCreateButton.tsx +++ /dev/null @@ -1,342 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import classNames from "classnames"; -import { useRouter } from "next/navigation"; -import { useCallback } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; -import { Accordion, AccordionItem } from "~/components/Accordion"; -import { Button, ButtonGroup } from "~/components/Button"; -import { Input } from "~/components/Input"; -import { useDisclosure } from "~/components/Modal"; -import { - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, -} from "~/components/Modal"; -import { Select, SelectItem } from "~/components/Select"; -import { - MatchCreateSchema, - matchCreateSchema, - useMatchCreate, -} from "~/lib/hooks/api/match"; -import { calcMethod } from "~/lib/utils/schemas"; - -const playersCount4DefaultValues: Partial = { - playersCount: 4, - /** @see https://github.com/react-hook-form/react-hook-form/issues/8382 */ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - crackBoxBonus: "10000", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - defaultPoints: "25000", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - defaultCalcPoints: "30000", - calcMethod: "round", - incline: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - incline1: "", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - incline2: "", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - incline3: "", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - incline4: "", - }, -}; - -const playersCount3DefaultValues: Partial = { - playersCount: 3, - /** @see https://github.com/react-hook-form/react-hook-form/issues/8382 */ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - crackBoxBonus: "10000", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - defaultPoints: "35000", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - defaultCalcPoints: "40000", - calcMethod: "round", - incline: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - incline1: "", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - incline2: "", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - incline3: "", - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - incline4: "0", - }, -}; - -export function MatchCreateButton({ className }: { className?: string }) { - const router = useRouter(); - const { trigger: createMatch } = useMatchCreate(); - const ruleCreateModal = useDisclosure(); - - const { - handleSubmit, - watch, - formState: { errors, isSubmitting }, - setValue, - register, - clearErrors, - } = useForm({ - resolver: zodResolver(matchCreateSchema), - defaultValues: playersCount4DefaultValues, - }); - - const playersCount = watch("playersCount"); - - const onRuleCreateSubmit: SubmitHandler = useCallback( - async (data) => { - const match = await createMatch(data); - router.push(`/matches/${match.id}`); - }, - [createMatch, router], - ); - - const handlePlayersCount4Click = useCallback(() => { - Object.entries(playersCount4DefaultValues).forEach(([key, value]) => { - setValue(key as keyof MatchCreateSchema, value); - }); - clearErrors(); - }, [clearErrors, setValue]); - - const handlePlayersCount3Click = useCallback(() => { - Object.entries(playersCount3DefaultValues).forEach(([key, value]) => { - setValue(key as keyof MatchCreateSchema, value); - }); - clearErrors(); - }, [clearErrors, setValue]); - - const inclineError = errors.incline; - const inclineErrorMessage = ( - inclineError?.incline1 ?? - inclineError?.incline2 ?? - inclineError?.incline3 ?? - inclineError?.incline4 ?? - inclineError?.root - )?.message; - - return ( - <> - - - - {(onClose) => ( - <> - ルール設定 -
    - - - - - -
    -
    - ウマ -
    -
    -
    - - - - - - - - {playersCount === 4 && -} - -
    - {/* same as Input component error */} -
    - {inclineErrorMessage} -
    -
    -
    - - - - -
    - } - {...register("chipRate")} - errorMessage={errors.chipRate?.message} - /> - - -
    - - - 点 - -
    - } - {...register("crackBoxBonus")} - errorMessage={errors.crackBoxBonus?.message} - /> - - - 点 - -
    - } - {...register("defaultPoints")} - errorMessage={errors.defaultPoints?.message} - /> - - - 点返し - - - } - {...register("defaultCalcPoints")} - errorMessage={errors.defaultCalcPoints?.message} - /> - - - - - - - - - - - - )} - - - - ); -} diff --git a/app/(console)/matches/MatchList.tsx b/app/(console)/matches/MatchList.tsx deleted file mode 100644 index eb8a3de..0000000 --- a/app/(console)/matches/MatchList.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import classNames from "classnames"; -import Link from "next/link"; -import { Card, CardHeader } from "~/components/Card"; -import { useMatches } from "~/lib/hooks/api/match"; -import { Matches } from "~/lib/services/matches"; - -export function MatchList({ - initialMatches, - className, -}: { - initialMatches: Matches; - className?: string; -}) { - const { data: matches } = useMatches(initialMatches); - - return ( -
      - {matches?.map((match) => ( -
    • - - - {match.date} - - -
    • - ))} -
    - ); -} diff --git a/app/(console)/matches/[matchId]/GameInputModal.tsx b/app/(console)/matches/[matchId]/GameInputModal.tsx deleted file mode 100644 index 70e3a55..0000000 --- a/app/(console)/matches/[matchId]/GameInputModal.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { DndContext } from "@dnd-kit/core"; -import { SortableContext } from "@dnd-kit/sortable"; -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { zodResolver } from "@hookform/resolvers/zod"; -import classNames from "classnames"; -import { ComponentProps, useCallback } from "react"; -import { SubmitHandler, useFieldArray, useForm } from "react-hook-form"; -import { Button } from "~/components/Button"; -import { Icon } from "~/components/Icon"; -import { Input } from "~/components/Input"; -import { - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, -} from "~/components/Modal"; -import { Popover, PopoverContent, PopoverTrigger } from "~/components/Popover"; -import { Select, SelectItem } from "~/components/Select"; -import { - GameAddSchema, - gameAddSchema, - useGameAdd, -} from "~/lib/hooks/api/games"; -import { useMatch } from "~/lib/hooks/api/match"; -import { Match } from "~/lib/services/match"; - -export function GameInputModal({ - match: defaultMatch, - isOpen, - onOpenChange, - onClose, -}: { - match: Match; - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - onClose: () => void; -}) { - const { data: match } = useMatch(defaultMatch); - const { trigger: addGame } = useGameAdd(match.id, match.rule); - const { players, rule } = match; - - const onSubmit: SubmitHandler = async (data) => { - await addGame(data); - reset(); - onClose(); - }; - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - control, - watch, - setValue, - reset, - setFocus, - } = useForm({ - resolver: zodResolver(gameAddSchema(match.rule)), - defaultValues: { - /** @see https://github.com/react-hook-form/react-hook-form/issues/8382 */ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - playerPoints: players.map((player) => ({ - name: player.name, - profileId: player.id, - points: "", - })), - }, - }); - - const { fields, move } = useFieldArray({ - control, - name: "playerPoints", - }); - - const handleDragEnd = useCallback< - NonNullable["onDragEnd"]> - >( - (event) => { - const { active, over } = event; - if (over && active.id !== over?.id) { - const activeIndex = active.data.current?.sortable?.index; - const overIndex = over.data.current?.sortable?.index; - if (activeIndex !== undefined && overIndex !== undefined) { - move(activeIndex, overIndex); - } - } - }, - [move], - ); - - const playerPoints = watch("playerPoints"); - - const isAutoFillAvailable = - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - playerPoints.filter(({ points }) => points !== "").length === - rule.playersCount - 1; - - const totalPoints = playerPoints.reduce( - (sum, { points }) => sum + Number(points), - 0, - ); - - const totalPointsToBe = (rule.defaultPoints * rule.playersCount) / 100; - - return ( - - {/* TODO: ModalContentに対してonCloseを親から渡してる箇所を修正 */} - - {(onClose) => ( - <> - -
    結果入力
    - - - - - - 点数が同じプレイヤーがいる場合、順番が先のプレイヤーの着順が上になります。名前の左のアイコンをドラッグ&ドロップして順番を変更できます。 - - -
    -
    - - - - {fields.map((field, index) => { - const name = `playerPoints.${index}.points` as const; - const points = watch(name); - return ( - - {({ attributes, listeners }) => ( -
    -
    - -
    - -
    - {field.name} -
    - - - - ) - } - endContent={ -
    - - 00 - - - 点 - -
    - } - {...register(`playerPoints.${index}.points`)} - /> -
    - )} -
    - ); - })} -
    -
    - {errors.playerPoints?.root && ( -

    - {errors.playerPoints?.root.message} -

    - )} - -
    - - - - -
    - - )} -
    -
    - ); -} - -type UseSortableReturn = Omit< - ReturnType, - "setNodeRef" | "transform" | "transition" ->; - -export function SortableItem({ - id, - children, -}: { - id: string; - children: (props: UseSortableReturn) => React.ReactNode; -}) { - const { setNodeRef, transform, transition, ...rest } = useSortable({ id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( -
    - {children({ ...rest })} -
    - ); -} diff --git a/app/(console)/matches/[matchId]/MatchAddButton.tsx b/app/(console)/matches/[matchId]/MatchAddButton.tsx deleted file mode 100644 index a509f3c..0000000 --- a/app/(console)/matches/[matchId]/MatchAddButton.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import { Button } from "@nextui-org/react"; - -export default function MatchAddButton({ className }: { className?: string }) { - return ( - - ); -} diff --git a/app/(console)/matches/[matchId]/MatchPlayerInputModal.tsx b/app/(console)/matches/[matchId]/MatchPlayerInputModal.tsx deleted file mode 100644 index 5004e93..0000000 --- a/app/(console)/matches/[matchId]/MatchPlayerInputModal.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useCallback } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; -import { z } from "zod"; -import { Button } from "~/components/Button"; -import { FriendsList } from "~/components/FriendsList"; -import { Input } from "~/components/Input"; -import { - Modal, - ModalBody, - ModalContent, - ModalHeader, -} from "~/components/Modal"; -import { ProfilesSearch } from "~/components/ProfilesSearch"; -import { ScrollShadow } from "~/components/ScrollShadow"; -import { Tab, Tabs } from "~/components/Tabs"; -import { useMatch, useMatchPlayerAdd } from "~/lib/hooks/api/match"; -import { useProfileCreate } from "~/lib/hooks/api/profile"; -import { Match } from "~/lib/services/match"; -import { schemas } from "~/lib/utils/schemas"; - -const anonymousFormSchema = z.object({ - name: schemas.name, -}); -type AnonymousFormSchema = z.infer; - -export function MatchPlayerInputModal({ - match: defaultMatch, - isOpen, - onOpenChange, - onClose, -}: { - match: Match; - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - onClose: () => void; -}) { - const { data: match } = useMatch(defaultMatch); - const { trigger: addMatchPlayer } = useMatchPlayerAdd(match.id); - const { trigger: createProfile } = useProfileCreate(); - - const matchPlayerIds = match.players.map((player) => player.id); - - const anonymousForm = useForm({ - resolver: zodResolver(anonymousFormSchema), - mode: "onChange", - }); - - const onAnonymousFormSubmit: SubmitHandler = async ({ - name, - }) => { - const anonymousProfile = await createProfile({ name }); - handlePlayerSelect(anonymousProfile.id); - }; - - const handlePlayerSelect = useCallback( - async (profileId: string) => { - await addMatchPlayer({ profileId }); - onClose(); - }, - [addMatchPlayer, onClose], - ); - - return ( - - - プレイヤー選択 - -
    - - - - - - - -
    - -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    - ); -} diff --git a/app/(console)/matches/[matchId]/MatchTable.tsx b/app/(console)/matches/[matchId]/MatchTable.tsx deleted file mode 100644 index fccc5f4..0000000 --- a/app/(console)/matches/[matchId]/MatchTable.tsx +++ /dev/null @@ -1,184 +0,0 @@ -"use client"; - -import classNames from "classnames"; -import { useMemo } from "react"; -import { Button } from "~/components/Button"; -import { Icon } from "~/components/Icon"; -import { useDisclosure } from "~/components/Modal"; -import { - Table, - TableBody, - TableCell, - TableColumn, - TableHeader, - TableRow, - getKeyValue, -} from "~/components/Table"; -import { useGames } from "~/lib/hooks/api/games"; -import { useMatch } from "~/lib/hooks/api/match"; -import { Game } from "~/lib/services/game"; -import { Match } from "~/lib/services/match"; -import { GameInputModal } from "./GameInputModal"; -import { MatchPlayerInputModal } from "./MatchPlayerInputModal"; - -export default function MatchTable({ - match: defaultMatch, - games: defaultGames, - className, -}: { - match: Match; - games: Game[]; - className?: string; -}) { - const { data: match } = useMatch(defaultMatch); - const { data: games } = useGames(match.id, defaultGames); - - const { rule, players } = match; - const { playersCount } = rule; - - const playersShortCount = playersCount - players.length; - const isPlayersShort = playersShortCount > 0; - - const playerColumns: { - id: string; - janrecoId: string; - name: string; - type: "index" | "player" | "empty"; - }[] = [ - { id: "index", janrecoId: "", name: "", type: "index" }, - ...players.map( - (player) => - ({ - ...player, - type: "player", - }) as const, - ), - ...Array.from({ length: playersShortCount }).map( - (_, i) => - ({ - id: `player-${i}`, - janrecoId: "", - name: "", - type: "empty", - }) as const, - ), - ]; - - const gameRows: { - [playerId: (typeof players)[number]["id"]]: number; - }[] = - games?.map((game, index) => - Object.fromEntries([ - ["key", `game-${index}`], - ["index", index + 1], - ...game.scores.map((score) => [score.profileId, score.score]), - ]), - ) ?? []; - - const matchPlayerInputModal = useDisclosure(); - const gameInputModal = useDisclosure(); - - const bottomContent = useMemo(() => { - return ( -
    - -
    - ); - }, [gameInputModal.onOpen]); - - return ( -
    - - - {(column) => ( - -
    -
    - {column.type === "empty" && ( - - )} - {column.type === "player" && ( - {column.name} - )} -
    -
    -
    - )} -
    - - {(item) => ( - - {(columnKey) => { - const value = getKeyValue(item, columnKey); - return ( - - {value} - - ); - }} - - )} - -
    - - -
    - ); -} diff --git a/app/(console)/matches/[matchId]/page.tsx b/app/(console)/matches/[matchId]/page.tsx deleted file mode 100644 index 2a4ab31..0000000 --- a/app/(console)/matches/[matchId]/page.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Link from "next/link"; -import { Button } from "~/components/Button"; -import { Icon } from "~/components/Icon"; -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/serverComponentClient"; -import MatchTable from "./MatchTable"; - -export default async function Match({ - params: { matchId }, -}: { - params: { matchId: string }; -}) { - const supabaseClient = createSupabaseClient(); - const { getMatch, getGames } = services(supabaseClient); - const [match, games] = await Promise.all([ - getMatch({ matchId }), - getGames({ matchId }), - ]); - - return ( -
    -
    -
    - -
    -
    {match.date}
    -
    - -
    - ); -} diff --git a/app/(console)/matches/page.tsx b/app/(console)/matches/page.tsx deleted file mode 100644 index 7119845..0000000 --- a/app/(console)/matches/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/serverComponentClient"; -import { MatchCreateButton } from "./MatchCreateButton"; -import { MatchList } from "./MatchList"; - -export const dynamic = "force-dynamic"; - -export default async function Matches() { - const supabaseClient = createSupabaseClient(); - const { getMatches } = services(supabaseClient); - const matches = await getMatches(); - return ( -
    -

    成績表

    - -
    - -
    -
    - ); -} diff --git a/app/(main)/(components)/Navbar/Avatar/Menu/actions.ts b/app/(main)/(components)/Navbar/Avatar/Menu/actions.ts new file mode 100644 index 0000000..9a95de4 --- /dev/null +++ b/app/(main)/(components)/Navbar/Avatar/Menu/actions.ts @@ -0,0 +1,17 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/utils/supabase/server"; + +export async function signOut() { + const supabase = createClient(); + + await supabase.auth.signOut(); + + /** + * @see https://nextjs.org/docs/app/api-reference/functions/revalidatePath#revalidating-all-data + */ + revalidatePath("/", "layout"); + redirect("/login"); +} diff --git a/app/(main)/(components)/Navbar/Avatar/Menu/index.tsx b/app/(main)/(components)/Navbar/Avatar/Menu/index.tsx new file mode 100644 index 0000000..0dc3df7 --- /dev/null +++ b/app/(main)/(components)/Navbar/Avatar/Menu/index.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { DropdownMenu, DropdownItem } from "@/components/Dropdown"; +import { signOut } from "./actions"; + +export function NavbarAvatarMenu({ + name, + janrecoId, +}: { + name: string; + janrecoId: string; +}) { + const router = useRouter(); + + function handleProfileMenuAction(key: unknown) { + switch (key) { + case "friends": + // prefetchしたほうが早いかも + router.push("/friends"); + break; + case "signOut": + signOut(); + break; + } + } + + return ( + + +

    {name}

    +

    @{janrecoId}

    +
    + フレンド + + ログアウト + +
    + ); +} diff --git a/app/(main)/(components)/Navbar/Avatar/index.tsx b/app/(main)/(components)/Navbar/Avatar/index.tsx new file mode 100644 index 0000000..40762a8 --- /dev/null +++ b/app/(main)/(components)/Navbar/Avatar/index.tsx @@ -0,0 +1,29 @@ +import { redirect } from "next/navigation"; +import { Avatar } from "@/components/Avatar"; +import { Dropdown, DropdownTrigger } from "@/components/Dropdown"; +import { serverServices } from "@/lib/services/server"; +import { NavbarAvatarMenu } from "./Menu"; + +export async function NavbarAvatar() { + const { getUserProfile } = serverServices(); + const [profile] = await Promise.all([getUserProfile()]); + + // TODO: isUnregisteredで扱う。ts制御 + if (!profile.janrecoId || !profile.name) { + redirect("/register"); + } + + return ( + + + + + + + ); +} diff --git a/app/(main)/(components)/Navbar/index.tsx b/app/(main)/(components)/Navbar/index.tsx new file mode 100644 index 0000000..8d30d02 --- /dev/null +++ b/app/(main)/(components)/Navbar/index.tsx @@ -0,0 +1,29 @@ +import { + Navbar as NextUINavbar, + NavbarBrand, + NavbarContent, +} from "@nextui-org/react"; +import { Suspense } from "react"; +import { Link } from "@/components/Link"; +import Logo from "@/components/Logo"; +import { Skeleton } from "@/components/Skeleton"; +import { NavbarAvatar } from "./Avatar"; + +export default function Navbar() { + return ( + + + + + + + + + + }> + + + + + ); +} diff --git a/app/(main)/(routes)/friends/(components)/AddButton/actions.ts b/app/(main)/(routes)/friends/(components)/AddButton/actions.ts new file mode 100644 index 0000000..b9de284 --- /dev/null +++ b/app/(main)/(routes)/friends/(components)/AddButton/actions.ts @@ -0,0 +1,14 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { serverServices } from "@/lib/services/server"; + +export async function addFriends(profileId: string) { + const { addFriends } = serverServices(); + + await addFriends({ profileId }); + + revalidatePath("/friends"); + redirect("/friends"); +} diff --git a/app/(main)/(routes)/friends/(components)/AddButton/index.tsx b/app/(main)/(routes)/friends/(components)/AddButton/index.tsx new file mode 100644 index 0000000..512d18a --- /dev/null +++ b/app/(main)/(routes)/friends/(components)/AddButton/index.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useState } from "react"; +import useSWR from "swr"; +import { useDebouncedCallback } from "use-debounce"; +import { Button } from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import { Input } from "@/components/Input"; +import { + Modal, + ModalBody, + ModalContent, + ModalHeader, + useDisclosure, +} from "@/components/Modal"; +import { ScrollShadow } from "@/components/ScrollShadow"; +import { User } from "@/components/User"; +import { browserServices } from "@/lib/services/browser"; +import { addFriends } from "./actions"; + +export function AddButton() { + const addModal = useDisclosure(); + const [query, setQuery] = useState(""); + + const { searchProfiles } = browserServices(); + + const { data: profiles, isValidating } = useSWR( + ["searchProfiles", query], + () => + searchProfiles({ + text: query, + }), + ); + + const handleSearch = useDebouncedCallback((value: string) => { + setQuery(value); + }, 300); + + const handleAdd = () => { + setQuery(""); + addModal.onClose(); + }; + + return ( + <> + + { + setQuery(""); + }} + > + + フレンド追加 + + + } + onChange={(e) => { + handleSearch(e.target.value); + }} + defaultValue={query} + /> + + {isValidating && + Array.from({ length: 1 }).map((_, i) => ( +
  • + +
  • + ))} + {!isValidating && !!query && profiles?.length === 0 && ( +

    + 見つかりませんでした +

    + )} + {!isValidating && + profiles && + profiles.length > 0 && + profiles?.map(({ id, name, janrecoId, isFriend }) => ( +
  • + + {isFriend ? ( +
    + 追加済み +
    + ) : ( +
    + +
    + )} +
  • + ))} +
    +
    +
    +
    + + ); +} diff --git a/app/(main)/(routes)/friends/(components)/FriendMenu/actions.ts b/app/(main)/(routes)/friends/(components)/FriendMenu/actions.ts new file mode 100644 index 0000000..9db4e9f --- /dev/null +++ b/app/(main)/(routes)/friends/(components)/FriendMenu/actions.ts @@ -0,0 +1,12 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { serverServices } from "@/lib/services/server"; + +export async function deleteFriends(profileId: string) { + const { deleteFriends } = serverServices(); + + await deleteFriends({ profileId }); + + revalidatePath("/friends"); +} diff --git a/app/(main)/(routes)/friends/(components)/FriendMenu/index.tsx b/app/(main)/(routes)/friends/(components)/FriendMenu/index.tsx new file mode 100644 index 0000000..784b6ff --- /dev/null +++ b/app/(main)/(routes)/friends/(components)/FriendMenu/index.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Button } from "@/components/Button"; +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, +} from "@/components/Dropdown"; +import { Icon } from "@/components/Icon"; +import { deleteFriends } from "./actions"; + +export function FriendMenu({ profileId }: { profileId: string }) { + function handleAction(key: unknown) { + if (key === "delete") { + deleteFriends(profileId); + } + } + + return ( + + + + + + 削除 + + + ); +} diff --git a/app/(main)/(routes)/friends/loading.tsx b/app/(main)/(routes)/friends/loading.tsx new file mode 100644 index 0000000..8886062 --- /dev/null +++ b/app/(main)/(routes)/friends/loading.tsx @@ -0,0 +1,21 @@ +import { User } from "@/components/User"; + +export default function FriendsLoading() { + return ( +
    +
    +

    フレンド

    +
    +
      + {Array.from({ length: 3 }).map((_, i) => ( +
    • + +
    • + ))} +
    +
    + ); +} diff --git a/app/(main)/(routes)/friends/page.tsx b/app/(main)/(routes)/friends/page.tsx new file mode 100644 index 0000000..94655e3 --- /dev/null +++ b/app/(main)/(routes)/friends/page.tsx @@ -0,0 +1,30 @@ +import { User } from "@/components/User"; +import { serverServices } from "@/lib/services/server"; +import { AddButton } from "./(components)/AddButton"; +import { FriendMenu } from "./(components)/FriendMenu"; + +export default async function FriendsPage() { + const { getFriends } = serverServices(); + + const friends = await getFriends(); + + return ( +
    +
    +

    フレンド

    + +
    +
      + {friends?.map((friend) => ( +
    • + + +
    • + ))} +
    +
    + ); +} diff --git a/app/(main)/(routes)/matches/(components)/CreateMatchButton/actions.ts b/app/(main)/(routes)/matches/(components)/CreateMatchButton/actions.ts new file mode 100644 index 0000000..81175bb --- /dev/null +++ b/app/(main)/(routes)/matches/(components)/CreateMatchButton/actions.ts @@ -0,0 +1,85 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { serverServices } from "@/lib/services/server"; +import { schema } from "@/lib/utils/schema"; + +type State = { + errors?: { + base?: string[]; + playersCount?: string[]; + incline?: string[]; + customIncline?: string[]; + rate?: string[]; + chipRate?: string[]; + crackBoxBonus?: string[]; + defaultPoints?: string[]; + defaultCalcPoints?: string[]; + calcMethod?: string[]; + }; +}; + +const createMatchschema = z.object({ + playersCount: schema.playersCount, + incline: schema.incline, + customIncline: schema.customIncline, + rate: schema.rate, + chipRate: schema.chipRate, + crackBoxBonus: schema.crackBoxBonus, + defaultPoints: schema.defaultPoints, + defaultCalcPoints: schema.defaultCalcPoints, + calcMethod: schema.calcMethod, +}); + +export type InputSchema = z.input; + +export async function createMatch( + prevState: State, + formData: FormData, +): Promise { + const inclineFormData = formData.get("incline"); + const validatedFields = createMatchschema.safeParse({ + playersCount: formData.get("playersCount"), + incline: inclineFormData, + customIncline: + inclineFormData === "custom" + ? { + incline1: formData.get("customIncline.incline1"), + incline2: formData.get("customIncline.incline2"), + incline3: formData.get("customIncline.incline3"), + incline4: formData.get("customIncline.incline4"), + } + : { + incline1: "0", // validationを通すためにダミーの値を入れる。実際には使われない。 + incline2: "0", + incline3: "0", + incline4: "0", + }, + rate: formData.get("rate"), + chipRate: formData.get("chipRate"), + crackBoxBonus: formData.get("crackBoxBonus"), + defaultPoints: formData.get("defaultPoints"), + defaultCalcPoints: formData.get("defaultCalcPoints"), + calcMethod: formData.get("calcMethod"), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const { incline, customIncline, ...restData } = validatedFields.data; + + const { createMatch } = serverServices(); + + const { id } = await createMatch({ + incline: incline === "custom" ? customIncline : incline, + ...restData, + }); + + revalidatePath("/matches"); + redirect(`/matches/${id}`); +} diff --git a/app/(main)/(routes)/matches/(components)/CreateMatchButton/index.tsx b/app/(main)/(routes)/matches/(components)/CreateMatchButton/index.tsx new file mode 100644 index 0000000..08386af --- /dev/null +++ b/app/(main)/(routes)/matches/(components)/CreateMatchButton/index.tsx @@ -0,0 +1,410 @@ +"use client"; + +import classNames from "classnames"; +import { useFormState } from "react-dom"; +import { Controller, useForm } from "react-hook-form"; +import { Accordion, AccordionItem } from "@/components/Accordion"; +import { Button, ButtonGroup } from "@/components/Button"; +import { Input } from "@/components/Input"; +import { useDisclosure } from "@/components/Modal"; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@/components/Modal"; +import { Select, SelectItem } from "@/components/Select"; +import { Slider } from "@/components/Slider"; +import { + calcMethods, + chipRateLabel, + chipRates, + inclineFor3PlayersLabel, + inclineFor4PlayersLabel, + rateLabel, + rates, +} from "@/lib/config"; +import { createMatch, InputSchema } from "./actions"; + +const playersCount4DefaultValues: InputSchema = { + playersCount: "4", + crackBoxBonus: "10000", + defaultPoints: "25000", + defaultCalcPoints: "30000", + calcMethod: "round", + incline: "0_0_0_0", + customIncline: { + incline1: "", + incline2: "", + incline3: "", + incline4: "", + }, + rate: "0", + chipRate: "0", +}; + +const playersCount3DefaultValues: InputSchema = { + playersCount: "3", + crackBoxBonus: "10000", + defaultPoints: "35000", + defaultCalcPoints: "40000", + calcMethod: "round", + incline: "0_0_0_0", + customIncline: { + incline1: "", + incline2: "", + incline3: "", + incline4: "0", + }, + rate: "0", + chipRate: "0", +}; + +export function CreateMatchButton({ className }: { className?: string }) { + const ruleCreateModal = useDisclosure(); + + const [state, formAction] = useFormState(createMatch, {}); + const { errors } = state; + + const { reset, control, watch } = useForm({ + defaultValues: playersCount4DefaultValues, + }); + + const playersCount = watch("playersCount"); + const incline = watch("incline"); + const inclineOption = + playersCount === "4" ? inclineFor4PlayersLabel : inclineFor3PlayersLabel; + + return ( + <> + + + + {(onClose) => ( + <> + ルール設定 +
    + + ( + <> + + + + + + + )} + /> + ( + <> + ({ + value: i, + label: String(rate), + }))} + getValue={(v) => rateLabel[rates[Number(v)]]} + onChange={(v) => { + onChange(String(v)); + }} + value={Number(value)} + {...field} + /> + + )} + /> + ( + <> + ({ + value: i, + label: String(chipRate), + }))} + getValue={(v) => chipRateLabel[chipRates[Number(v)]]} + onChange={(v) => { + onChange(String(v)); + }} + value={Number(value)} + {...field} + /> + + )} + /> + ( + + )} + /> + {incline === "custom" && ( +
    +
    +
    +
    + ( + + )} + /> + - + ( + + )} + /> + - + ( + + )} + /> + {playersCount === "4" && -} + ( + + )} + /> +
    + {/* same as Input component error */} + {errors?.customIncline && ( +
    + {errors?.customIncline[0]} +
    + )} +
    +
    + )} + + +
    + ( + + + 点 + +
    + } + errorMessage={errors?.crackBoxBonus} + {...field} + /> + )} + /> + ( + + + 点 + + + } + errorMessage={errors?.defaultPoints} + {...field} + /> + )} + /> + ( + + + 点返し + + + } + errorMessage={errors?.defaultCalcPoints} + {...field} + /> + )} + /> + ( + + )} + /> + +
    +
    +
    + + + + +
    + + )} +
    +
    + + ); +} diff --git a/app/(main)/(routes)/matches/(components)/MatchCard/(components)/Card/index.tsx b/app/(main)/(routes)/matches/(components)/MatchCard/(components)/Card/index.tsx new file mode 100644 index 0000000..290a8c0 --- /dev/null +++ b/app/(main)/(routes)/matches/(components)/MatchCard/(components)/Card/index.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Card } from "@/components/Card"; + +export function NavigationCard({ + matchId, + children, +}: { + matchId: string; + children: React.ReactNode; +}) { + const { push } = useRouter(); + + return ( + push(`/matches/${matchId}`)}> + {children} + + ); +} diff --git a/app/(main)/(routes)/matches/(components)/MatchCard/(components)/RankCountChart/index.tsx b/app/(main)/(routes)/matches/(components)/MatchCard/(components)/RankCountChart/index.tsx new file mode 100644 index 0000000..c1f3cc3 --- /dev/null +++ b/app/(main)/(routes)/matches/(components)/MatchCard/(components)/RankCountChart/index.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { + Bar, + BarChart, + ResponsiveContainer, + XAxis, + YAxis, + Text, +} from "recharts"; + +// Override console.error +// This is a hack to suppress the warning about missing defaultProps in recharts library as of version 2.12 +// @link https://github.com/recharts/recharts/issues/3615 +// eslint-disable-next-line no-console +const error = console.error; +// eslint-disable-next-line no-console, @typescript-eslint/no-explicit-any +console.error = (...args: any) => { + if (/defaultProps/.test(args[0])) return; + error(...args); +}; + +const radius = 5; + +export function RankCountChart({ + matchResult, + userProfileId, +}: { + matchResult: { + [profileId: string]: { + rankCounts: number[]; + averageRank: number; + totalPoints: number; + }; + }; + userProfileId: string; +}) { + const data = matchResult[userProfileId].rankCounts.map((count, index) => ({ + name: `${index + 1}`, + value: count, + })); + + return ( + + + + } + width={28} + /> + + + + ); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function CustomTick(props: any) { + return ( + + {`${props.payload.value}着`} + + ); +} diff --git a/app/(main)/(routes)/matches/(components)/MatchCard/index.tsx b/app/(main)/(routes)/matches/(components)/MatchCard/index.tsx new file mode 100644 index 0000000..9a58cd4 --- /dev/null +++ b/app/(main)/(routes)/matches/(components)/MatchCard/index.tsx @@ -0,0 +1,48 @@ +import { Avatar, AvatarGroup } from "@/components/Avatar"; +import { CardBody, CardHeader } from "@/components/Card"; +import { Divider } from "@/components/Divider"; +import { serverServices } from "@/lib/services/server"; +import { dayjs } from "@/lib/utils/date"; +import { NavigationCard } from "./(components)/Card"; + +export async function MatchCard({ + matchId, + date, +}: { + matchId: string; + date: string; +}) { + const { getMatch } = serverServices(); + const [match] = await Promise.all([getMatch({ matchId })]); + + const today = dayjs(); + const targetDate = dayjs(date); + const isSameYear = today.isSame(targetDate, "year"); + const displayDate = isSameYear + ? dayjs(date).format("M/D") + : dayjs(date).format("YYYY/M/D"); + + return ( + + +
    +

    {displayDate}

    + + {match.players.map((player) => ( + + ))} + +
    +
    + + + {/*
    + +
    */} +
    +
    + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/(components)/Form/actions.ts b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/(components)/Form/actions.ts new file mode 100644 index 0000000..dc2362e --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/(components)/Form/actions.ts @@ -0,0 +1,78 @@ +"use server"; + +import { z } from "zod"; +import { serverServices } from "@/lib/services/server"; +import { schema } from "@/lib/utils/schema"; + +type AddChipState = { + success?: boolean; + errors?: { + base?: string[]; + playerChip?: string[]; + }; +}; + +const addChipSchema = z + .object({ + playerChip: z.array( + z.object({ + profileId: schema.profileId, + chipCount: z.string().transform(Number), + }), + ), + }) + .refine( + ({ playerChip }) => { + const total = playerChip.reduce((acc, { chipCount }) => { + return acc + (chipCount ?? 0); + }, 0); + return total === 0; + }, + ({ playerChip }) => { + const total = playerChip.reduce((acc, { chipCount }) => { + return acc + (chipCount ?? 0); + }, 0); + return { + path: ["playerChip"], + message: `チップの合計が0枚なるように入力してください\n現在: ${total.toLocaleString()}枚`, + }; + }, + ); + +export async function addChip( + matchId: string, + totalPlayersCount: number, + prevState: AddChipState, + formData: FormData, +): Promise { + const playerChip = Array.from({ length: totalPlayersCount }).map((_, i) => { + return { + profileId: formData.get(`playerChip.${i}.profileId`), + chipCount: formData.get(`playerChip.${i}.chipCount`), + }; + }); + + const validatedFields = addChipSchema.safeParse({ + playerChip, + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const { updateMatchPlayer } = serverServices(); + + validatedFields.data.playerChip.map(async (playerChip) => { + await updateMatchPlayer({ + matchId, + playerId: playerChip.profileId, + chipCount: playerChip.chipCount, + }); + }); + + return { + success: true, + }; +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/(components)/Form/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/(components)/Form/index.tsx new file mode 100644 index 0000000..21453b0 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/(components)/Form/index.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect } from "react"; +import { useFormState } from "react-dom"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { Button } from "@/components/Button"; +import { Input } from "@/components/Input"; +import { ModalBody, ModalFooter } from "@/components/Modal"; +import { Match } from "@/lib/type"; +import { useChipInputModal } from "../../hooks"; +import { addChip } from "./actions"; + +type Schema = { + playerChip: { + profileId: string; + chipCount: string; + }[]; +}; + +export function ChipInputForm({ match }: { match: Match }) { + const chipInputModal = useChipInputModal(); + const { players, rule } = match; + const [{ errors, success }, formAction] = useFormState( + addChip.bind(null, match.id, players.length), + {}, + ); + + const { control, watch, setValue, setFocus } = useForm({ + defaultValues: { + playerChip: players.map((player) => ({ + profileId: player.id, + chipCount: "", + })), + }, + }); + + const { fields } = useFieldArray({ + control, + name: "playerChip", + }); + + const playerChip = watch("playerChip"); + + const isAutoFillAvailable = + playerChip.filter(({ chipCount }) => chipCount !== "").length === + rule.playersCount - 1; + + const totalChipCount = playerChip.reduce( + (sum, { chipCount }) => sum + Number(chipCount), + 0, + ); + + useEffect(() => { + if (success) { + chipInputModal.onClose(); + } + }, [chipInputModal, success]); + + return ( +
    + +
      + {fields.map((field, index) => ( +
    • + } + /> +
      + {players[index].name} +
      + ( + + + + ) + } + endContent={ +
      + +
      + } + {...field} + /> + )} + /> +
    • + ))} +
    + {errors?.playerChip && ( +

    + {errors.playerChip[0]} +

    + )} +
    + + + + +
    + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/(components)/ModalController/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/(components)/ModalController/index.tsx new file mode 100644 index 0000000..040be9e --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/(components)/ModalController/index.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Modal, ModalContent } from "@/components/Modal"; +import { useChipInputModal } from "../../hooks"; + +export function ChipInputModalController({ + children, +}: { + children: React.ReactNode; +}) { + const chipInputModal = useChipInputModal(); + + return ( + + {children} + + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/hooks.ts b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/hooks.ts new file mode 100644 index 0000000..5ae2b9e --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/hooks.ts @@ -0,0 +1,5 @@ +import { useQueryControlledModal } from "@/components/Modal"; + +export const useChipInputModal = () => { + return useQueryControlledModal("chip-input"); +}; diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/index.tsx new file mode 100644 index 0000000..8a7e783 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/ChipInputModal/index.tsx @@ -0,0 +1,16 @@ +import { ModalHeader } from "@/components/Modal"; +import { serverServices } from "@/lib/services/server"; +import { ChipInputForm } from "./(components)/Form"; +import { ChipInputModalController } from "./(components)/ModalController"; + +export async function ChipInputModal({ matchId }: { matchId: string }) { + const { getMatch } = serverServices(); + const [match] = await Promise.all([getMatch({ matchId })]); + + return ( + + チップ入力 + + + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/(components)/Form/actions.ts b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/(components)/Form/actions.ts new file mode 100644 index 0000000..aa83caa --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/(components)/Form/actions.ts @@ -0,0 +1,126 @@ +"use server"; + +import { z } from "zod"; +import { serverServices } from "@/lib/services/server"; +import { Rule } from "@/lib/type"; +import { schema } from "@/lib/utils/schema"; +import { calcPlayerScores } from "@/lib/utils/score"; + +type AddGameState = { + success?: boolean; + errors?: { + base?: string[]; + players?: string[]; + crackBoxPlayerId?: string[]; + }; +}; + +const addGameSchema = z + .object({ + players: z.array( + z.object({ + id: schema.profileId, + points: schema.points, + }), + ), + playersCount: z.number(), + defaultPoints: z.number(), + crackBoxPlayerId: schema.profileId.transform((v) => + v === "" ? undefined : v, + ), + }) + .refine( + ({ players, playersCount }) => { + return ( + players.filter(({ points }) => points !== undefined).length === + playersCount + ); + }, + ({ playersCount }) => ({ + path: ["players"], + message: `${playersCount}人分の点数を入力してください`, + }), + ) + .refine( + ({ players, playersCount, defaultPoints }) => { + const total = players.reduce((acc, { points }) => { + return acc + (points ?? 0); + }, 0); + return total === playersCount * defaultPoints; + }, + ({ players, playersCount, defaultPoints }) => { + const total = players.reduce((acc, { points }) => { + return acc + (points ?? 0); + }, 0); + return { + path: ["players"], + message: `点数の合計が${( + playersCount * defaultPoints + ).toLocaleString()}点になるように入力してください\n現在: ${total.toLocaleString()}点`, + }; + }, + ) + .refine( + ({ players, crackBoxPlayerId }) => { + const underZeroPointsPlayerExists = players.some( + ({ points }) => typeof points === "number" && points < 0, + ); + if (!underZeroPointsPlayerExists && crackBoxPlayerId !== undefined) { + return false; + } + return true; + }, + { + path: ["crackBoxPlayerId"], + message: "0点を下回るプレイヤーがいません", + }, + ); + +export type AddGameInputSchema = z.input; + +export async function addGame( + matchId: string, + rule: Rule, + totalPlayersCount: number, + prevState: AddGameState, + formData: FormData, +): Promise { + const players = Array.from({ length: totalPlayersCount }).map((_, i) => { + return { + id: formData.get(`players.${i}.id`), + points: formData.get(`players.${i}.points`), + }; + }); + + const validatedFields = addGameSchema.safeParse({ + players, + playersCount: rule.playersCount, + defaultPoints: rule.defaultPoints, + crackBoxPlayerId: formData.get("crackBoxPlayerId"), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const { createGame } = serverServices(); + + const playerScores = calcPlayerScores({ + players: validatedFields.data.players.filter( + (players) => players.points !== undefined, + ) as { id: string; points: number }[], + rule, + crackBoxPlayerId: validatedFields.data.crackBoxPlayerId, + }); + + await createGame({ + matchId, + gamePlayers: playerScores, + }); + + return { + success: true, + }; +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/(components)/Form/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/(components)/Form/index.tsx new file mode 100644 index 0000000..c9ac28d --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/(components)/Form/index.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { DndContext } from "@dnd-kit/core"; +import { SortableContext } from "@dnd-kit/sortable"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import classNames from "classnames"; +import { ComponentProps, useCallback, useEffect } from "react"; +import { useFormState } from "react-dom"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { Button } from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import { Input } from "@/components/Input"; +import { ModalBody, ModalFooter } from "@/components/Modal"; +import { Select, SelectItem } from "@/components/Select"; +import { Match } from "@/lib/type"; +import { useGameInputModal } from "../../hooks"; +import { addGame } from "./actions"; + +type Schema = { + players: { + id: string; + name: string | null; + points?: string; + }[]; + crackBoxPlayerId?: string; +}; + +export function GameInputForm({ match }: { match: Match }) { + const gameInputModal = useGameInputModal(); + + const { rule } = match; + const [{ errors, success }, formAction] = useFormState( + addGame.bind(null, match.id, rule, match.players.length), + {}, + ); + + const { control, watch, setValue, setFocus } = useForm({ + defaultValues: { + players: match.players.map((player) => ({ + id: player.id, + name: player.name, + points: "", + })), + }, + }); + + const { fields, move } = useFieldArray({ + control, + name: "players", + }); + + const handleDragEnd = useCallback< + NonNullable["onDragEnd"]> + >( + (event) => { + const { active, over } = event; + if (over && active.id !== over?.id) { + const activeIndex = active.data.current?.sortable?.index; + const overIndex = over.data.current?.sortable?.index; + if (activeIndex !== undefined && overIndex !== undefined) { + move(activeIndex, overIndex); + } + } + }, + [move], + ); + + const players = watch("players"); + + const isAutoFillAvailable = + players.filter(({ points }) => points !== "").length === + rule.playersCount - 1; + + const totalPoints = players.reduce( + (sum, { points }) => sum + Number(points), + 0, + ); + + const totalPointsToBe = (rule.defaultPoints * rule.playersCount) / 100; + + useEffect(() => { + if (success) { + gameInputModal.onClose(); + } + }, [gameInputModal, success]); + + return ( +
    + +
      + + + {fields.map((field, index) => { + const name = `players.${index}.points` as const; + const points = watch(name); + return ( + + {({ attributes, listeners }) => ( +
      + ( + + )} + /> +
      + {field.name} +
      + ( + + + + ) + } + endContent={ +
      + + 00 + + + 点 + +
      + } + {...field} + /> + )} + /> +
      + +
      +
      + )} +
      + ); + })} +
      +
      +
    + {errors?.players && ( +

    + {errors.players[0]} +

    + )} + ( + + )} + /> +
    + + + + +
    + ); +} + +type UseSortableReturn = Omit< + ReturnType, + "setNodeRef" | "transform" | "transition" +>; + +export function SortableItem({ + id, + children, +}: { + id: string; + children: (props: UseSortableReturn) => React.ReactNode; +}) { + const { setNodeRef, transform, transition, ...rest } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
  • + {children({ ...rest })} +
  • + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/(components)/ModalController/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/(components)/ModalController/index.tsx new file mode 100644 index 0000000..3b5a187 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/(components)/ModalController/index.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Modal, ModalContent } from "@/components/Modal"; +import { useGameInputModal } from "../../hooks"; + +export function GameInputModalController({ + children, +}: { + children: React.ReactNode; +}) { + const gameInputModal = useGameInputModal(); + + return ( + + {children} + + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/hooks.ts b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/hooks.ts new file mode 100644 index 0000000..61a4e8f --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/hooks.ts @@ -0,0 +1,5 @@ +import { useQueryControlledModal } from "@/components/Modal"; + +export const useGameInputModal = () => { + return useQueryControlledModal("game-input"); +}; diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/index.tsx new file mode 100644 index 0000000..df4e9f5 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/GameInputModal/index.tsx @@ -0,0 +1,32 @@ +import { Button } from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import { ModalHeader } from "@/components/Modal"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/Popover"; +import { serverServices } from "@/lib/services/server"; +import { GameInputForm } from "./(components)/Form"; +import { GameInputModalController } from "./(components)/ModalController"; + +export async function GameInputModal({ matchId }: { matchId: string }) { + const { getMatch } = serverServices(); + const [match] = await Promise.all([getMatch({ matchId })]); + + return ( + + +
    結果入力
    + + + + + + 点数が同じプレイヤーがいる場合、順番が先のプレイヤーの着順が上になります。名前の左のアイコンをドラッグ&ドロップして順番を変更できます。 + + +
    + +
    + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/AnonymousTab/actions.ts b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/AnonymousTab/actions.ts new file mode 100644 index 0000000..893bde4 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/AnonymousTab/actions.ts @@ -0,0 +1,47 @@ +"use server"; + +import { z } from "zod"; +import { serverServices } from "@/lib/services/server"; +import { schema } from "@/lib/utils/schema"; + +type State = { + success?: boolean; + errors?: { + base?: string[]; + name?: string[]; + }; +}; + +const addAnonymousPlayerSchema = z.object({ + name: schema.name, +}); + +export async function addAnonymousPlayer( + matchId: string, + prevState: State, + formData: FormData, +): Promise { + const validatedFields = addAnonymousPlayerSchema.safeParse({ + name: formData.get("name"), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const { createProfile, addMatchPlayer } = serverServices(); + const player = await createProfile({ + ...validatedFields.data, + }); + + await addMatchPlayer({ + matchId, + playerId: player.id, + }); + + return { + success: true, + }; +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/AnonymousTab/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/AnonymousTab/index.tsx new file mode 100644 index 0000000..74d49e7 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/AnonymousTab/index.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect } from "react"; +import { useFormState } from "react-dom"; +import { Avatar } from "@/components/Avatar"; +import { Button } from "@/components/Button"; +import { Input } from "@/components/Input"; +import { NAME_MAX_LENGTH } from "@/lib/config"; +import { useMatchPlayerInputModal } from "../../hooks"; +import { addAnonymousPlayer } from "./actions"; + +export function AnonymousTab({ matchId }: { matchId: string }) { + const matchPlayerInputModal = useMatchPlayerInputModal(); + + const [{ errors, success }, formAction] = useFormState( + addAnonymousPlayer.bind(null, matchId), + { + success: false, + }, + ); + + // TODO: 見直し。サーバー側でできないか + useEffect(() => { + if (success) { + matchPlayerInputModal.onClose(); + } + }, [matchPlayerInputModal, success]); + + return ( +
    +
    + + +
    + +
    + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/ModalController/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/ModalController/index.tsx new file mode 100644 index 0000000..2aa0376 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/ModalController/index.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { Modal, ModalContent } from "@/components/Modal"; +import { useMatchPlayerInputModal } from "../../hooks"; + +export function ModalController({ children }: { children: React.ReactNode }) { + const { onClose, isOpen } = useMatchPlayerInputModal(); + + return ( + + {children} + + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/Tabs/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/Tabs/index.tsx new file mode 100644 index 0000000..5a16016 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/Tabs/index.tsx @@ -0,0 +1,33 @@ +/** + * Tabs works only in the client side. + * @see https://nextui.org/docs/components/tabs#nextjs + */ +"use client"; + +import { Tab, Tabs } from "@/components/Tabs"; + +export function MatchPlayerInputTabs({ + userTab, + anonymousTab, +}: { + userTab: React.ReactNode; + anonymousTab: React.ReactNode; +}) { + return ( + + + {userTab} + + + {anonymousTab} + + + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/UserTab/actions.ts b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/UserTab/actions.ts new file mode 100644 index 0000000..84de7c0 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/UserTab/actions.ts @@ -0,0 +1,58 @@ +"use server"; + +import { z } from "zod"; +import { serverServices } from "@/lib/services/server"; + +export async function addUserPlayer({ + matchId, + playerId, +}: { + matchId: string; + playerId: string; +}) { + const { addMatchPlayer } = serverServices(); + await addMatchPlayer({ + matchId, + playerId, + }); +} + +type SearchProfilesState = { + profiles?: Awaited< + ReturnType["searchProfiles"]> + >; + errors?: { + text?: string[]; + }; +}; + +const searchProfilesSchema = z.object({ + text: z.string(), +}); + +export async function searchProfiles( + prevState: SearchProfilesState, + formDate: FormData, +): Promise { + const validatedFields = searchProfilesSchema.safeParse({ + text: formDate.get("text"), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const { text } = validatedFields.data; + + if (!text) { + return {}; + } + + const { searchProfiles } = serverServices(); + const profiles = await searchProfiles({ text }); + return { + profiles, + }; +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/UserTab/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/UserTab/index.tsx new file mode 100644 index 0000000..cdea368 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/(components)/UserTab/index.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { useFormState, useFormStatus } from "react-dom"; +import { useDebouncedCallback } from "use-debounce"; +import { Icon } from "@/components/Icon"; +import { Input } from "@/components/Input"; +import { ScrollShadow } from "@/components/ScrollShadow"; +import { User } from "@/components/User"; +import { Profile } from "@/lib/type"; +import { useMatchPlayerInputModal } from "../../hooks"; +import { addUserPlayer, searchProfiles } from "./actions"; + +export function UserTab({ + matchId, + friends, + playerIds, +}: { + matchId: string; + friends: Profile[]; + playerIds: string[]; +}) { + const matchPlayerInputModal = useMatchPlayerInputModal(); + const formRef = useRef(null); + const [{ profiles }, formAction] = useFormState(searchProfiles, {}); + + const selectableFriends = friends.filter( + (friend) => !playerIds.includes(friend.id), + ); + + const selectableProfiles = profiles?.filter( + (profile) => !playerIds.includes(profile.id), + ); + + const handleChange = useDebouncedCallback(() => { + formRef.current?.requestSubmit(); + }, 300); + + const selectUser = useCallback( + (playerId: string) => { + // TODO: await中の表示 + addUserPlayer({ matchId, playerId }); + // TODO: server側でredirectしてもいいかも + matchPlayerInputModal.onClose(); + }, + [matchId, matchPlayerInputModal], + ); + + return ( +
    + } + onChange={handleChange} + /> + + {!!selectableProfiles && ( + + )} + {!profiles && !!friends.length && ( + <> +

    + フレンドから選ぶ +

    +
      + {selectableFriends.map((friend) => ( +
    • + {/* TODO: ripple */} + +
    • + ))} +
    + + )} +
    +
    + ); +} + +function ProfileList({ + profiles, + onSelect, +}: { + profiles: Profile[]; + onSelect: (profileId: string) => void; +}) { + const { pending } = useFormStatus(); + + if (pending) { + return ( +
      + {Array.from({ length: 1 }).map((_, i) => ( +
    • + +
    • + ))} +
    + ); + } + + if (!profiles.length) { + return ( +

    + 見つかりませんでした +

    + ); + } + + return ( +
      + {profiles.map((profile) => ( +
    • + {/* TODO: ripple, 共通化, 連打対策, 見た目 */} + +
    • + ))} +
    + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/hooks.ts b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/hooks.ts new file mode 100644 index 0000000..d0524ce --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/hooks.ts @@ -0,0 +1,5 @@ +import { useQueryControlledModal } from "@/components/Modal"; + +export const useMatchPlayerInputModal = () => { + return useQueryControlledModal("player-input"); +}; diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/index.tsx new file mode 100644 index 0000000..06dff17 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchPlayerInputModal/index.tsx @@ -0,0 +1,34 @@ +import { ModalBody, ModalHeader } from "@/components/Modal"; +import { serverServices } from "@/lib/services/server"; +import { AnonymousTab } from "./(components)/AnonymousTab"; +import { ModalController } from "./(components)/ModalController"; +import { MatchPlayerInputTabs } from "./(components)/Tabs"; +import { UserTab } from "./(components)/UserTab"; + +export async function MatchPlayerInputModal({ matchId }: { matchId: string }) { + const { getFriends, getMatch } = serverServices(); + const [friends, match] = await Promise.all([ + getFriends(), + getMatch({ matchId }), + ]); + + return ( + + プレイヤー追加 + +
    + player.id)} + /> + } + anonymousTab={} + /> +
    +
    +
    + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/(components)/AddChipButton/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/(components)/AddChipButton/index.tsx new file mode 100644 index 0000000..758ade3 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/(components)/AddChipButton/index.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Button } from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import { useChipInputModal } from "../../../ChipInputModal/hooks"; + +export function AddChipButton({ isDisabled }: { isDisabled: boolean }) { + const gameInputModal = useChipInputModal(); + + return ( + + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/(components)/AddGameButton/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/(components)/AddGameButton/index.tsx new file mode 100644 index 0000000..790278e --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/(components)/AddGameButton/index.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Button } from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import { useGameInputModal } from "../../../GameInputModal/hooks"; + +export function AddGameButton({ isDisabled }: { isDisabled: boolean }) { + const gameInputModal = useGameInputModal(); + + return ( + + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/(components)/AddPlayerButton/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/(components)/AddPlayerButton/index.tsx new file mode 100644 index 0000000..f2a31c5 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/(components)/AddPlayerButton/index.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Button } from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import { useMatchPlayerInputModal } from "../../../MatchPlayerInputModal/hooks"; + +export function AddPlayerButton() { + const matchPlayerInputModal = useMatchPlayerInputModal(); + + return ( + + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/index.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/index.tsx new file mode 100644 index 0000000..8885bfb --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/(components)/MatchTable/index.tsx @@ -0,0 +1,152 @@ +import classNames from "classnames"; +import { + Table, + TableBody, + TableCell, + TableColumn, + TableFooter, + TableFooterCell, + TableFooterRow, + TableHeader, + TableRow, +} from "@/components/Table"; +import { serverServices } from "@/lib/services/server"; +import { AddChipButton } from "./(components)/AddChipButton"; +import { AddGameButton } from "./(components)/AddGameButton"; +import { AddPlayerButton } from "./(components)/AddPlayerButton"; + +type Column = { + id: string; + janrecoId: string | null; + name: string | null; + type: "index" | "player" | "empty"; +}; + +type Row = { + [playerId: Column["id"]]: number; +}; + +export async function MatchTable({ matchId }: { matchId: string }) { + const { getMatch } = serverServices(); + const [match] = await Promise.all([getMatch({ matchId })]); + const { rule, players } = match; + const { playersCount } = rule; + + const playersShortCount = playersCount - players.length; + const isPlayersShort = playersShortCount > 0; + + const columns: Column[] = [ + ...players.map( + (player) => + ({ + ...player, + type: "player", + }) as const, + ), + ...Array.from({ length: playersShortCount }).map( + (_, i) => + ({ + id: `player-${i}`, + janrecoId: "", + name: "", + type: "empty", + }) as const, + ), + ]; + + const gameRows: Row[] = + match.games?.map((game) => + Object.fromEntries( + game.players.map((player) => [player.id, player.score]), + ), + ) ?? []; + + const totalPointsRow: Row = Object.fromEntries( + players.map((player) => [ + player.id, + gameRows.reduce((acc, gameRow) => acc + gameRow[player.id], 0), + ]), + ); + + const chipsRow: Row = Object.fromEntries( + players.map((player) => [player.id, player.chipCount ?? 0]), + ); + + return ( +
    + + + + + + {columns.map((column) => ( + +
    +
    + {column.type === "empty" && } + {column.type === "player" && ( + {column.name} + )} +
    +
    +
    + ))} +
    + + {gameRows.map((item, index) => ( + + + {index + 1} + + {columns.map((column) => ( + + {item[column.id]} + + ))} + + ))} + + + + + + + + 合計 + {columns.map((column) => ( + + {totalPointsRow[column.id]} + + ))} + + + チップ + {columns.map((column) => ( + + {chipsRow[column.id]} + + ))} + + + 収支 + {columns.map((column) => ( + + {totalPointsRow[column.id]} + + ))} + + +
    +
    + + +
    +
    +
    + ); +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/loading.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/loading.tsx new file mode 100644 index 0000000..4349ac3 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null; +} diff --git a/app/(main)/(routes)/matches/(routes)/[matchId]/page.tsx b/app/(main)/(routes)/matches/(routes)/[matchId]/page.tsx new file mode 100644 index 0000000..f7b0680 --- /dev/null +++ b/app/(main)/(routes)/matches/(routes)/[matchId]/page.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { Suspense } from "react"; +import { Button } from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import { serverServices } from "@/lib/services/server"; +import { dayjs } from "@/lib/utils/date"; +import { ChipInputModal } from "./(components)/ChipInputModal"; +import { GameInputModal } from "./(components)/GameInputModal"; +import { MatchPlayerInputModal } from "./(components)/MatchPlayerInputModal"; +import { MatchTable } from "./(components)/MatchTable"; + +export default async function Match({ + params: { matchId }, +}: { + params: { matchId: string }; +}) { + const { getMatch } = serverServices(); + const [match] = await Promise.all([getMatch({ matchId })]); + + const { createdAt } = match; + + const today = dayjs(); + const targetDate = dayjs(createdAt); + const isSameYear = today.isSame(targetDate, "year"); + const displayDate = isSameYear + ? dayjs(createdAt).format("M/D") + : dayjs(createdAt).format("YYYY/M/D"); + + return ( +
    +
    +
    + +

    {displayDate}

    +
    +
    + +
    +
    + + + + + + + + + + + + +
    + ); +} diff --git a/app/(main)/(routes)/matches/loading.tsx b/app/(main)/(routes)/matches/loading.tsx new file mode 100644 index 0000000..dd0b43d --- /dev/null +++ b/app/(main)/(routes)/matches/loading.tsx @@ -0,0 +1,7 @@ +export default function Loading() { + return ( +
    +

    成績表

    +
    + ); +} diff --git a/app/(main)/(routes)/matches/page.tsx b/app/(main)/(routes)/matches/page.tsx new file mode 100644 index 0000000..2817e5b --- /dev/null +++ b/app/(main)/(routes)/matches/page.tsx @@ -0,0 +1,33 @@ +import { serverServices } from "@/lib/services/server"; +import { CreateMatchButton } from "./(components)/CreateMatchButton"; +import { MatchCard } from "./(components)/MatchCard"; + +export default async function Matches() { + const { getMatches } = serverServices(); + + // TODO: infinite scroll + const matches = await getMatches({}); + + return ( +
    +

    成績表

    + {matches.length === 0 && ( +

    + まだ成績表がありません。 +
    + 「ゲームを始める」ボタンから、新しい成績表を作成しましょう。 +

    + )} +
      + {matches?.map((match) => ( +
    • + +
    • + ))} +
    +
    + +
    +
    + ); +} diff --git a/app/(main)/actions.ts b/app/(main)/actions.ts new file mode 100644 index 0000000..9a95de4 --- /dev/null +++ b/app/(main)/actions.ts @@ -0,0 +1,17 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/utils/supabase/server"; + +export async function signOut() { + const supabase = createClient(); + + await supabase.auth.signOut(); + + /** + * @see https://nextjs.org/docs/app/api-reference/functions/revalidatePath#revalidating-all-data + */ + revalidatePath("/", "layout"); + redirect("/login"); +} diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx new file mode 100644 index 0000000..ce1864b --- /dev/null +++ b/app/(main)/layout.tsx @@ -0,0 +1,14 @@ +import Navbar from "./(components)/Navbar"; + +export default async function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
    + +
    {children}
    +
    + ); +} diff --git a/app/page.tsx b/app/(main)/page.tsx similarity index 50% rename from app/page.tsx rename to app/(main)/page.tsx index 7089e6a..a278d94 100644 --- a/app/page.tsx +++ b/app/(main)/page.tsx @@ -1,7 +1,9 @@ import Link from "next/link"; -import { getURL } from "~/lib/utils/url"; +import { Button } from "@/components/Button"; +import { getURL } from "@/lib/utils/url"; +import { signOut } from "./actions"; -export default function Root() { +export default function Page() { return ( <> login @@ -9,7 +11,11 @@ export default function Root() { client register matches + friends

    {getURL()}

    +
    + +
    ); } diff --git a/app/(register)/layout.tsx b/app/(register)/layout.tsx deleted file mode 100644 index c443549..0000000 --- a/app/(register)/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function RegisterLayout({ - children, -}: { - children: React.ReactNode; -}) { - return
    {children}
    ; -} diff --git a/app/(register)/register/ProfileForm.tsx b/app/(register)/register/ProfileForm.tsx deleted file mode 100644 index c493aba..0000000 --- a/app/(register)/register/ProfileForm.tsx +++ /dev/null @@ -1,222 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import classNames from "classnames"; -import { useRouter } from "next/navigation"; -import { useCallback, useState } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; -import { z } from "zod"; -import { Button } from "~/components/Button"; -import { Card, CardBody } from "~/components/Card"; -import { Icon } from "~/components/Icon"; -import { Input } from "~/components/Input"; -import { Popover, PopoverContent, PopoverTrigger } from "~/components/Popover"; -import { Progress } from "~/components/Progress"; -import { User } from "~/components/User"; -import { - ProfileUpdateSchema, - profileUpdateSchema, - useProfileUpdate, -} from "~/lib/hooks/api/profile"; -import { useProfileExists } from "~/lib/hooks/api/profiles"; - -const janrecoIdSchema = profileUpdateSchema.pick({ janrecoId: true }); -type JanrecoIdSchema = z.infer; -const nameSchema = profileUpdateSchema.pick({ name: true }); -type NameSchema = z.infer; - -export default function ProfileForm({ - className, - userId, -}: { - className?: string; - userId: string; -}) { - const router = useRouter(); - const [step, setStep] = useState(1); - const progress = (100 / 3) * step; - - const [janrecoId, setJanrecoId] = - useState(); - const [name, setName] = useState(); - - const { trigger: getProfileExists } = useProfileExists(); - - const { trigger: updateProfile, isMutating: isUpdatingProfile } = - useProfileUpdate({ profileId: userId }); - - const janrecoIdForm = useForm({ - resolver: zodResolver(janrecoIdSchema), - mode: "onChange", - }); - - const onJanrecoIdSubmit: SubmitHandler = async (data) => { - const exists = await getProfileExists({ janrecoId: data.janrecoId }); - if (exists) { - janrecoIdForm.setError("janrecoId", { - type: "manual", - message: "このIDは既に使用されています", - }); - return; - } - setJanrecoId(data.janrecoId); - setStep((prev) => prev + 1); - }; - - const nameForm = useForm({ - resolver: zodResolver(nameSchema), - mode: "onChange", - }); - - const onNameSubmit: SubmitHandler = async (data) => { - setName(data.name); - setStep((prev) => prev + 1); - }; - - const handleConfirmClick = useCallback(async () => { - if (!janrecoId || !name) { - return; - } - await updateProfile({ janrecoId, name }); - router.push("/"); - }, [janrecoId, name, router, updateProfile]); - - const handlePrevClick = useCallback(() => { - setStep((prev) => prev - 1); - }, []); - - return ( -
    - -
    -
    - {/* janrecoIdForm */} - {step === 1 && ( -
    -
    -

    - ユーザーIDを決めてください -

    - - - - - -
    - ユーザーIDは、他のユーザーがあなたを検索するために使用されます。あとから変更することも可能です。 -
    -
    -
    -
    -
    - - @ -
    - } - description="半角英数字4~12文字で入力してください" - maxLength={12} - {...janrecoIdForm.register("janrecoId")} - errorMessage={ - janrecoIdForm.formState.errors.janrecoId?.message - } - autoFocus - /> -
    - -
    -
    - - )} - {/* nameForm */} - {step === 2 && ( -
    -
    -

    名前を決めてください

    - - - - - -
    - 名前は成績表に表示されます。あとから変更することも可能です。 -
    -
    -
    -
    -
    - -
    - - -
    -
    -
    - )} - {/* confirm */} - {step === 3 && !!name && !!janrecoId && ( - <> -
    -

    - こちらでよろしいですか? -

    -

    - あとから変更できます -

    -
    -
    - - -
    - -
    -
    -
    -
    - - -
    -
    - - )} -
    -
    - - ); -} diff --git a/app/(register)/register/page.tsx b/app/(register)/register/page.tsx deleted file mode 100644 index eba5676..0000000 --- a/app/(register)/register/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Metadata } from "next"; -import { redirect } from "next/navigation"; -import Logo from "~/components/Logo"; -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/serverComponentClient"; -import ProfileForm from "./ProfileForm"; - -export const metadata: Metadata = { - title: "ユーザー情報登録", -}; - -export default async function Register() { - const supabaseClient = createSupabaseClient(); - const { getUserProfile } = services(supabaseClient); - const user = await getUserProfile(); - - if (user.name && user.janrecoId) { - redirect("/"); - } - - return ( -
    - - -
    - ); -} diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts index 601520a..9a884af 100644 --- a/app/api/auth/callback/route.ts +++ b/app/api/auth/callback/route.ts @@ -1,22 +1,29 @@ -/** - * @see https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange - */ -import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; -import { cookies } from "next/headers"; import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - -export const dynamic = "force-dynamic"; +import { serverServices } from "@/lib/services/server"; +import { createClient } from "@/lib/utils/supabase/server"; -export async function GET(request: NextRequest) { - const requestUrl = new URL(request.url); - const code = requestUrl.searchParams.get("code"); +/** + * @see https://supabase.com/docs/guides/auth/server-side/oauth-with-pkce-flow-for-ssr + */ +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get("code"); + // if "next" is in param, use it as the redirect URL + const next = searchParams.get("next") ?? "/"; if (code) { - const supabase = createRouteHandlerClient({ cookies }); - await supabase.auth.exchangeCodeForSession(code); + const supabase = createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + const { getUserProfile } = serverServices(); + const profile = await getUserProfile(); + if (profile.isUnregistered) { + return NextResponse.redirect(`${origin}/register`); + } + return NextResponse.redirect(`${origin}${next}`); + } } - // URL to redirect to after sign in process completes - return NextResponse.redirect(requestUrl.origin); + // return the user to an error page with instructions + return NextResponse.redirect(`${origin}/auth-code-error`); } diff --git a/app/api/auth/sign-in/route.ts b/app/api/auth/sign-in/route.ts deleted file mode 100644 index a31794b..0000000 --- a/app/api/auth/sign-in/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; -import { cookies } from "next/headers"; -import { NextRequest } from "next/server"; -import type { Database } from "~/lib/database.types"; - -export const dynamic = "force-dynamic"; - -export async function POST(request: NextRequest) { - const { email, password } = await request.json(); - const supabase = createRouteHandlerClient({ cookies }); - - const { error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (error) { - return new Response(error.message, { - status: error.status, - }); - } - - return new Response(null, { - status: 200, - }); -} diff --git a/app/api/auth/sign-out/route.ts b/app/api/auth/sign-out/route.ts deleted file mode 100644 index bd024c9..0000000 --- a/app/api/auth/sign-out/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; -import { cookies } from "next/headers"; -import { NextResponse } from "next/server"; -import type { Database } from "~/lib/database.types"; - -export const dynamic = "force-dynamic"; - -export async function POST(request: Request) { - const requestUrl = new URL(request.url); - const supabase = createRouteHandlerClient({ cookies }); - - await supabase.auth.signOut(); - - return NextResponse.redirect(`${requestUrl.origin}/`, { - status: 301, - }); -} diff --git a/app/api/auth/sign-up/route.ts b/app/api/auth/sign-up/route.ts deleted file mode 100644 index f24fa7c..0000000 --- a/app/api/auth/sign-up/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; -import { cookies } from "next/headers"; -import type { Database } from "~/lib/database.types"; - -export const dynamic = "force-dynamic"; - -export async function POST(request: Request) { - const { email, password } = await request.json(); - const supabase = createRouteHandlerClient({ cookies }); - - const { error } = await supabase.auth.signUp({ - email, - password, - }); - - if (error) { - return new Response(error.message, { - status: error.status, - }); - } - - return new Response(null, { - status: 200, - }); -} diff --git a/app/api/friends/route.ts b/app/api/friends/route.ts deleted file mode 100644 index 9427be7..0000000 --- a/app/api/friends/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextResponse } from "next/server"; -import { services } from "~/lib/services"; -import { AddFriendsPayload } from "~/lib/services/friends"; -import { createSupabaseClient } from "~/lib/utils/supabase/routeHandlerClient"; - -export async function GET() { - const supabaseClient = createSupabaseClient(); - const { getFriends } = services(supabaseClient); - const data = await getFriends(); - return NextResponse.json(data); -} - -export async function POST(request: Request) { - const supabaseClient = createSupabaseClient(); - const { addFriends } = services(supabaseClient); - const body = (await request.json()) as AddFriendsPayload; - await addFriends(body); - return NextResponse.json({}); -} - -export async function DELETE(request: Request) { - const { searchParams } = new URL(request.url); - const profileId = searchParams.get("profileId"); - if (!profileId) { - return NextResponse.json({ - error: "profileId is required", - status: 400, - }); - } - const supabaseClient = createSupabaseClient(); - const { deleteFriends } = services(supabaseClient); - await deleteFriends({ profileId }); - return NextResponse.json({}); -} diff --git a/app/api/matches/[matchId]/games/route.ts b/app/api/matches/[matchId]/games/route.ts deleted file mode 100644 index e00fc06..0000000 --- a/app/api/matches/[matchId]/games/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextResponse } from "next/server"; -import { services } from "~/lib/services"; -import { Rule } from "~/lib/services/match"; -import { createSupabaseClient } from "~/lib/utils/supabase/routeHandlerClient"; -export const dynamic = "force-dynamic"; - -export async function POST( - request: Request, - { params: { matchId } }: { params: { matchId: string } }, -) { - const supabaseClient = createSupabaseClient(); - const { createGame } = services(supabaseClient); - const body = (await request.json()) as { - playerPoints: { - profileId: string; - points: number; - }[]; - rule: Rule; - crackBoxPlayerId?: string; - }; - await createGame({ - matchId, - ...body, - }); - return NextResponse.json({}); -} - -export async function GET( - request: Request, - { params: { matchId } }: { params: { matchId: string } }, -) { - const supabaseClient = createSupabaseClient(); - const { getGames } = services(supabaseClient); - const match = await getGames({ matchId }); - return NextResponse.json(match); -} diff --git a/app/api/matches/[matchId]/players/route.ts b/app/api/matches/[matchId]/players/route.ts deleted file mode 100644 index b6a9b5a..0000000 --- a/app/api/matches/[matchId]/players/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextResponse } from "next/server"; -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/routeHandlerClient"; -export const dynamic = "force-dynamic"; - -export async function POST( - request: Request, - { params: { matchId } }: { params: { matchId: string } }, -) { - const supabaseClient = createSupabaseClient(); - const { addMatchPlayer } = services(supabaseClient); - const body = (await request.json()) as { profileId: string }; - await addMatchPlayer({ - matchId, - profileId: body.profileId, - }); - return NextResponse.json({}); -} diff --git a/app/api/matches/[matchId]/route.ts b/app/api/matches/[matchId]/route.ts deleted file mode 100644 index b6657b2..0000000 --- a/app/api/matches/[matchId]/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NextResponse } from "next/server"; -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/routeHandlerClient"; - -export async function GET( - request: Request, - { params: { matchId } }: { params: { matchId: string } }, -) { - const supabaseClient = createSupabaseClient(); - const { getMatch } = services(supabaseClient); - const data = await getMatch({ matchId }); - return NextResponse.json(data); -} diff --git a/app/api/matches/route.ts b/app/api/matches/route.ts deleted file mode 100644 index 0c54594..0000000 --- a/app/api/matches/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextResponse } from "next/server"; -import { services } from "~/lib/services"; -import { CreateMatchPayload } from "~/lib/services/matches"; -import { createSupabaseClient } from "~/lib/utils/supabase/routeHandlerClient"; - -export async function GET() { - const supabaseClient = createSupabaseClient(); - const { getMatches } = services(supabaseClient); - const data = await getMatches(); - return NextResponse.json(data); -} - -export async function POST(request: Request) { - const supabaseClient = createSupabaseClient(); - const { createMatch } = services(supabaseClient); - const body = (await request.json()) as CreateMatchPayload; - const data = await createMatch(body); - return NextResponse.json(data); -} diff --git a/app/api/profiles/[profileId]/route.ts b/app/api/profiles/[profileId]/route.ts deleted file mode 100644 index 5c04875..0000000 --- a/app/api/profiles/[profileId]/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextResponse } from "next/server"; -import { services } from "~/lib/services"; -import { UpdateProfilePayload } from "~/lib/services/profile"; -import { createSupabaseClient } from "~/lib/utils/supabase/routeHandlerClient"; - -export async function POST( - request: Request, - { params: { profileId } }: { params: { profileId: string } }, -) { - const supabaseClient = createSupabaseClient(); - const { updateProfile } = services(supabaseClient); - const body = (await request.json()) as { name: string; janrecoId: string }; - const payload: UpdateProfilePayload = { - id: profileId, - name: body.name, - janrecoId: body.janrecoId, - }; - await updateProfile(payload); - return NextResponse.json({}); -} diff --git a/app/api/profiles/exists/route.ts b/app/api/profiles/exists/route.ts deleted file mode 100644 index 1e40a8a..0000000 --- a/app/api/profiles/exists/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/routeHandlerClient"; - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const janrecoId = searchParams.get("janrecoId") || ""; - const supabaseClient = createSupabaseClient(); - const { getProfileExists } = services(supabaseClient); - const data = await getProfileExists({ janrecoId }); - return NextResponse.json(data); -} diff --git a/app/api/profiles/route.ts b/app/api/profiles/route.ts deleted file mode 100644 index 3b27cd6..0000000 --- a/app/api/profiles/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NextResponse } from "next/server"; -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/routeHandlerClient"; - -export async function POST(request: Request) { - const supabaseClient = createSupabaseClient(); - const { createProfile } = services(supabaseClient); - const body = (await request.json()) as { - name: string; - }; - const data = await createProfile(body); - return NextResponse.json(data); -} diff --git a/app/api/profiles/search/route.ts b/app/api/profiles/search/route.ts deleted file mode 100644 index c4f5572..0000000 --- a/app/api/profiles/search/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { services } from "~/lib/services"; -import { createSupabaseClient } from "~/lib/utils/supabase/routeHandlerClient"; - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const text = searchParams.get("text") || ""; - const supabaseClient = createSupabaseClient(); - const { searchProfiles } = services(supabaseClient); - const data = await searchProfiles({ text }); - return NextResponse.json(data); -} diff --git a/app/client/page.tsx b/app/client/page.tsx deleted file mode 100644 index b1ee26f..0000000 --- a/app/client/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { - User, - createClientComponentClient, -} from "@supabase/auth-helpers-nextjs"; -import Link from "next/link"; -import { useEffect, useState } from "react"; -import type { Database } from "~/lib/database.types"; - -type Todo = Database["public"]["Tables"]["matches"]["Row"]; - -export default function Page() { - const [todos, setTodos] = useState(null); - const [user, setUser] = useState(null); - const supabase = createClientComponentClient(); - - useEffect(() => { - const getData = async () => { - const { data } = await supabase.from("matches").select(); - setTodos(data); - }; - const getUser = async () => { - const { data } = await supabase.auth.getUser(); - setUser(data.user); - }; - - getData(); - getUser(); - }, [supabase]); - - return todos ? ( - <> - root - server -
    {JSON.stringify(todos, null, 2)}
    -

    {user?.email}

    - - ) : ( -

    Loading todos...

    - ); -} diff --git a/app/fonts.ts b/app/fonts.ts index bdc0ee0..a30e66a 100644 --- a/app/fonts.ts +++ b/app/fonts.ts @@ -1,4 +1,4 @@ -import { Noto_Sans_JP, Righteous } from "next/font/google"; +import { Noto_Sans_JP, Righteous, M_PLUS_1p } from "next/font/google"; export const notoSansJp = Noto_Sans_JP({ variable: "--font-noto-sans-jp", @@ -13,3 +13,10 @@ export const righteous = Righteous({ subsets: ["latin"], display: "swap", }); + +export const mPlus1p = M_PLUS_1p({ + variable: "--font-m-plus-1p", + weight: ["400", "500", "700"], + subsets: ["latin"], + display: "swap", +}); diff --git a/app/globals.css b/app/globals.css index 1ccbecd..045d29b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -22,7 +22,7 @@ } .heading-1 { - @apply text-xl font-bold; + @apply text-medium text-foreground-light font-bold; } } @@ -30,4 +30,8 @@ .z-header { @apply z-10; } + + .break-auto { + word-break: auto-phrase; + } } diff --git a/app/layout.tsx b/app/layout.tsx index f4d56e4..7d9125b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,8 @@ import classNames from "classnames"; import { Metadata } from "next"; -import { IconDefs } from "~/components/Icon"; -import { ToastContainer } from "~/lib/toast"; -import { notoSansJp, righteous } from "./fonts"; +import { IconDefs } from "@/components/Icon"; +import { ToastContainer } from "@/lib/toast"; +import { righteous, mPlus1p } from "./fonts"; import "./globals.css"; import { NextUIProvider } from "./nextUiProvider"; @@ -22,13 +22,13 @@ export default async function RootLayout({ return ( - + -
    {children}
    +
    {children}
    diff --git a/app/server/data.tsx b/app/server/data.tsx deleted file mode 100644 index 9ae40fb..0000000 --- a/app/server/data.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; -import { cookies } from "next/headers"; -import { Database } from "~/lib/database.types"; - -export default async function Data() { - const supabase = createServerComponentClient({ cookies }); - const { data } = await supabase.from("matches").select(); - return
    {JSON.stringify(data, null, 2)}
    ; -} diff --git a/app/server/page.tsx b/app/server/page.tsx deleted file mode 100644 index f9a7ae0..0000000 --- a/app/server/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Link from "next/link"; -import { Suspense } from "react"; -import Data from "./data"; -import User from "./user"; - -export const revalidate = 0; - -export default async function ServerComponent() { - return ( - <> - root - client - Loading User...

    }> - -
    - Loading Data...

    }> - -
    - - ); -} diff --git a/app/server/user.tsx b/app/server/user.tsx deleted file mode 100644 index fe85c0b..0000000 --- a/app/server/user.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; -import { cookies } from "next/headers"; -import { Database } from "~/lib/database.types"; - -export default async function User() { - const supabase = createServerComponentClient({ cookies }); - const user = await supabase.auth.getUser(); - return
    {user.data.user?.email}
    ; -} diff --git a/components/AppHeader/index.tsx b/components/AppHeader/index.tsx deleted file mode 100644 index 7cff7b4..0000000 --- a/components/AppHeader/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import Logo from "../Logo"; - -export function AppHeader() { - return ( -
    -
    - -
    -
    - ); -} diff --git a/components/Avatar/index.tsx b/components/Avatar/index.tsx new file mode 100644 index 0000000..957069d --- /dev/null +++ b/components/Avatar/index.tsx @@ -0,0 +1 @@ +export { Avatar, AvatarGroup } from "@nextui-org/react"; diff --git a/components/BottomNavigation/index.tsx b/components/BottomNavigation/index.tsx index 4742216..f13d218 100644 --- a/components/BottomNavigation/index.tsx +++ b/components/BottomNavigation/index.tsx @@ -6,11 +6,11 @@ export function BottomNavigation() {