pages directory

This commit is contained in:
Oliver Bryan
2026-01-09 05:43:04 +00:00
parent d4394990b7
commit 3d963579a3
7 changed files with 8 additions and 10 deletions

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;