diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..abfdc28 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_SUPABASE_URL= +VITE_SUPABASE_ANON_KEY= diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a4b7629..8adc462 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Setup | Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 # node-version-file: .nvmrc diff --git a/README.md b/README.md index abff58e..81797a2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Mac や WSL では [mise](https://mise.jdx.dev/getting-started.html) 等を経 ```sh npm install ``` +`.env.example` ファイルをコピーして `.env` ファイルを作成し、環境変数を設定してください。(イコールの間に空白を入れないよう注意) ## 開発 diff --git a/app/components/Loading.tsx b/app/components/Loading.tsx new file mode 100644 index 0000000..77705a9 --- /dev/null +++ b/app/components/Loading.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export const Loading = (): ReactNode => ( +
+
+
+); diff --git a/app/hooks/useSession.ts b/app/hooks/useSession.ts new file mode 100644 index 0000000..d02ea15 --- /dev/null +++ b/app/hooks/useSession.ts @@ -0,0 +1,41 @@ +import type { Session } from "@supabase/supabase-js"; +import useSWRImmutable from "swr/immutable"; +import useSWRSubscription from "swr/subscription"; +import type { SWRSubscriptionOptions } from "swr/subscription"; +import { supabase } from "~/libs/supabase"; + +export function useSession(): Session | null { + const key = "session"; + const { data: initialSession } = useSWRImmutable( + key, + async () => { + const { + data: { session }, + } = await supabase.auth.getSession(); + + return session; + }, + { + suspense: true, + }, + ); + + const { data: session } = useSWRSubscription( + key, + (_, { next }: SWRSubscriptionOptions) => { + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + next(null, session); + }); + + return subscription.unsubscribe; + }, + { + suspense: true, + fallbackData: initialSession, + }, + ); + + return session ?? null; +} diff --git a/app/libs/getAccountCredentials.ts b/app/libs/getAccountCredentials.ts new file mode 100644 index 0000000..5933964 --- /dev/null +++ b/app/libs/getAccountCredentials.ts @@ -0,0 +1,11 @@ +import type { SignInWithPasswordCredentials } from "@supabase/supabase-js"; + +export function getAccountCredentials( + id: string, +): SignInWithPasswordCredentials { + return { + // supabase は userId のみでサインインできないため、強引にやる + email: `email-${id}@example.com`, + password: "password", + }; +} diff --git a/app/libs/supabase.ts b/app/libs/supabase.ts new file mode 100644 index 0000000..e5bd544 --- /dev/null +++ b/app/libs/supabase.ts @@ -0,0 +1,6 @@ +import { createClient } from "@supabase/supabase-js"; + +export const supabase = createClient( + import.meta.env.VITE_SUPABASE_URL, + import.meta.env.VITE_SUPABASE_ANON_KEY, +); diff --git a/app/root.tsx b/app/root.tsx index 7f66a39..6fd6ddc 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -6,6 +6,8 @@ import { ScrollRestoration, } from "@remix-run/react"; import "./tailwind.css"; +import { Suspense } from "react"; +import { Loading } from "./components/Loading"; export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -26,5 +28,9 @@ export function Layout({ children }: { children: React.ReactNode }) { } export default function App() { - return ; + return ( + }> + + + ); } diff --git a/app/routes/_auth._index.tsx b/app/routes/_auth._index.tsx new file mode 100644 index 0000000..02f871c --- /dev/null +++ b/app/routes/_auth._index.tsx @@ -0,0 +1,43 @@ +import type { MetaFunction } from "@remix-run/node"; +import { useSession } from "~/hooks/useSession"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + const session = useSession(); + + // 未サインインの場合 + if (!session) + return ( +
+

Game

+
+ + 匿名で登録 + +
+
+ ); + + // サインイン済みの場合 + return ( +
+

Game

+
+ Hello World +
+
+ ID: {session.user.email?.replace("@example.com", "")} +
+
+ ); +} diff --git a/app/routes/_auth.tsx b/app/routes/_auth.tsx new file mode 100644 index 0000000..996feb4 --- /dev/null +++ b/app/routes/_auth.tsx @@ -0,0 +1,17 @@ +import { Outlet, useNavigate } from "@remix-run/react"; +import { type ReactNode, useLayoutEffect } from "react"; +import { useSession } from "~/hooks/useSession"; + +// _auth.**.tsx のパスへのアクセスは必ずここで前処理される +export default function Layout(): ReactNode { + const navigate = useNavigate(); + const session = useSession(); + + useLayoutEffect(() => { + if (!session) { + navigate("/signup"); + } + }, [session, navigate]); + + return ; +} diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx deleted file mode 100644 index 76d0055..0000000 --- a/app/routes/_index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { MetaFunction } from "@remix-run/node"; - -export const meta: MetaFunction = () => { - return [ - { title: "New Remix App" }, - { name: "description", content: "Welcome to Remix!" }, - ]; -}; - -export default function Index() { - return ( -
-

