Initial commit.

This commit is contained in:
2025-05-27 16:29:14 -07:00
commit 4f44d5edca
29 changed files with 5230 additions and 0 deletions

6
src/config.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Application configuration values.
*
* This includes endpoints and other environment-specific settings.
*/
export const POCKETBASE_URL: string = "http://127.0.0.1:8090"; // Update as needed for deployment

View File

@@ -0,0 +1,127 @@
import { createContext, useContext, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { pb } from "@/lib/pocketbase";
import type { AuthRecord } from "pocketbase";
/**
* Represents the shape of the authenticated user object from PocketBase.
*/
/**
* Context value for authentication state and actions.
*/
export interface AuthContextValue {
user: AuthRecord | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
signup: (
email: string,
password: string,
passwordConfirm: string,
) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
/**
* Fetches the currently authenticated user from PocketBase.
*/
async function fetchUser(): Promise<AuthRecord | null> {
if (pb.authStore.isValid) {
return pb.authStore.record;
}
return null;
}
/**
* Provider for authentication context, using TanStack Query for state management.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { data: user, isLoading } = useQuery({
queryKey: ["auth", "user"],
queryFn: fetchUser,
});
const loginMutation = useMutation({
mutationFn: async ({
email,
password,
}: {
email: string;
password: string;
}) => {
await pb.collection("users").authWithPassword(email, password);
return fetchUser();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "user"] });
},
});
const signupMutation = useMutation({
mutationFn: async ({
email,
password,
passwordConfirm,
}: {
email: string;
password: string;
passwordConfirm: string;
}) => {
await pb.collection("users").create({ email, password, passwordConfirm });
await pb.collection("users").authWithPassword(email, password);
return fetchUser();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "user"] });
},
});
const logoutMutation = useMutation({
mutationFn: async () => {
pb.authStore.clear();
return null;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "user"] });
},
});
const login = useCallback(
async (email: string, password: string) => {
await loginMutation.mutateAsync({ email, password });
},
[loginMutation],
);
const signup = useCallback(
async (email: string, password: string, passwordConfirm: string) => {
await signupMutation.mutateAsync({ email, password, passwordConfirm });
},
[signupMutation],
);
const logout = useCallback(async () => {
await logoutMutation.mutateAsync();
}, [logoutMutation]);
return (
<AuthContext.Provider
value={{ user: user ?? null, isLoading, login, signup, logout }}
>
{children}
</AuthContext.Provider>
);
}
/**
* Hook to access authentication context.
* Throws if used outside of AuthProvider.
*/
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
return ctx;
}

9
src/lib/pocketbase.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Initializes and exports a singleton PocketBase instance for use throughout the app.
*
* Throws if the PocketBase URL is invalid.
*/
import PocketBase from "pocketbase";
import { POCKETBASE_URL } from "@/config";
export const pb = new PocketBase(POCKETBASE_URL);

42
src/main.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
// Import the generated route tree
import { routeTree } from './routeTree.gen'
import './styles.css'
import reportWebVitals from './reportWebVitals.ts'
// Create a new router instance
const router = createRouter({
routeTree,
context: {},
defaultPreload: 'intent',
scrollRestoration: true,
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
})
// Register the router instance for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// Render the app
const rootElement = document.getElementById('app')
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement)
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
}
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()

13
src/reportWebVitals.ts Normal file
View File

@@ -0,0 +1,13 @@
const reportWebVitals = (onPerfEntry?: () => void) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
onCLS(onPerfEntry)
onINP(onPerfEntry)
onFCP(onPerfEntry)
onLCP(onPerfEntry)
onTTFB(onPerfEntry)
})
}
}
export default reportWebVitals

