mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 10:33:01 +00:00
pages directory
This commit is contained in:
338
packages/frontend/src/pages/Index.tsx
Normal file
338
packages/frontend/src/pages/Index.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
||||
import type {
|
||||
IssueResponse,
|
||||
OrganisationMemberResponse,
|
||||
OrganisationResponse,
|
||||
ProjectResponse,
|
||||
UserRecord,
|
||||
} from "@issue/shared";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import AccountDialog from "@/components/account-dialog";
|
||||
import { CreateIssue } from "@/components/create-issue";
|
||||
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
||||
import { IssuesTable } from "@/components/issues-table";
|
||||
import LogOutButton from "@/components/log-out-button";
|
||||
import { OrganisationSelect } from "@/components/organisation-select";
|
||||
import OrganisationsDialog from "@/components/organisations-dialog";
|
||||
import { ProjectSelect } from "@/components/project-select";
|
||||
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
|
||||
import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
|
||||
import { issue, organisation, project } from "@/lib/server";
|
||||
|
||||
const BREATHING_ROOM = 1;
|
||||
|
||||
function Index() {
|
||||
const userData = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord;
|
||||
|
||||
const [user, setUser] = useState<UserRecord>(userData);
|
||||
|
||||
const organisationsRef = useRef(false);
|
||||
const [organisations, setOrganisations] = useState<OrganisationResponse[]>([]);
|
||||
const [selectedOrganisation, setSelectedOrganisation] = useState<OrganisationResponse | null>(null);
|
||||
|
||||
const [projects, setProjects] = useState<ProjectResponse[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<ProjectResponse | null>(null);
|
||||
|
||||
const [issues, setIssues] = useState<IssueResponse[]>([]);
|
||||
const [selectedIssue, setSelectedIssue] = useState<IssueResponse | null>(null);
|
||||
|
||||
const [members, setMembers] = useState<UserRecord[]>([]);
|
||||
|
||||
const refetchUser = async () => {
|
||||
const userData = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord;
|
||||
setUser(userData);
|
||||
};
|
||||
|
||||
const refetchOrganisations = async (options?: { selectOrganisationId?: number }) => {
|
||||
try {
|
||||
await organisation.byUser({
|
||||
userId: user.id,
|
||||
onSuccess: (data) => {
|
||||
const organisations = data as OrganisationResponse[];
|
||||
organisations.sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name));
|
||||
setOrganisations(organisations);
|
||||
|
||||
let selected: OrganisationResponse | null = null;
|
||||
|
||||
if (options?.selectOrganisationId) {
|
||||
const created = organisations.find(
|
||||
(o) => o.Organisation.id === options.selectOrganisationId,
|
||||
);
|
||||
if (created) {
|
||||
selected = created;
|
||||
}
|
||||
} else {
|
||||
const savedId = localStorage.getItem("selectedOrganisationId");
|
||||
if (savedId) {
|
||||
const saved = organisations.find((o) => o.Organisation.id === Number(savedId));
|
||||
if (saved) {
|
||||
selected = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
selected = organisations[0] || null;
|
||||
}
|
||||
|
||||
setSelectedOrganisation(selected);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("error fetching organisations:", error);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("error fetching organisations:", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (organisationsRef.current) return;
|
||||
organisationsRef.current = true;
|
||||
void refetchOrganisations();
|
||||
}, [user.id]);
|
||||
|
||||
const refetchProjects = async (organisationId: number, options?: { selectProjectId?: number }) => {
|
||||
try {
|
||||
await project.byOrganisation({
|
||||
organisationId,
|
||||
onSuccess: (data) => {
|
||||
const projects = data as ProjectResponse[];
|
||||
projects.sort((a, b) => a.Project.name.localeCompare(b.Project.name));
|
||||
setProjects(projects);
|
||||
|
||||
let selected: ProjectResponse | null = null;
|
||||
|
||||
if (options?.selectProjectId) {
|
||||
const created = projects.find((p) => p.Project.id === options.selectProjectId);
|
||||
if (created) {
|
||||
selected = created;
|
||||
}
|
||||
} else {
|
||||
const savedId = localStorage.getItem("selectedProjectId");
|
||||
if (savedId) {
|
||||
const saved = projects.find((p) => p.Project.id === Number(savedId));
|
||||
if (saved) {
|
||||
selected = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
selected = projects[0] || null;
|
||||
}
|
||||
|
||||
setSelectedProject(selected);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("error fetching projects:", error);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("error fetching projects:", err);
|
||||
setProjects([]);
|
||||
}
|
||||
};
|
||||
|
||||
const refetchMembers = async (organisationId: number) => {
|
||||
try {
|
||||
await organisation.members({
|
||||
organisationId,
|
||||
onSuccess: (data: OrganisationMemberResponse[]) => {
|
||||
setMembers(data.map((m) => m.User));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("error fetching members:", error);
|
||||
setMembers([]);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("error fetching members:", err);
|
||||
setMembers([]);
|
||||
}
|
||||
};
|
||||
|
||||
// fetch projects when organisation is selected
|
||||
useEffect(() => {
|
||||
setProjects([]);
|
||||
setSelectedProject(null);
|
||||
setSelectedIssue(null);
|
||||
setIssues([]);
|
||||
setMembers([]);
|
||||
if (!selectedOrganisation) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refetchProjects(selectedOrganisation.Organisation.id);
|
||||
void refetchMembers(selectedOrganisation.Organisation.id);
|
||||
}, [selectedOrganisation]);
|
||||
|
||||
const refetchIssues = async () => {
|
||||
try {
|
||||
await issue.byProject({
|
||||
projectId: selectedProject?.Project.id || 0,
|
||||
onSuccess: (data) => {
|
||||
const issues = data as IssueResponse[];
|
||||
issues.reverse(); // newest at the bottom, but if the order has been rearranged, respect that
|
||||
setIssues(issues);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("error fetching issues:", error);
|
||||
setIssues([]);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("error fetching issues:", err);
|
||||
setIssues([]);
|
||||
}
|
||||
};
|
||||
|
||||
// fetch issues when project is selected
|
||||
useEffect(() => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
void refetchIssues();
|
||||
}, [selectedProject]);
|
||||
|
||||
return (
|
||||
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
||||
{/* header area */}
|
||||
<div className="flex gap-12 items-center justify-between">
|
||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||
{/* organisation selection */}
|
||||
<OrganisationSelect
|
||||
organisations={organisations}
|
||||
selectedOrganisation={selectedOrganisation}
|
||||
onSelectedOrganisationChange={(org) => {
|
||||
setSelectedOrganisation(org);
|
||||
localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`);
|
||||
}}
|
||||
onCreateOrganisation={async (organisationId) => {
|
||||
await refetchOrganisations({ selectOrganisationId: organisationId });
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
{/* project selection - only shown when organisation is selected */}
|
||||
{selectedOrganisation && (
|
||||
<ProjectSelect
|
||||
projects={projects}
|
||||
selectedProject={selectedProject}
|
||||
organisationId={selectedOrganisation?.Organisation.id}
|
||||
onSelectedProjectChange={(project) => {
|
||||
setSelectedProject(project);
|
||||
localStorage.setItem("selectedProjectId", `${project?.Project.id}`);
|
||||
setSelectedIssue(null);
|
||||
}}
|
||||
onCreateProject={async (projectId) => {
|
||||
if (!selectedOrganisation) return;
|
||||
await refetchProjects(selectedOrganisation.Organisation.id, {
|
||||
selectProjectId: projectId,
|
||||
});
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
)}
|
||||
{selectedOrganisation && selectedProject && (
|
||||
<CreateIssue
|
||||
projectId={selectedProject?.Project.id}
|
||||
members={members}
|
||||
completeAction={async () => {
|
||||
if (!selectedProject) return;
|
||||
await refetchIssues();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-sm">
|
||||
<SmallUserDisplay user={user} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"}>
|
||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||
<AccountDialog
|
||||
onUpdate={async () => {
|
||||
refetchUser();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||
<OrganisationsDialog
|
||||
organisations={organisations}
|
||||
selectedOrganisation={selectedOrganisation}
|
||||
setSelectedOrganisation={setSelectedOrganisation}
|
||||
refetchOrganisations={refetchOrganisations}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||
<ServerConfigurationDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex w-full items-center justify-end text-end px-2 py-1 m-0 h-auto"
|
||||
title="Server Configuration"
|
||||
>
|
||||
Server Configuration
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="flex items-end justify-end p-0 m-0">
|
||||
<LogOutButton noStyle className={"flex w-full justify-end"} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* main body */}
|
||||
{selectedProject && issues.length > 0 && (
|
||||
<ResizablePanelGroup className={`flex-1`}>
|
||||
<ResizablePanel id={"left"} minSize={400}>
|
||||
{/* issues list (table) */}
|
||||
<IssuesTable
|
||||
issuesData={issues}
|
||||
columns={{ description: false }}
|
||||
issueSelectAction={(issue) => {
|
||||
if (issue.Issue.id === selectedIssue?.Issue.id) setSelectedIssue(null);
|
||||
else setSelectedIssue(issue);
|
||||
}}
|
||||
className="border w-full flex-shrink"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* issue detail pane */}
|
||||
{selectedIssue && selectedOrganisation && (
|
||||
<>
|
||||
<ResizableSeparator />
|
||||
<ResizablePanel id={"right"} defaultSize={"30%"} minSize={360} maxSize={"60%"}>
|
||||
<div className="border">
|
||||
<IssueDetailPane
|
||||
project={selectedProject}
|
||||
issueData={selectedIssue}
|
||||
members={members}
|
||||
close={() => setSelectedIssue(null)}
|
||||
onIssueUpdate={refetchIssues}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Index;
|
||||
98
packages/frontend/src/pages/Landing.tsx
Normal file
98
packages/frontend/src/pages/Landing.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { UserRecord } from "@issue/shared";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
|
||||
|
||||
type AuthState = "unknown" | "authenticated" | "unauthenticated";
|
||||
|
||||
export default function Landing() {
|
||||
const [authState, setAuthState] = useState<AuthState>("unknown");
|
||||
const verifiedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (verifiedRef.current) return;
|
||||
verifiedRef.current = true;
|
||||
|
||||
fetch(`${getServerURL()}/auth/me`, {
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { user: UserRecord; csrfToken: string };
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
setCsrfToken(data.csrfToken);
|
||||
setAuthState("authenticated");
|
||||
} else {
|
||||
clearAuth();
|
||||
setAuthState("unauthenticated");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
clearAuth();
|
||||
setAuthState("unauthenticated");
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="relative flex items-center justify-center p-2 border-b">
|
||||
<div className="text-3xl font-basteleur font-700">Issue</div>
|
||||
<nav className="absolute right-2 flex items-center gap-4">
|
||||
{authState === "authenticated" ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/app">Open app</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/login">Sign in</Link>
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col items-center justify-center gap-8">
|
||||
<div className="max-w-3xl text-center space-y-4">
|
||||
<h1 className="text-[54px] font-basteleur font-700">
|
||||
Need a snappy project management tool?
|
||||
</h1>
|
||||
<p className="text-[24px] font-goudy text-muted-foreground">
|
||||
Build your next project with <span className="font-goudy font-700">Issue.</span>
|
||||
</p>
|
||||
<p className="text-[18px] font-goudy text-muted-foreground">
|
||||
Sick of Jira? Say hello to your new favorite project management tool.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{authState === "authenticated" ? (
|
||||
<Button asChild size="lg">
|
||||
<Link to="/app">Open app</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild size="lg">
|
||||
<Link to="/login">Get started</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="flex justify-center gap-2 items-center py-2 border-t">
|
||||
<span className="font-300 text-sm text-muted-foreground">
|
||||
Built by{" "}
|
||||
<a
|
||||
href="https://ob248.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-personality"
|
||||
>
|
||||
Oliver Bryan
|
||||
</a>
|
||||
</span>
|
||||
<a href="https://ob248.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="oliver-bryan.svg" alt="Oliver Bryan" className="w-3 h-3" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
packages/frontend/src/pages/Login.tsx
Normal file
47
packages/frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { UserRecord } from "@issue/shared";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import Loading from "@/components/loading";
|
||||
import LogInForm from "@/components/login-form";
|
||||
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [checking, setChecking] = useState(true);
|
||||
const checkedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkedRef.current) return;
|
||||
checkedRef.current = true;
|
||||
|
||||
fetch(`${getServerURL()}/auth/me`, {
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { user: UserRecord; csrfToken: string };
|
||||
setCsrfToken(data.csrfToken);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
const next = searchParams.get("next") || "/app";
|
||||
navigate(next, { replace: true });
|
||||
} else {
|
||||
clearAuth();
|
||||
setChecking(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setChecking(false);
|
||||
});
|
||||
}, [navigate, searchParams]);
|
||||
|
||||
if (checking) {
|
||||
return <Loading message="Checking authentication" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]">
|
||||
<LogInForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
packages/frontend/src/pages/NotFound.tsx
Normal file
11
packages/frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { CircleQuestionMark } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className={`w-full h-[100vh] flex flex-col items-center justify-center gap-4`}>
|
||||
<CircleQuestionMark size={72} />
|
||||
<span className="text-7xl font-500">404</span>
|
||||
<span className="text-2xl font-400">Not Found</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
packages/frontend/src/pages/Test.tsx
Normal file
18
packages/frontend/src/pages/Test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import LogOutButton from "@/components/log-out-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function Test() {
|
||||
return (
|
||||
<main className="w-full h-[100vh] flex flex-col items-center justify-center gap-4 p-4">
|
||||
<h1 className="text-3xl font-bold">Test</h1>
|
||||
|
||||
<p className="text-muted-foreground">Simple test page for demo</p>
|
||||
<div className="flex gap-4">
|
||||
<Button linkTo="/">go back to "/"</Button>
|
||||
</div>
|
||||
<LogOutButton />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Test;
|
||||
Reference in New Issue
Block a user