diff --git a/bun.lock b/bun.lock index 856f22b..7fe9385 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,8 @@ "@better-auth/passkey": "^1.6.9", "@elysia/eden": "^1.4.10", "@tiptap/core": "^3.22.4", + "@tiptap/extension-link": "^3.22.4", + "@tiptap/extension-placeholder": "^3.22.4", "@tiptap/pm": "^3.22.4", "@tiptap/react": "^3.22.4", "@tiptap/starter-kit": "^3.22.4", @@ -633,6 +635,8 @@ "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-de6dFkIhigiENESY6rNJ3yTVS/337ybfP30dNPudTwGe9oAu9ZCS+04j6QCvXSjhlI3ULiv7wiSHqrP26Gd+Hw=="], + "@tiptap/extension-placeholder": ["@tiptap/extension-placeholder@3.22.4", "", { "peerDependencies": { "@tiptap/extensions": "3.22.4" } }, "sha512-Z3wtWL+KufwkC7CkJge5enAxx4q8C3oOYixme02snY9zfjX3V/1pjAmEfP4wxScgM5GIuTEJ83B9Yz3wRzPA6Q=="], + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="], "@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="], diff --git a/components/profile/settings-accounts-tab.tsx b/components/profile/settings-accounts-tab.tsx index d2a45f6..5385480 100644 --- a/components/profile/settings-accounts-tab.tsx +++ b/components/profile/settings-accounts-tab.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useCallback } from "react" +import { useState, useEffect } from "react" import { Loader2, Link as LinkIcon, Unlink, Shield } from "lucide-react" import { motion } from "motion/react" import { FaGoogle, FaDiscord } from "react-icons/fa" @@ -33,7 +33,7 @@ export function SettingsAccountsTab() { const [unlinking, setUnlinking] = useState(null) const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) - const fetchData = useCallback(async () => { + const refreshData = async () => { try { const [accountsRes, methodsRes] = await Promise.all([ fetch("/api/auth/list-accounts", { credentials: "include" }), @@ -49,16 +49,26 @@ export function SettingsAccountsTab() { const data = await methodsRes.json() setAuthMethods(data) } - } catch (err) { - console.error("Failed to fetch account data:", err) - } finally { - setLoading(false) + } catch { + // silently fail } - }, []) + } useEffect(() => { - fetchData() - }, [fetchData]) + if (accounts.length > 0 || authMethods !== null) return + let cancelled = false + Promise.all([ + fetch("/api/auth/list-accounts", { credentials: "include" }).then(r => r.ok ? r.json() : []).catch(() => []), + fetch("/api/user/me/auth-methods").then(r => r.ok ? r.json() : null).catch(() => null), + ]).then(([accountsData, methodsData]) => { + if (cancelled) return + if (accountsData) setAccounts(Array.isArray(accountsData) ? accountsData : []) + if (methodsData) setAuthMethods(methodsData) + setLoading(false) + }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: fetch only on mount + }, []) const handleLink = async (provider: "google" | "discord") => { try { @@ -81,9 +91,9 @@ export function SettingsAccountsTab() { const data = await res.json() // If the API returns a URL, redirect to it if (data && data.url) { - window.location.href = data.url + window.location.assign(data.url as string) } - } catch (err) { + } catch { setMessage({ type: "error", text: "Failed to initiate account linking" }) } } @@ -104,7 +114,7 @@ export function SettingsAccountsTab() { if (res.ok) { setMessage({ type: "success", text: `${providerConfig[providerId]?.name || providerId} account unlinked` }) - await fetchData() + await refreshData() } else { const data = await res.json() setMessage({ type: "error", text: data.message || "Failed to unlink account" }) diff --git a/components/profile/settings-security-tab.tsx b/components/profile/settings-security-tab.tsx index a37f3c3..ec384d3 100644 --- a/components/profile/settings-security-tab.tsx +++ b/components/profile/settings-security-tab.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useCallback } from "react" +import { useState, useRef, useEffect } from "react" import { authClient } from "@/lib/auth-client" import { Loader2, Key, Fingerprint, Plus, Trash2, Pencil, Check, X, Shield } from "lucide-react" import { motion, AnimatePresence } from "motion/react" @@ -39,42 +39,38 @@ export function SettingsSecurityTab() { const [isDeletingPasskey, setIsDeletingPasskey] = useState(null) const [isAddingPasskey, setIsAddingPasskey] = useState(false) - const fetchAuthMethods = useCallback(async () => { - try { - const res = await fetch("/api/user/me/auth-methods", { - credentials: "include", - }) - if (res.ok) { - const data = await res.json() - setAuthMethods(data) - } - } catch { - // silently fail - } finally { - setIsLoadingAuthMethods(false) - } - }, []) - - const fetchPasskeys = useCallback(async () => { - try { - const res = await fetch("/api/auth/passkey/list-user-passkeys", { - credentials: "include", - }) - if (res.ok) { - const data = await res.json() - setPasskeys(Array.isArray(data) ? data : []) - } - } catch { - // silently fail - } finally { - setIsLoadingPasskeys(false) - } - }, []) - + const fetchRef = useRef(false) useEffect(() => { - fetchAuthMethods() - fetchPasskeys() - }, [fetchAuthMethods, fetchPasskeys]) + if (fetchRef.current) return + fetchRef.current = true + + let cancelled = false + Promise.all([ + fetch("/api/user/me/auth-methods", { credentials: "include" }).then(r => r.ok ? r.json() : null).catch(() => null), + fetch("/api/auth/passkey/list-user-passkeys", { credentials: "include" }).then(r => r.ok ? r.json() : []).catch(() => []), + ]).then(([methodsData, passkeyData]) => { + if (cancelled) return + if (methodsData) setAuthMethods(methodsData) + if (passkeyData) setPasskeys(Array.isArray(passkeyData) ? passkeyData : []) + setIsLoadingAuthMethods(false) + setIsLoadingPasskeys(false) + }) + + return () => { cancelled = true } + }, []) + + const refreshAuthMethods = async () => { + const res = await fetch("/api/user/me/auth-methods", { credentials: "include" }) + if (res.ok) setAuthMethods(await res.json()) + } + + const refreshPasskeys = async () => { + const res = await fetch("/api/auth/passkey/list-user-passkeys", { credentials: "include" }) + if (res.ok) { + const data = await res.json() + setPasskeys(Array.isArray(data) ? data : []) + } + } const handlePasswordSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -121,7 +117,7 @@ export function SettingsSecurityTab() { setPasswordMessage({ type: "success", text: "Password set successfully" }) setNewPassword("") setConfirmPassword("") - await fetchAuthMethods() + await refreshAuthMethods() } } @@ -133,11 +129,10 @@ export function SettingsSecurityTab() { try { const { error } = await authClient.passkey.addPasskey() if (error) { - // eslint-disable-next-line no-console console.error("Failed to add passkey:", error) } else { - await fetchPasskeys() - await fetchAuthMethods() + await refreshPasskeys() + await refreshAuthMethods() } } catch { // silently fail @@ -158,8 +153,8 @@ export function SettingsSecurityTab() { credentials: "include", }) if (res.ok) { - await fetchPasskeys() - await fetchAuthMethods() + await refreshPasskeys() + await refreshAuthMethods() } } catch { // silently fail @@ -188,7 +183,7 @@ export function SettingsSecurityTab() { credentials: "include", }) if (res.ok) { - await fetchPasskeys() + await refreshPasskeys() setEditingPasskeyId(null) setEditingName("") } diff --git a/drizzle/0005_brief_marvel_zombies.sql b/drizzle/0005_brief_marvel_zombies.sql new file mode 100644 index 0000000..a2ca83b --- /dev/null +++ b/drizzle/0005_brief_marvel_zombies.sql @@ -0,0 +1,11 @@ +CREATE TABLE "saved_games" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "game_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "saved_games_user_game_unique" UNIQUE("user_id","game_id") +); +--> statement-breakpoint +ALTER TABLE "performance_entries" ADD COLUMN "launch_options" text;--> statement-breakpoint +ALTER TABLE "saved_games" ADD CONSTRAINT "saved_games_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "saved_games" ADD CONSTRAINT "saved_games_game_id_games_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json index 6101d47..aaeea90 100644 --- a/drizzle/meta/0005_snapshot.json +++ b/drizzle/meta/0005_snapshot.json @@ -1,6 +1,6 @@ { - "id": "7f7353e5-646d-4e21-92e6-250b5b28ee50", - "prevId": "273a7a41-0813-4612-ad50-bc53aa0eac5f", + "id": "66b4e2ff-53cc-46d0-a9fd-321a378683f1", + "prevId": "7f7353e5-646d-4e21-92e6-250b5b28ee50", "version": "7", "dialect": "postgresql", "tables": { @@ -1282,6 +1282,12 @@ "primaryKey": false, "notNull": false }, + "launch_options": { + "name": "launch_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, "settings_json": { "name": "settings_json", "type": "jsonb", @@ -1460,6 +1466,80 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "public.saved_games": { + "name": "saved_games", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "saved_games_user_id_user_id_fk": { + "name": "saved_games_user_id_user_id_fk", + "tableFrom": "saved_games", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_games_game_id_games_id_fk": { + "name": "saved_games_game_id_games_id_fk", + "tableFrom": "saved_games", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "saved_games_user_game_unique": { + "name": "saved_games_user_game_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "game_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": { diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a77578a..82711c6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1777189057323, "tag": "0005_add_preset_performance_link", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1777217663641, + "tag": "0005_brief_marvel_zombies", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/eden.ts b/lib/eden.ts index 5ca20cc..730dd7c 100644 --- a/lib/eden.ts +++ b/lib/eden.ts @@ -7,5 +7,5 @@ import type { App } from "@/app/api/[[...slugs]]/route" export const api = typeof window === "undefined" ? // eslint-disable-next-line @typescript-eslint/no-require-imports - treaty((require("../../app/api/[[...slugs]]/route") as { app: App }).app).api + treaty((require("../app/api/[[...slugs]]/route") as { app: App }).app).api : treaty(process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000").api diff --git a/package.json b/package.json index 315545f..8a9d73b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@better-auth/passkey": "^1.6.9", "@elysia/eden": "^1.4.10", "@tiptap/core": "^3.22.4", + "@tiptap/extension-link": "^3.22.4", + "@tiptap/extension-placeholder": "^3.22.4", "@tiptap/pm": "^3.22.4", "@tiptap/react": "^3.22.4", "@tiptap/starter-kit": "^3.22.4",