Moves routes inside an _app for the header and builds a print route
This commit is contained in:
12
src/routes/_app/_authenticated.tsx
Normal file
12
src/routes/_app/_authenticated.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { isAuthenticated } from "@/lib/pocketbase";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_app/_authenticated")({
|
||||
beforeLoad: () => {
|
||||
if (!isAuthenticated()) {
|
||||
throw redirect({
|
||||
to: "/login",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
92
src/routes/_app/_authenticated/campaigns.$campaignId.tsx
Normal file
92
src/routes/_app/_authenticated/campaigns.$campaignId.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { SessionRow } from "@/components/documents/session/SessionRow";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Loader } from "@/components/Loader";
|
||||
|
||||
export const Route = createFileRoute("/_app/_authenticated/campaigns/$campaignId")({
|
||||
component: RouteComponent,
|
||||
pendingComponent: Loader,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const queryClient = useQueryClient();
|
||||
const params = Route.useParams();
|
||||
|
||||
const {
|
||||
data: { campaign, sessions },
|
||||
} = useSuspenseQuery({
|
||||
queryKey: ["campaign"],
|
||||
queryFn: async () => {
|
||||
const campaign = await pb
|
||||
.collection("campaigns")
|
||||
.getOne(params.campaignId);
|
||||
// Fetch all documents for this campaign
|
||||
const docs = await pb.collection("documents").getFullList({
|
||||
filter: `campaign = "${params.campaignId}"`,
|
||||
});
|
||||
// Filter to only those with data.session
|
||||
const sessions = docs.filter((doc: any) => doc.data && doc.data.session);
|
||||
return {
|
||||
campaign,
|
||||
sessions,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const createNewSession = useCallback(async () => {
|
||||
await pb.collection("documents").create({
|
||||
campaign: campaign.id,
|
||||
data: {
|
||||
session: {
|
||||
strongStart: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["campaign"] });
|
||||
}, [campaign]);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-8">
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
to="/campaigns"
|
||||
className="text-slate-400 hover:text-violet-400 text-sm underline underline-offset-2 transition-colors"
|
||||
>
|
||||
← Back to campaigns
|
||||
</Link>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-4 text-slate-100">
|
||||
{campaign.name}
|
||||
</h2>
|
||||
<div className="flex justify-between">
|
||||
<h3 className="text-lg font-semibold mb-2 text-slate-200">Sessions</h3>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => createNewSession()}
|
||||
className="inline-flex items-center justify-center rounded bg-violet-600 hover:bg-violet-700 text-white px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
>
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{sessions && sessions.length > 0 ? (
|
||||
<div>
|
||||
<ul className="space-y-2">
|
||||
{sessions.map((s: any) => (
|
||||
<li key={s.id}>
|
||||
<SessionRow session={s} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-400">
|
||||
No sessions found for this campaign.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/routes/_app/_authenticated/campaigns.index.tsx
Normal file
56
src/routes/_app/_authenticated/campaigns.index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import type { Campaign } from "@/lib/types";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { CreateCampaignButton } from "@/components/CreateCampaignButton";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_app/_authenticated/campaigns/")({
|
||||
loader: async () => {
|
||||
const records = await pb.collection("campaigns").getFullList();
|
||||
return {
|
||||
campaigns: records.map((rec: any) => ({
|
||||
id: rec.id,
|
||||
name: rec.name,
|
||||
})) as Campaign[],
|
||||
};
|
||||
},
|
||||
component: RouteComponent,
|
||||
pendingComponent: Loader,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { campaigns } = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
|
||||
const handleCreated = async () => {
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-8">
|
||||
<h2 className="text-xl font-bold mb-4 text-slate-100">Your Campaigns</h2>
|
||||
{!campaigns || campaigns.length === 0 ? (
|
||||
<div>No campaigns found.</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{campaigns.map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link
|
||||
to="/campaigns/$campaignId"
|
||||
params={{ campaignId: c.id }}
|
||||
className="block px-4 py-2 rounded bg-slate-800 hover:bg-violet-700 text-slate-100 transition-colors"
|
||||
>
|
||||
{c.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<CreateCampaignButton onCreated={handleCreated} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/routes/_app/_authenticated/document.$documentId.tsx
Normal file
68
src/routes/_app/_authenticated/document.$documentId.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import _ from "lodash";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import {
|
||||
RelationshipType,
|
||||
type Relationship,
|
||||
type Session,
|
||||
type Document,
|
||||
} from "@/lib/types";
|
||||
import { RelationshipList } from "@/components/RelationshipList";
|
||||
import { SessionForm } from "@/components/documents/session/SessionForm";
|
||||
|
||||
export const Route = createFileRoute("/_app/_authenticated/document/$documentId")({
|
||||
loader: async ({ params }) => {
|
||||
const doc = await pb.collection("documents").getOne(params.documentId);
|
||||
const relationships: Relationship[] = await pb
|
||||
.collection("relationships")
|
||||
.getFullList({
|
||||
filter: `primary = "${params.documentId}"`,
|
||||
expand: "secondary",
|
||||
});
|
||||
console.log("Fetched data: ", relationships);
|
||||
return {
|
||||
document: doc,
|
||||
relationships: _.mapValues(
|
||||
_.groupBy(relationships, (r) => r.type),
|
||||
(rs: Relationship[]) => rs.flatMap((r) => r.expand?.secondary),
|
||||
),
|
||||
};
|
||||
},
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { document: session, relationships } = Route.useLoaderData() as {
|
||||
document: Session;
|
||||
relationships: Record<RelationshipType, Document[]>;
|
||||
};
|
||||
|
||||
async function handleSaveSession(data: Session["data"]) {
|
||||
await pb.collection("documents").update(session.id, {
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Parsed data: ", relationships);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-8">
|
||||
<SessionForm session={session as Session} onSubmit={handleSaveSession} />
|
||||
{[
|
||||
RelationshipType.Scenes,
|
||||
RelationshipType.Secrets,
|
||||
RelationshipType.Locations,
|
||||
RelationshipType.Npcs,
|
||||
RelationshipType.Monsters,
|
||||
RelationshipType.Treasures,
|
||||
].map((relationshipType) => (
|
||||
<RelationshipList
|
||||
key={relationshipType}
|
||||
root={session}
|
||||
relationshipType={relationshipType}
|
||||
items={relationships[relationshipType] ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/routes/_app/about.tsx
Normal file
9
src/routes/_app/about.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_app/about')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/about"!</div>
|
||||
}
|
||||
13
src/routes/_app/index.tsx
Normal file
13
src/routes/_app/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_app/")({
|
||||
component: App,
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h1>Welcome to the DM's Companion</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/routes/_app/login.tsx
Normal file
205
src/routes/_app/login.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useAuth } from "@/context/auth/AuthContext";
|
||||
import { isAuthenticated } from "@/lib/pocketbase";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Login and signup page for authentication.
|
||||
* Allows users to log in or create a new account.
|
||||
*/
|
||||
export const Route = createFileRoute("/_app/login")({
|
||||
beforeLoad: () => {
|
||||
if (isAuthenticated()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
});
|
||||
}
|
||||
},
|
||||
component: LoginPage,
|
||||
});
|
||||
|
||||
function LoginPage() {
|
||||
const { login, signup, isLoading } = useAuth();
|
||||
const [mode, setMode] = useState<"login" | "signup">("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
passwordConfirm?: string;
|
||||
}>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === "login") {
|
||||
await login(email, password);
|
||||
} else {
|
||||
if (password !== passwordConfirm) {
|
||||
setFieldErrors({ passwordConfirm: "Passwords do not match" });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
await signup(email, password, passwordConfirm);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ClientResponseError) {
|
||||
setFieldErrors({
|
||||
email: err.response.data.email?.message,
|
||||
password: err.response.data.password?.message,
|
||||
passwordConfirm: err.response.data.passwordConfirm?.message,
|
||||
});
|
||||
}
|
||||
setError((err as Error)?.message || "Authentication failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center min-h-screen bg-slate-900">
|
||||
<form
|
||||
className="bg-slate-800 p-8 rounded-lg shadow-lg w-full max-w-md flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
aria-busy={submitting || isLoading}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-slate-100 mb-2 text-center">
|
||||
{mode === "login" ? "Sign In" : "Sign Up"}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="email" className="text-slate-200 font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="px-3 py-2 rounded bg-slate-700 text-slate-100 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={submitting || isLoading}
|
||||
aria-invalid={!!fieldErrors.email}
|
||||
aria-describedby={fieldErrors.email ? "email-error" : undefined}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<div
|
||||
className="text-red-400 text-xs mt-1"
|
||||
id="email-error"
|
||||
role="alert"
|
||||
>
|
||||
{fieldErrors.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="password" className="text-slate-200 font-medium">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete={
|
||||
mode === "login" ? "current-password" : "new-password"
|
||||
}
|
||||
required
|
||||
className="px-3 py-2 rounded bg-slate-700 text-slate-100 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={submitting || isLoading}
|
||||
aria-invalid={!!fieldErrors.password}
|
||||
aria-describedby={
|
||||
fieldErrors.password ? "password-error" : undefined
|
||||
}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<div
|
||||
className="text-red-400 text-xs mt-1"
|
||||
id="password-error"
|
||||
role="alert"
|
||||
>
|
||||
{fieldErrors.password}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mode === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="passwordConfirm"
|
||||
className="text-slate-200 font-medium"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="px-3 py-2 rounded bg-slate-700 text-slate-100 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
disabled={submitting || isLoading}
|
||||
aria-invalid={!!fieldErrors.passwordConfirm}
|
||||
aria-describedby={
|
||||
fieldErrors.passwordConfirm
|
||||
? "passwordConfirm-error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{fieldErrors.passwordConfirm && (
|
||||
<div
|
||||
className="text-red-400 text-xs mt-1"
|
||||
id="passwordConfirm-error"
|
||||
role="alert"
|
||||
>
|
||||
{fieldErrors.passwordConfirm}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!fieldErrors.email &&
|
||||
!fieldErrors.password &&
|
||||
!fieldErrors.passwordConfirm &&
|
||||
error && (
|
||||
<div className="text-red-400 text-sm text-center" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-violet-600 hover:bg-violet-700 text-white font-semibold py-2 rounded transition-colors disabled:opacity-60"
|
||||
disabled={submitting || isLoading}
|
||||
>
|
||||
{submitting
|
||||
? mode === "login"
|
||||
? "Signing In..."
|
||||
: "Signing Up..."
|
||||
: mode === "login"
|
||||
? "Sign In"
|
||||
: "Sign Up"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-violet-400 hover:underline text-sm mt-2"
|
||||
onClick={() => {
|
||||
setMode(mode === "login" ? "signup" : "login");
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
}}
|
||||
disabled={submitting || isLoading}
|
||||
>
|
||||
{mode === "login"
|
||||
? "Don't have an account? Sign up."
|
||||
: "Already have an account? Sign in."}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user