basic user info

This commit is contained in:
Daniel Bulant 2026-04-19 14:58:46 +02:00
parent 09ba243fe3
commit 38732a4cf6
No known key found for this signature in database
9 changed files with 213 additions and 131 deletions

View file

@ -14,6 +14,7 @@
"typescript": "^5"
},
"dependencies": {
"@spotify/web-api-ts-sdk": "^1.2.0",
"@statsfm/statsfm.js": "github.com:statsfm/statsfm.js",
"better-auth": "^1.6.5",
"elysia": "catalog:"

View file

@ -15,6 +15,7 @@
"api": {
"name": "api",
"dependencies": {
"@spotify/web-api-ts-sdk": "^1.2.0",
"@statsfm/statsfm.js": "github.com:statsfm/statsfm.js",
"better-auth": "^1.6.5",
"elysia": "catalog:",
@ -482,6 +483,8 @@
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
"@spotify/web-api-ts-sdk": ["@spotify/web-api-ts-sdk@1.2.0", "", {}, "sha512-JUaebva3Ohwo5I5tuTqyW/FKGOMbb40YevJMySAOINRxP7qQ/AMjBzfJx0zeO6yS+wAPfQSoGNsZaUggHw8vsA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@statsfm/statsfm.js": ["@statsfm/statsfm.js@git+ssh://github.com:statsfm/statsfm.js#44e5eb3e731791c03a896648c6a64a65b7c49612", { "dependencies": { "file-type": "16", "isomorphic-unfetch": "^3.1.0" } }, "44e5eb3e731791c03a896648c6a64a65b7c49612"],

View file

@ -0,0 +1,29 @@
import { useRouteContext } from "@tanstack/react-router";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "./ui/item";
import { useUser } from "#/hooks/user";
import { initials } from "#/lib/utils";
export function UserInfo() {
const { user } = useUser();
return (
<Item>
<ItemMedia>
<Avatar>
<AvatarImage src={user?.image || undefined} />
<AvatarFallback>{initials(user?.name || "")}</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>{user?.name}</ItemTitle>
<ItemDescription>No party yet</ItemDescription>
</ItemContent>
</Item>
);
}

21
web/src/hooks/user.ts Normal file
View file

@ -0,0 +1,21 @@
import { authClient } from "#/lib/auth-client";
import type { AuthSession } from "#/lib/auth.serverfn";
import { Route } from "#/routes/__root";
interface UserData {
user: AuthSession["user"] | null;
session: AuthSession["session"] | null;
isLoading: boolean;
}
export function useUser() {
const routeContext = Route.useRouteContext();
const { data: clientSession, isPending: isClientSessionPending } =
authClient.useSession();
const session = (clientSession?.session ??
routeContext.session?.session ??
null) as AuthSession["session"] | null;
const user = clientSession?.user ?? routeContext.session?.user ?? null;
const isLoading = !routeContext.session && isClientSessionPending;
return { user, session, isLoading } satisfies UserData;
}

View file

@ -1,3 +1,24 @@
import { createAuthClient } from "better-auth/react";
import type { AuthSession } from "./auth.serverfn";
import type { QueryClient } from "@tanstack/react-query";
export const authClient = createAuthClient();
export const sessionQueryKey = ["auth", "session"] as const;
export async function fetchSession(): Promise<AuthSession | null> {
const { data } = await authClient.getSession();
return data ?? null;
}
export async function signOutAndClearQueryCache({
navigateToLogin,
queryClient,
}: {
navigateToLogin: () => Promise<void> | void;
queryClient: QueryClient;
}): Promise<void> {
await authClient.signOut();
queryClient.clear();
await navigateToLogin();
}

View file

@ -0,0 +1,46 @@
import { createServerFn } from "@tanstack/react-start";
export interface AuthSession {
user: {
id: string;
createdAt: Date;
updatedAt: Date;
email: string;
emailVerified: boolean;
name: string;
image?: string | null | undefined;
};
session: {
id: string;
createdAt: Date;
updatedAt: Date;
userId: string;
expiresAt: Date;
token: string;
ipAddress?: string | null | undefined;
userAgent?: string | null | undefined;
};
}
export const getSession = createServerFn({ method: "GET" }).handler(
async (): Promise<AuthSession | null> => {
const { getRequestHeaders } = await import("@tanstack/react-start/server");
const headers = getRequestHeaders();
const resolvedBaseUrl =
process.env.VITE_BETTER_AUTH_URL ??
import.meta.env.VITE_BETTER_AUTH_URL ??
headers.get("origin") ??
"http://127.0.0.1:3000";
const response = await fetch(
new URL("/api/auth/get-session", resolvedBaseUrl),
{ headers },
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as AuthSession | null;
return data ?? null;
},
);

View file

@ -2,5 +2,12 @@ import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs));
}
export function initials(name: string) {
return name
.split(" ")
.map((t) => t[0])
.join("");
}

View file

@ -1,66 +1,95 @@
import { TanStackDevtools } from "@tanstack/react-devtools";
import type { QueryClient } from "@tanstack/react-query";
import {
createRootRouteWithContext,
HeadContent,
Scripts,
createRootRouteWithContext,
HeadContent,
redirect,
Scripts,
} from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { Toaster } from "@/components/ui/sonner";
import TanStackQueryDevtools from "../integrations/tanstack-query/devtools";
import appCss from "../styles.css?url";
import type { AuthSession } from "#/lib/auth.serverfn";
import { fetchSession, sessionQueryKey } from "#/lib/auth-client";
interface MyRouterContext {
queryClient: QueryClient;
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "TanStack Start Starter",
},
],
links: [
{
rel: "stylesheet",
href: appCss,
},
],
}),
shellComponent: RootDocument,
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "TanStack Start Starter",
},
],
links: [
{
rel: "stylesheet",
href: appCss,
},
],
}),
shellComponent: RootDocument,
beforeLoad: async ({ context, location }) => {
const authPublicPaths = new Set(["/login"]);
const isAuthPublicPath = authPublicPaths.has(location.pathname);
let session: AuthSession | null;
if (typeof window === "undefined") {
const { getSession } = await import("../lib/auth.serverfn");
session = await getSession();
} else {
session = await context.queryClient.fetchQuery({
queryKey: sessionQueryKey,
queryFn: fetchSession,
staleTime: 30_000,
});
}
const user = session?.user;
if (!user && !isAuthPublicPath) {
throw redirect({
to: "/login",
search: { redirect: location.href },
});
}
return { user, session };
},
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body className="font-sans antialiased wrap-anywhere dark">
{children}
<Toaster />
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
TanStackQueryDevtools,
]}
/>
<Scripts />
</body>
</html>
);
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body className="font-sans antialiased wrap-anywhere dark">
{children}
<Toaster />
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
TanStackQueryDevtools,
]}
/>
<Scripts />
</body>
</html>
);
}

View file

@ -1,87 +1,12 @@
import { UserInfo } from "#/components/user-info";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: App });
function App() {
return (
<main className="page-wrap px-4 pb-8 pt-14">
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-10 sm:px-10 sm:py-14">
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.32),transparent_66%)]" />
<div className="pointer-events-none absolute -bottom-20 -right-20 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_66%)]" />
<p className="island-kicker mb-3">TanStack Start Base Template</p>
<h1 className="display-title mb-5 max-w-3xl text-4xl leading-[1.02] font-bold tracking-tight text-[var(--sea-ink)] sm:text-6xl">
Start simple, ship quickly.
</h1>
<p className="mb-8 max-w-2xl text-base text-[var(--sea-ink-soft)] sm:text-lg">
This base starter intentionally keeps things light: two routes, clean
structure, and the essentials you need to build from scratch.
</p>
<div className="flex flex-wrap gap-3">
<a
href="/about"
className="rounded-full border border-[rgba(50,143,151,0.3)] bg-[rgba(79,184,178,0.14)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.24)]"
>
About This Starter
</a>
<a
href="https://tanstack.com/router"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-[rgba(23,58,64,0.2)] bg-white/50 px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.35)]"
>
Router Guide
</a>
</div>
</section>
<section className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
[
"Type-Safe Routing",
"Routes and links stay in sync across every page.",
],
[
"Server Functions",
"Call server code from your UI without creating API boilerplate.",
],
[
"Streaming by Default",
"Ship progressively rendered responses for faster experiences.",
],
[
"Tailwind Native",
"Design quickly with utility-first styling and reusable tokens.",
],
].map(([title, desc], index) => (
<article
key={title}
className="island-shell feature-card rise-in rounded-2xl p-5"
style={{ animationDelay: `${index * 90 + 80}ms` }}
>
<h2 className="mb-2 text-base font-semibold text-[var(--sea-ink)]">
{title}
</h2>
<p className="m-0 text-sm text-[var(--sea-ink-soft)]">{desc}</p>
</article>
))}
</section>
<section className="island-shell mt-8 rounded-2xl p-6">
<p className="island-kicker mb-2">Quick Start</p>
<ul className="m-0 list-disc space-y-2 pl-5 text-sm text-[var(--sea-ink-soft)]">
<li>
Edit <code>src/routes/index.tsx</code> to customize the home page.
</li>
<li>
Update <code>src/components/Header.tsx</code> and{" "}
<code>src/components/Footer.tsx</code> for brand links.
</li>
<li>
Add routes in <code>src/routes</code> and tweak visual tokens in{" "}
<code>src/styles.css</code>.
</li>
</ul>
</section>
</main>
);
return (
<main>
<UserInfo />
</main>
);
}