diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index b5a5138..28aec4c 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -36,7 +36,13 @@ export default [ route("notifications", "routes/notifications.tsx"), route("api/notifications/:id/read", "routes/api.notifications.$id.read.ts"), route("api/notifications/read-all", "routes/api.notifications.read-all.ts"), - route("settings", "routes/settings.tsx"), + route("settings", "routes/settings.tsx", [ + index("routes/settings._index.tsx"), + route("profile", "routes/settings.profile.tsx"), + route("account", "routes/settings.account.tsx"), + route("security", "routes/settings.security.tsx"), + route("connections", "routes/settings.connections.tsx"), + ]), route("api/settings/profile", "routes/api.settings.profile.ts"), route("api/settings/email", "routes/api.settings.email.ts"), route("api/settings/passkey/delete", "routes/api.settings.passkey.delete.ts"), diff --git a/apps/journal/app/routes/settings._index.tsx b/apps/journal/app/routes/settings._index.tsx new file mode 100644 index 0000000..7afc536 --- /dev/null +++ b/apps/journal/app/routes/settings._index.tsx @@ -0,0 +1,8 @@ +import { redirect } from "react-router"; + +// /settings lands on the Profile section. Keeps deep-link friendliness +// (every section has a stable URL) without making the bare /settings +// URL feel empty. +export function loader() { + return redirect("/settings/profile"); +} diff --git a/apps/journal/app/routes/settings.account.tsx b/apps/journal/app/routes/settings.account.tsx new file mode 100644 index 0000000..17aa3ca --- /dev/null +++ b/apps/journal/app/routes/settings.account.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect } from "react"; +import { data, redirect, useFetcher } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/settings.account"; +import { getSessionUser } from "~/lib/auth.server"; + +export function meta() { + return [{ title: "Account — Settings — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return data({ + user: { + username: user.username, + email: user.email, + }, + }); +} + +export default function AccountSettings({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + const { t } = useTranslation(["journal", "common"]); + const emailFetcher = useFetcher(); + + const [newEmail, setNewEmail] = useState(""); + const [showEmailForm, setShowEmailForm] = useState(false); + const [emailSent, setEmailSent] = useState(false); + + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [deleteUsername, setDeleteUsername] = useState(""); + + useEffect(() => { + if (emailFetcher.data && !emailFetcher.data.error) { + setEmailSent(true); + } + }, [emailFetcher.data]); + + return ( +
+

{t("settings.account.title")}

+ + {/* Email */} +
+ +
+

{user.email}

+ {!showEmailForm && !emailSent && ( + + )} +
+ + {showEmailForm && !emailSent && ( + + setNewEmail(e.target.value)} + placeholder={t("settings.account.newEmailPlaceholder")} + className="block flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + + + + )} + + {emailFetcher.data?.error && ( +

{emailFetcher.data.error}

+ )} + + {emailSent && ( +

{t("settings.account.verificationSent")}

+ )} +
+ + {/* Delete Account */} +
+

{t("settings.account.dangerZone")}

+

{t("settings.account.deleteDescription")}

+ + {!deleteConfirm ? ( + + ) : ( +
+

+ {t("settings.account.deleteConfirmPrompt", { username: user.username })} +

+ setDeleteUsername(e.target.value)} + placeholder={user.username} + className="block w-full rounded-md border border-red-300 px-3 py-2 text-sm" + /> +
+
+ +
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/journal/app/routes/settings.connections.tsx b/apps/journal/app/routes/settings.connections.tsx new file mode 100644 index 0000000..682e5a7 --- /dev/null +++ b/apps/journal/app/routes/settings.connections.tsx @@ -0,0 +1,86 @@ +import { data, redirect } from "react-router"; +import { useTranslation } from "react-i18next"; +import { eq } from "drizzle-orm"; +import type { Route } from "./+types/settings.connections"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections } from "@trails-cool/db/schema/journal"; +import { getAllProviders } from "~/lib/sync/registry"; + +export function meta() { + return [{ title: "Connected services — Settings — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const db = getDb(); + const connections = await db + .select({ + provider: syncConnections.provider, + providerUserId: syncConnections.providerUserId, + }) + .from(syncConnections) + .where(eq(syncConnections.userId, user.id)); + + const providers = getAllProviders().map((p) => { + const conn = connections.find((c) => c.provider === p.id); + return { id: p.id, name: p.name, connected: !!conn, providerUserId: conn?.providerUserId }; + }); + + return data({ providers }); +} + +export default function ConnectionsSettings({ loaderData }: Route.ComponentProps) { + const { providers } = loaderData; + const { t } = useTranslation(["journal"]); + + return ( +
+

{t("settings.services.title")}

+
+ {providers.map((p) => ( +
+
+

{p.name}

+ {p.connected && p.providerUserId && ( +

+ {t("settings.services.connectedAs", { id: p.providerUserId })} +

+ )} +
+ {p.connected ? ( +
+ + {t("sync.import")} + +
+ +
+
+ ) : ( + + {t("settings.services.connect")} + + )} +
+ ))} +
+
+ ); +} diff --git a/apps/journal/app/routes/settings.profile.tsx b/apps/journal/app/routes/settings.profile.tsx new file mode 100644 index 0000000..2c9956b --- /dev/null +++ b/apps/journal/app/routes/settings.profile.tsx @@ -0,0 +1,138 @@ +import { useState, useEffect } from "react"; +import { data, redirect, useFetcher } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/settings.profile"; +import { getSessionUser } from "~/lib/auth.server"; + +export function meta() { + return [{ title: "Profile — Settings — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + return data({ + user: { + username: user.username, + displayName: user.displayName, + bio: user.bio, + profileVisibility: user.profileVisibility, + }, + }); +} + +export default function ProfileSettings({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + const { t } = useTranslation(["journal", "common"]); + const profileFetcher = useFetcher(); + + const [displayName, setDisplayName] = useState(user.displayName ?? ""); + const [bio, setBio] = useState(user.bio ?? ""); + const [profileVisibility, setProfileVisibility] = useState<"public" | "private">( + user.profileVisibility, + ); + const [profileSaved, setProfileSaved] = useState(false); + + useEffect(() => { + if (profileFetcher.data && !profileFetcher.data.error) { + setProfileSaved(true); + const timer = setTimeout(() => setProfileSaved(false), 3000); + return () => clearTimeout(timer); + } + }, [profileFetcher.data]); + + return ( +
+

{t("settings.profile.title")}

+ +
+ + setDisplayName(e.target.value)} + placeholder={user.username} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+ +