landing page implementation

needs some work, but does a great job for now
This commit is contained in:
Oliver Bryan
2026-01-09 02:08:00 +00:00
parent f2786e3095
commit bc07baa25d
8 changed files with 208 additions and 42 deletions

View File

@@ -5,7 +5,7 @@
"identifier": "com.hex248.issue", "identifier": "com.hex248.issue",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420/app",
"beforeBuildCommand": "bun run build", "beforeBuildCommand": "bun run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
@@ -17,7 +17,8 @@
"height": 900, "height": 900,
"minWidth": 640, "minWidth": 640,
"minHeight": 360, "minHeight": 360,
"decorations": false "decorations": false,
"url": "/app"
} }
], ],
"security": { "security": {

View File

@@ -1,5 +1,7 @@
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Auth } from "@/components/auth-provider"; import { Auth } from "@/components/auth-provider";
import Landing from "@/components/landing";
import LoginPage from "@/components/login-page";
import NotFound from "@/components/NotFound"; import NotFound from "@/components/NotFound";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import Index from "@/Index"; import Index from "@/Index";
@@ -8,15 +10,33 @@ import Test from "@/Test";
function App() { function App() {
return ( return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<Auth> <BrowserRouter>
<BrowserRouter> <Routes>
<Routes> {/* public routes */}
<Route path="/" element={<Index />} /> <Route path="/" element={<Landing />} />
<Route path="/test" element={<Test />} /> <Route path="/login" element={<LoginPage />} />
<Route path={"*"} element={<NotFound />} />
</Routes> {/* authed routes */}
</BrowserRouter> <Route
</Auth> path="/app"
element={
<Auth>
<Index />
</Auth>
}
/>
<Route
path="/test"
element={
<Auth>
<Test />
</Auth>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@@ -1,12 +1,13 @@
import type { UserRecord } from "@issue/shared"; import type { UserRecord } from "@issue/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import Loading from "@/components/loading"; import Loading from "@/components/loading";
import LogInForm from "@/components/login-form";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
export function Auth({ children }: { children: React.ReactNode; loggedInDefault?: boolean }) { export function Auth({ children }: { children: React.ReactNode }) {
const [loggedIn, setLoggedIn] = useState<boolean>(); const [loggedIn, setLoggedIn] = useState<boolean>();
const fetched = useRef(false); const fetched = useRef(false);
const location = useLocation();
useEffect(() => { useEffect(() => {
if (fetched.current) return; if (fetched.current) return;
@@ -40,12 +41,11 @@ export function Auth({ children }: { children: React.ReactNode; loggedInDefault?
if (loggedIn) { if (loggedIn) {
return <>{children}</>; return <>{children}</>;
} }
if (loggedIn === false)
return (
<div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]">
<LogInForm />
</div>
);
return <Loading message={"Understanding your authentication state"} />; if (loggedIn === false) {
const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?next=${next}`} replace />;
}
return <Loading message={"Checking authentication"} />;
} }

View File

@@ -0,0 +1,104 @@
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 { getServerURL } from "@/lib/utils";
type AuthState = "unknown" | "authenticated" | "unauthenticated";
export default function Landing() {
const [authState, setAuthState] = useState<AuthState>(() => {
// if token exists, assume authenticated until verified
return localStorage.getItem("token") ? "unknown" : "unauthenticated";
});
const verifiedRef = useRef(false);
useEffect(() => {
if (verifiedRef.current) return;
verifiedRef.current = true;
const token = localStorage.getItem("token");
if (!token) {
setAuthState("unauthenticated");
return;
}
// verify token in background
fetch(`${getServerURL()}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(async (res) => {
if (res.ok) {
const user = (await res.json()) as UserRecord;
localStorage.setItem("user", JSON.stringify(user));
setAuthState("authenticated");
} else {
localStorage.removeItem("token");
localStorage.removeItem("user");
setAuthState("unauthenticated");
}
})
.catch(() => {
localStorage.removeItem("token");
localStorage.removeItem("user");
setAuthState("unauthenticated");
});
}, []);
return (
<div className="min-h-screen flex flex-col">
<header className="flex items-center justify-between p-2 border-b">
<div className="text-lg font-semibold">Issue</div>
<nav className="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-2xl text-center space-y-4">
<h1 className="text-4xl font-600">
Does your team need a snappy project management tool?
</h1>
<p className="text-md text-muted-foreground font-500">
Build your next project with <span className="font-700">Issue</span>.
</p>
<p className="text-md text-muted-foreground font-400">
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="p-2 border-t text-center text-sm font-300 text-muted-foreground">
Built by{" "}
<a
href="https://ob248.com"
target="_blank"
rel="noopener noreferrer"
className="hover:underline underline-offset-2"
>
Oliver Bryan
</a>
</footer>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { AlertTriangle, X } from "lucide-react"; import { AlertTriangle, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog"; import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -17,6 +18,9 @@ const DEMO_USERS = [
]; ];
export default function LogInForm() { export default function LogInForm() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false); const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
const [showWarning, setShowWarning] = useState(() => { const [showWarning, setShowWarning] = useState(() => {
return localStorage.getItem("hide-under-construction") !== "true"; return localStorage.getItem("hide-under-construction") !== "true";
@@ -48,7 +52,8 @@ export default function LogInForm() {
const data = await res.json(); const data = await res.json();
localStorage.setItem("token", data.token); localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user)); localStorage.setItem("user", JSON.stringify(data.user));
window.location.href = ""; const next = searchParams.get("next") || "/app";
navigate(next, { replace: true });
} }
// unauthorized // unauthorized
else if (res.status === 401) { else if (res.status === 401) {
@@ -84,7 +89,8 @@ export default function LogInForm() {
const data = await res.json(); const data = await res.json();
localStorage.setItem("token", data.token); localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user)); localStorage.setItem("user", JSON.stringify(data.user));
window.location.href = ""; const next = searchParams.get("next") || "/app";
navigate(next, { replace: true });
} }
// bad request (probably a bad user input) // bad request (probably a bad user input)
else if (res.status === 400) { else if (res.status === 400) {

View File

@@ -0,0 +1,52 @@
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 { getServerURL } from "@/lib/utils";
export default function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [checking, setChecking] = useState(true);
const checkedRef = useRef(false);
useEffect(() => {
if (checkedRef.current) return;
checkedRef.current = true;
const token = localStorage.getItem("token");
if (!token) {
setChecking(false);
return;
}
fetch(`${getServerURL()}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(async (res) => {
if (res.ok) {
// logged in, redirect to next if defined
// fallback to /app
const next = searchParams.get("next") || "/app";
navigate(next, { replace: true });
} else {
localStorage.removeItem("token");
localStorage.removeItem("user");
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

@@ -1,19 +0,0 @@
import type { UserRecord } from "@issue/shared";
import type { ReactNode } from "react";
import Header from "@/components/header";
export function SettingsPageLayout({ title, children }: { title: string; children?: ReactNode }) {
const user = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord;
return (
<main className="w-full h-screen flex flex-col">
<Header user={user}>
<div className="flex gap-1 items-center">
<h1 className="text-3xl font-600">{title}</h1>
</div>
</Header>
<div className="flex flex-col items-center justify-center w-full flex-1 text-md">{children}</div>
</main>
);
}

View File

@@ -1,6 +1,8 @@
- landing/marketing page - landing/marketing page
- does your team need a snappy project management tool? - does your team need a snappy project management tool?
- build your next project with Issue (might need a new name...) - build your next project with Issue (might need a new name...)
- add loading state to landing CTAs during auth verification
- dedicated /register route (currently login/register are combined on /login)
- real logo - real logo
- org settings - org settings
- sprints - sprints
@@ -10,7 +12,7 @@
- admins are capable of deleting comments from members who are at their permission level or below (not sure if this should apply, or if ANYONE should have control over others' comments - people in an org tend to be trusted to not be trolls) - admins are capable of deleting comments from members who are at their permission level or below (not sure if this should apply, or if ANYONE should have control over others' comments - people in an org tend to be trusted to not be trolls)
- status - status
- predefined statuses are added to organisation by default. list of statuses can be edited by owner/admin (maybe this should be on projects rather than organisations?) - predefined statuses are added to organisation by default. list of statuses can be edited by owner/admin (maybe this should be on projects rather than organisations?)
- sprints - sprint
- time tracking (linked to issues or standalone) - time tracking (linked to issues or standalone)
- user preferences - user preferences
- "assign to me by default" option for new issues - "assign to me by default" option for new issues