Welcome to Remix

- -
- ); -} diff --git a/app/routes/_noauth.signin.tsx b/app/routes/_noauth.signin.tsx new file mode 100644 index 0000000..c3f8269 --- /dev/null +++ b/app/routes/_noauth.signin.tsx @@ -0,0 +1,55 @@ +import { useNavigate } from "@remix-run/react"; +import type { ReactNode } from "react"; +import { useForm } from "react-hook-form"; +import { getAccountCredentials } from "~/libs/getAccountCredentials"; +import { supabase } from "~/libs/supabase"; + +type FormValues = { + id: string; +}; + +export default function Signin(): ReactNode { + // ID でサインイン、デバッグ用(そもそもログアウトは想定していない) + const navigate = useNavigate(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + mode: "onBlur", + }); + + const onSubmit = async ({ id }: FormValues) => { + const { error } = await supabase.auth.signInWithPassword( + getAccountCredentials(id), + ); + + if (error) { + console.error(error); + return; + } + + navigate("/"); + }; + + return ( +
+

Game

+

ID でサインイン

+
+ +
+ +
+ ); +} diff --git a/app/routes/_noauth.signup.tsx b/app/routes/_noauth.signup.tsx new file mode 100644 index 0000000..d43eab7 --- /dev/null +++ b/app/routes/_noauth.signup.tsx @@ -0,0 +1,34 @@ +import { useNavigate } from "@remix-run/react"; +import { v4 } from "uuid"; +import { getAccountCredentials } from "~/libs/getAccountCredentials"; +import { supabase } from "~/libs/supabase"; + +export default function Signup() { + const navigate = useNavigate(); + + const signUp = async () => { + const id = v4(); + const { error } = await supabase.auth.signUp(getAccountCredentials(id)); + + if (error) { + console.error("Error signing up:", error.message); + return; + } + + navigate("/"); + }; + return ( +
+

Game

+
+ +
+
+ ); +} diff --git a/app/routes/_noauth.tsx b/app/routes/_noauth.tsx new file mode 100644 index 0000000..24dd24a --- /dev/null +++ b/app/routes/_noauth.tsx @@ -0,0 +1,19 @@ +import { Outlet, useNavigate } from "@remix-run/react"; +import type { ReactNode } from "react"; +import { useLayoutEffect } from "react"; + +import { useSession } from "~/hooks/useSession"; + +// _noauth.**.tsx のパスへのアクセスは必ずここで前処理される +export default function Layout(): ReactNode { + const navigate = useNavigate(); + const session = useSession(); + + useLayoutEffect(() => { + if (session) { + navigate("/"); + } + }, [session, navigate]); + + return ; +} diff --git a/biome.jsonc b/biome.jsonc index 8f255b3..b7f6fa3 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -6,7 +6,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "nursery": { + "useSortedClasses": "warn" + } } }, "formatter": { diff --git a/package-lock.json b/package-lock.json index d47a815..6403d9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,14 @@ "@remix-run/react": "^2.12.0", "@remix-run/serve": "^2.12.0", "@supabase/supabase-js": "^2.45.3", + "@types/uuid": "^10.0.0", "isbot": "^4.1.0", "qrcode.react": "^4.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "swr": "^2.2.5" + "react-hook-form": "^7.53.0", + "swr": "^2.2.5", + "uuid": "^10.0.0" }, "devDependencies": { "@biomejs/biome": "^1.8.3", @@ -2867,6 +2870,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", @@ -10533,6 +10542,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.53.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz", + "integrity": "sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -12533,6 +12558,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/uvu": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", diff --git a/package.json b/package.json index 88a677d..4b3c4ad 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,21 @@ "start": "remix-serve ./build/server/index.js", "typecheck": "tsc", "lint": "biome check .", - "fmt": "biome check . --write" + "fmt": "biome check . --write --unsafe" }, "dependencies": { "@remix-run/node": "^2.12.0", "@remix-run/react": "^2.12.0", "@remix-run/serve": "^2.12.0", "@supabase/supabase-js": "^2.45.3", + "@types/uuid": "^10.0.0", "isbot": "^4.1.0", "qrcode.react": "^4.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "swr": "^2.2.5" + "react-hook-form": "^7.53.0", + "swr": "^2.2.5", + "uuid": "^10.0.0" }, "devDependencies": { "@biomejs/biome": "^1.8.3", diff --git a/vite.config.ts b/vite.config.ts index e07fb91..2713971 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ v3_relativeSplatPath: true, v3_throwAbortReason: true, }, + ssr: false, }), tsconfigPaths(), ],