191
src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,191 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as AboutImport } from './routes/about'
import { Route as IndexImport } from './routes/index'
import { Route as SessionsIndexImport } from './routes/sessions.index'
import { Route as CampaignsIndexImport } from './routes/campaigns.index'
import { Route as CampaignsCampaignIdImport } from './routes/campaigns.$campaignId'
// Create/Update Routes
const AboutRoute = AboutImport.update({
id: '/about',
path: '/about',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any)
const SessionsIndexRoute = SessionsIndexImport.update({
id: '/sessions/',
path: '/sessions/',
getParentRoute: () => rootRoute,
} as any)
const CampaignsIndexRoute = CampaignsIndexImport.update({
id: '/campaigns/',
path: '/campaigns/',
getParentRoute: () => rootRoute,
} as any)
const CampaignsCampaignIdRoute = CampaignsCampaignIdImport.update({
id: '/campaigns/$campaignId',
path: '/campaigns/$campaignId',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/about': {
id: '/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof AboutImport
parentRoute: typeof rootRoute
}
'/campaigns/$campaignId': {
id: '/campaigns/$campaignId'
path: '/campaigns/$campaignId'
fullPath: '/campaigns/$campaignId'
preLoaderRoute: typeof CampaignsCampaignIdImport
parentRoute: typeof rootRoute
}
'/campaigns/': {
id: '/campaigns/'
path: '/campaigns'
fullPath: '/campaigns'
preLoaderRoute: typeof CampaignsIndexImport
parentRoute: typeof rootRoute
}
'/sessions/': {
id: '/sessions/'
path: '/sessions'
fullPath: '/sessions'
preLoaderRoute: typeof SessionsIndexImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/campaigns/$campaignId': typeof CampaignsCampaignIdRoute
'/campaigns': typeof CampaignsIndexRoute
'/sessions': typeof SessionsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/campaigns/$campaignId': typeof CampaignsCampaignIdRoute
'/campaigns': typeof CampaignsIndexRoute
'/sessions': typeof SessionsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/campaigns/$campaignId': typeof CampaignsCampaignIdRoute
'/campaigns/': typeof CampaignsIndexRoute
'/sessions/': typeof SessionsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/about'
| '/campaigns/$campaignId'
| '/campaigns'
| '/sessions'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/campaigns/$campaignId' | '/campaigns' | '/sessions'
id:
| '__root__'
| '/'
| '/about'
| '/campaigns/$campaignId'
| '/campaigns/'
| '/sessions/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
CampaignsCampaignIdRoute: typeof CampaignsCampaignIdRoute
CampaignsIndexRoute: typeof CampaignsIndexRoute
SessionsIndexRoute: typeof SessionsIndexRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
CampaignsCampaignIdRoute: CampaignsCampaignIdRoute,
CampaignsIndexRoute: CampaignsIndexRoute,
SessionsIndexRoute: SessionsIndexRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/about",
"/campaigns/$campaignId",
"/campaigns/",
"/sessions/"
]
},
"/": {
"filePath": "index.tsx"
},
"/about": {
"filePath": "about.tsx"
},
"/campaigns/$campaignId": {
"filePath": "campaigns.$campaignId.tsx"
},
"/campaigns/": {
"filePath": "campaigns.index.tsx"
},
"/sessions/": {
"filePath": "sessions.index.tsx"
}
}
}
ROUTE_MANIFEST_END */

55
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { AuthProvider } from "@/context/auth/AuthContext";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
/** Root of the application */
export const Route = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<header className="flex items-center justify-between px-8 py-4 border-b border-slate-700 bg-slate-900">
<h1 className="text-2xl font-bold text-slate-100 m-0">
DM's Table Companion
</h1>
<nav aria-label="Main navigation" className="flex gap-6">
<Link
to="/campaigns"
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
activeProps={{
className:
"no-underline text-violet-400 border-violet-400 border-b-2 pb-1",
}}
>
Campaigns
</Link>
<Link
to="/sessions"
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
activeProps={{
className:
"no-underline text-violet-400 border-violet-400 border-b-2 pb-1",
}}
>
Sessions
</Link>
<Link
to="/about"
className="no-underline text-slate-200 hover:text-violet-400 transition-colors font-medium border-b-2 border-transparent pb-1"
activeProps={{
className:
"no-underline text-violet-400 border-violet-400 border-b-2 pb-1",
}}
>
About
</Link>
</nav>
</header>
<Outlet />
<TanStackRouterDevtools />
</AuthProvider>
</QueryClientProvider>
),
});

9
src/routes/about.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/about"!</div>
}

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/campaigns/$campaignId")({
component: RouteComponent,
});
function RouteComponent() {
const { campaignId } = Route.useParams();
return <div>Hello "/campaigns/{campaignId}"!</div>;
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/campaigns/")({
component: RouteComponent,
});
function RouteComponent() {
return <div>Hello "/campaigns/"!</div>;
}

20
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: App,
});
function App() {
return (
<div className="text-center">
<header className="min-h-screen flex flex-col items-center justify-center bg-[#282c34] text-white text-[calc(10px+2vmin)]">
<h1>Hello, Tanstack</h1>
</header>
<div>
<Link to="/campaigns" activeProps={{ className: "weight-bold" }}>
Campaigns
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/sessions/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/sessions/"!</div>
}

49
src/styles.css Normal file
View File

@@ -0,0 +1,49 @@
@import "tailwindcss";
html, body {
height: 100%;
min-height: 100%;
background-color: #0f172a; /* slate-900 */
color: #f1f5f9; /* slate-100 */
font-family: 'Inter', system-ui, sans-serif;
line-height: 1.5;
}
body {
margin: 0;
padding: 0;
min-width: 320px;
}
code, pre {
font-family: 'Fira Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
background: #1e293b; /* slate-800 */
color: #a5b4fc; /* violet-300 */
border-radius: 0.25rem;
padding: 0.2em 0.4em;
}
a {
color: #a5b4fc; /* violet-300 */
text-decoration: underline;
transition: color 0.2s;
}
a:hover, a:focus {
color: #7c3aed; /* violet-600 */
}
/* Remove default outline, but keep focus-visible for accessibility */
:focus:not(:focus-visible) {
outline: none;
}
/* Scrollbar styling for dark mode */
::-webkit-scrollbar {
width: 8px;
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}