basic user info
This commit is contained in:
parent
09ba243fe3
commit
38732a4cf6
9 changed files with 213 additions and 131 deletions
|
|
@ -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:"
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -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"],
|
||||
|
|
|
|||
29
web/src/components/user-info.tsx
Normal file
29
web/src/components/user-info.tsx
Normal 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
21
web/src/hooks/user.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
46
web/src/lib/auth.serverfn.ts
Normal file
46
web/src/lib/auth.serverfn.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
|
|
@ -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("");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue