From c0c1a5332252056de7f5d43a091ea53c0474af89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 26 Apr 2026 09:48:00 +0200 Subject: [PATCH] Split /settings into 4 sub-pages with a sidebar layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream E from docs/information-architecture.md. The settings page was a single scrollable list of five concerns; split it into four deep-linkable sections behind a shared sidebar layout, so each concern has a stable URL, a focused loader (only fetches what that section needs), and a meta title that reflects the section. Sections: - /settings/profile — display name, bio, profile visibility - /settings/account — email change + danger-zone account deletion - /settings/security — passkeys - /settings/connections — sync providers (Wahoo today) URL pattern: nested routes under a layout. /settings itself redirects to /settings/profile so the bare URL still lands somewhere useful without rendering an empty container. - apps/journal/app/routes/settings.tsx — converted from a single scrollable page to a layout that renders the sidebar nav + Outlet. - apps/journal/app/routes/settings.{profile,account,security, connections,_index}.tsx — new files, each with its own loader and meta. _index redirects to /settings/profile. - apps/journal/app/routes.ts — registers the four children + index under the settings layout route. - packages/i18n/src/locales/{en,de}.ts — new settings.nav.{profile, account,security,connections} keys for the sidebar labels. Specs (profile-settings, account-management, connected-services) are unchanged — they describe behavior, not URL structure. The journal-landing spec is also unchanged; the navbar still has a single "Settings" link. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/routes.ts | 8 +- apps/journal/app/routes/settings._index.tsx | 8 + apps/journal/app/routes/settings.account.tsx | 142 +++++ .../app/routes/settings.connections.tsx | 86 +++ apps/journal/app/routes/settings.profile.tsx | 138 +++++ apps/journal/app/routes/settings.security.tsx | 174 +++++++ apps/journal/app/routes/settings.tsx | 489 ++---------------- packages/i18n/src/locales/de.ts | 6 + packages/i18n/src/locales/en.ts | 6 + 9 files changed, 604 insertions(+), 453 deletions(-) create mode 100644 apps/journal/app/routes/settings._index.tsx create mode 100644 apps/journal/app/routes/settings.account.tsx create mode 100644 apps/journal/app/routes/settings.connections.tsx create mode 100644 apps/journal/app/routes/settings.profile.tsx create mode 100644 apps/journal/app/routes/settings.security.tsx 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" + /> +
+
+ +