login/logout logic, breadcrumbs, initial ui stuff

This commit is contained in:
wisplite
2025-11-19 15:38:21 -06:00
parent ccf644acef
commit d176d6d761
23 changed files with 1359 additions and 36 deletions
+45 -9
View File
@@ -1,16 +1,52 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
function App() {
const [count, setCount] = useState(0)
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom'
import Gallery from './gallery'
import Login from './account/login'
import { getServerUrl } from './hooks/getConstants'
import { useEffect } from 'react'
import CreateRootUser from './account/createRoot'
import { AccountProvider } from './contexts/useAccount'
import { NotifierProvider } from './contexts/useNotifier'
function RedirectHandler() {
return (
<div>
<h1>Raster</h1>
<p className="text-red-500">Hello World</p>
<div className="flex flex-col items-center justify-center h-full w-full bg-[#141414]">
</div>
)
}
function App() {
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
const checkRootUser = async () => {
const response = await fetch(`${getServerUrl()}/api/user/rootUserExists`)
const data = await response.json()
if (data.exists) {
// Only navigate to gallery if we're on the root path
if (location.pathname === '/') {
navigate('/gallery')
}
} else {
// Always redirect to onboarding if root user doesn't exist
if (location.pathname !== '/onboarding/createRootUser') {
navigate('/onboarding/createRootUser')
}
}
}
checkRootUser()
}, [location.pathname, navigate])
return (
<NotifierProvider>
<AccountProvider>
<Routes>
<Route path="/" element={<RedirectHandler />} />
<Route path="/gallery/*" element={<Gallery />} />
<Route path="/login" element={<Login />} />
<Route path="/onboarding/createRootUser" element={<CreateRootUser />} />
</Routes>
</AccountProvider>
</NotifierProvider>
)
}
export default App
+81
View File
@@ -0,0 +1,81 @@
import { useState } from 'react'
import { getServerUrl } from '../hooks/getConstants'
import { useNavigate } from 'react-router-dom'
export default function CreateRootUser() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const handleCreateRootUser = async () => {
const response = await fetch(`${getServerUrl()}/api/user/createUser`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password,
}),
})
const data = await response.json()
if (data.error) {
console.error(data.error)
} else {
const rootResponse = await fetch(`${getServerUrl()}/api/user/setRootUser`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
}),
})
const rootData = await rootResponse.json()
if (rootData.error) {
console.error(rootData.error)
} else {
navigate('/gallery')
}
}
}
return (
<div className="flex flex-col items-center justify-center h-full w-full bg-[#141414]">
<div className="flex flex-col w-full max-w-md px-8">
<div className="flex flex-col gap-8 border border-[#2B2B2B] rounded-lg p-8 bg-[#1a1a1a]">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold text-white red-hat-mono">Create Root User</h1>
<p className="text-sm text-gray-400 red-hat-text">This is the primary user account for Raster. Make sure to remember your credentials.</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm text-gray-400 red-hat-mono">Username</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
type="text"
placeholder="Enter username"
className="w-full px-3 py-2.5 bg-[#141414] border border-[#2B2B2B] rounded-md text-white placeholder-gray-600 focus:outline-none focus:border-[#3B3B3B] transition-colors red-hat-text"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm text-gray-400 red-hat-mono">Password</label>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter password"
className="w-full px-3 py-2.5 bg-[#141414] border border-[#2B2B2B] rounded-md text-white placeholder-gray-600 focus:outline-none focus:border-[#3B3B3B] transition-colors red-hat-text"
/>
</div>
</div>
<button className="w-full py-2.5 bg-white text-black rounded-md font-medium hover:bg-gray-200 transition-colors red-hat-mono cursor-pointer" onClick={handleCreateRootUser}>
Create
</button>
</div>
</div>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react'
import { useAccount } from '../contexts/useAccount'
import { useNavigate } from 'react-router-dom'
import { useNotifier } from '../contexts/useNotifier'
export default function Login() {
const { showError } = useNotifier()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const { login } = useAccount()
const handleLogin = () => {
login(username, password)
.then(() => {
navigate('/gallery')
})
.catch((error) => {
showError(error.message)
})
}
return (
<div className="flex flex-col items-center justify-center h-full w-full bg-[#141414]">
<div className="flex flex-col w-full max-w-md px-8">
<div className="flex flex-col gap-8 border border-[#2B2B2B] rounded-lg p-8 bg-[#1a1a1a]">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold text-white red-hat-mono">Login</h1>
<p className="text-sm text-gray-400 red-hat-text">Enter your credentials to continue</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm text-gray-400 red-hat-mono">Username</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
type="text"
placeholder="Enter username"
className="w-full px-3 py-2.5 bg-[#141414] border border-[#2B2B2B] rounded-md text-white placeholder-gray-600 focus:outline-none focus:border-[#3B3B3B] transition-colors red-hat-text"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm text-gray-400 red-hat-mono">Password</label>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter password"
className="w-full px-3 py-2.5 bg-[#141414] border border-[#2B2B2B] rounded-md text-white placeholder-gray-600 focus:outline-none focus:border-[#3B3B3B] transition-colors red-hat-text"
/>
</div>
</div>
<button className="w-full py-2.5 bg-white text-black rounded-md font-medium hover:bg-gray-200 transition-colors red-hat-mono cursor-pointer" onClick={handleLogin}>
Login
</button>
</div>
</div>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2_3)">
<rect width="120" height="120" fill="#0D1B1E"/>
<rect y="60" width="60" height="60" fill="#EBF5EE"/>
<rect width="60" height="60" fill="#FF5376"/>
<rect x="60" y="60" width="60" height="60" fill="#9DACFF"/>
</g>
<defs>
<clipPath id="clip0_2_3">
<rect width="120" height="120" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 450 B

+48
View File
@@ -0,0 +1,48 @@
import { Link } from 'react-router-dom'
import { ChevronDown, LogIn, LogOut, UserIcon } from 'lucide-react'
import * as Popover from '@radix-ui/react-popover'
import { useState } from 'react';
import { useAccount } from '../contexts/useAccount';
export default function NavBar({ path }) {
const [open, setOpen] = useState(false);
const { user, logout } = useAccount();
return (
<div className="flex flex-row items-center justify-between h-1/10 w-full px-6 py-2 border-b border-[#2B2B2B]">
<div className="flex flex-row items-center justify-start gap-2">
{path.map((item, index) => (
<div className="flex flex-row items-center justify-start gap-2 red-hat-mono">
<Link to={`/${path.slice(0, index + 1).join('/')}`} key={item} className={`text-white ${index === path.length - 1 ? 'font-bold' : ''}`}>
{item}
</Link>
{index !== path.length - 1 && <p className="text-white">/</p>}
</div>
))}
</div>
<div className="flex flex-row items-center justify-start gap-2">
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger className="flex flex-row items-center justify-start gap-0 cursor-pointer">
<UserIcon className="w-6 h-6 text-white cursor-pointer" />
<ChevronDown className={`w-4 h-4 text-white ${open ? 'rotate-180' : ''}`} />
</Popover.Trigger>
<Popover.Content align="end" sideOffset={8}>
<div className="flex flex-col items-center justify-start gap-2 bg-[#141414] border px-4 py-2 border-[#2B2B2B] rounded-md p-2 ">
<p className="text-white red-hat-text">Logged in as: {user?.Username || 'Guest'}</p>
<hr className="w-full border-[#2B2B2B]" />
{user ? (
<div className="flex flex-row items-center justify-start gap-2">
<p className="text-white cursor-pointer red-hat-text" onClick={logout}>Log Out</p>
<LogOut className="w-4 h-4 text-white cursor-pointer" onClick={logout} />
</div>
) : (
<div className="flex flex-row items-center justify-start gap-2">
<Link to="/login" className="text-white red-hat-text">Log In</Link>
<LogIn className="w-4 h-4 text-white cursor-pointer" />
</div>
)}
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { createContext, useContext, useState } from 'react'
import { getServerUrl } from '../hooks/getConstants'
const AccountContext = createContext()
export const AccountProvider = ({ children }) => {
const [accessToken, setAccessToken] = useState(null)
const [user, setUser] = useState(null)
const login = async (username, password) => {
const response = await fetch(`${getServerUrl()}/api/user/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
const data = await response.json()
if (data.error) {
throw new Error(data.error)
} else {
setAccessToken(data.accessToken)
localStorage.setItem('accessToken', data.accessToken)
fetchUserData(data.accessToken)
return true
}
}
const fetchUserData = async (accessToken) => {
if (!accessToken) {
accessToken = getAccessToken()
if (!accessToken) {
setUser(null)
return false
}
}
const response = await fetch(`${getServerUrl()}/api/user/getUserData`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
})
const data = await response.json()
console.log(data)
if (data.error) {
throw new Error(data.error)
} else {
setUser(data.userData)
return true
}
}
const logout = () => {
setAccessToken(null)
localStorage.removeItem('accessToken')
location.reload()
}
const getAccessToken = () => {
if (!accessToken && localStorage.getItem('accessToken')) {
setAccessToken(localStorage.getItem('accessToken'))
return localStorage.getItem('accessToken')
}
return accessToken
}
return <AccountContext.Provider value={{ getAccessToken, logout, login, fetchUserData, user }}>{children}</AccountContext.Provider>
}
export const useAccount = () => {
const context = useContext(AccountContext)
if (!context) {
throw new Error('useAccount must be used within an AccountProvider')
}
return context
}
+125
View File
@@ -0,0 +1,125 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { X, AlertCircle, CheckCircle, Info } from 'lucide-react'
const NotifierContext = createContext()
export const useNotifier = () => {
const context = useContext(NotifierContext)
if (!context) {
throw new Error('useNotifier must be used within a NotifierProvider')
}
return context
}
export const NotifierProvider = ({ children }) => {
const [notifications, setNotifications] = useState([])
const removeNotification = useCallback((id) => {
setNotifications((prev) => prev.filter((n) => n.id !== id))
}, [])
const addNotification = useCallback((message, type = 'info', duration = 5000) => {
const id = Date.now() + Math.random()
setNotifications((prev) => [...prev, { id, message, type, duration }])
}, [])
const showError = useCallback((message, duration = 5000) => {
addNotification(message, 'error', duration)
}, [addNotification])
const showSuccess = useCallback((message, duration = 3000) => {
addNotification(message, 'success', duration)
}, [addNotification])
const showInfo = useCallback((message, duration = 3000) => {
addNotification(message, 'info', duration)
}, [addNotification])
const showUpdate = useCallback((message, duration = 3000) => {
addNotification(message, 'info', duration)
}, [addNotification])
return (
<NotifierContext.Provider value={{ showError, showSuccess, showInfo, showUpdate }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onClose={() => removeNotification(notification.id)}
/>
))}
</div>
</NotifierContext.Provider>
)
}
const NotificationItem = ({ notification, onClose }) => {
const { message, type, duration } = notification
const [isExiting, setIsExiting] = useState(false)
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(() => {
setIsExiting(true)
}, duration)
return () => clearTimeout(timer)
}
}, [duration])
useEffect(() => {
if (isExiting) {
const timer = setTimeout(() => {
onClose()
}, 300) // Match animation duration
return () => clearTimeout(timer)
}
}, [isExiting, onClose])
const getIcon = () => {
switch (type) {
case 'error':
return <AlertCircle className="w-5 h-5 text-red-500" />
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />
default:
return <Info className="w-5 h-5 text-blue-500" />
}
}
const getBorderColor = () => {
switch (type) {
case 'error':
return 'border-red-500/50'
case 'success':
return 'border-green-500/50'
default:
return 'border-[#2B2B2B]'
}
}
return (
<div
className={`
pointer-events-auto
flex items-center gap-3
bg-[#141414] border ${getBorderColor()}
px-4 py-3 rounded-md shadow-lg
min-w-[300px] max-w-[400px]
transition-all duration-300 ease-in-out
${isExiting ? 'opacity-0 translate-x-full' : 'opacity-100 translate-x-0'}
`}
>
{getIcon()}
<p className="text-white red-hat-text text-sm flex-1">{message}</p>
<button
onClick={() => setIsExiting(true)}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import NavBar from '../components/NavBar'
import { useLocation } from 'react-router-dom';
import { useAccount } from '../contexts/useAccount';
import { useEffect } from 'react';
export default function Gallery() {
const currentPath = useLocation().pathname;
const pathList = currentPath.split('/').slice(1);
const { fetchUserData, user } = useAccount()
useEffect(() => {
fetchUserData()
}, [])
return (
<div className="flex flex-col items-center justify-start h-full w-full bg-[#141414]">
<NavBar path={pathList} />
</div>
)
}
+3
View File
@@ -0,0 +1,3 @@
export const getServerUrl = () => {
return 'http://localhost:8080';
}
+24 -1
View File
@@ -1 +1,24 @@
@import "tailwindcss";
@import "tailwindcss";
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
}
.red-hat-mono {
font-family: "Red Hat Mono", monospace;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
}
.red-hat-text {
font-family: "Red Hat Text", sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
}
+4 -1
View File
@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
+7
View File
@@ -0,0 +1,7 @@
export default function Test() {
return (
<div className="flex flex-col items-center justify-center h-full w-full">
<h1 className="text-2xl font-bold">Test</h1>
</div>
)
}