Add frontend #1

Merged
KartoffelChipss merged 50 commits from feature/frontend into main 2026-05-06 20:16:59 +02:00
13 changed files with 190 additions and 144 deletions
Showing only changes of commit c8ec394774 - Show all commits
+23 -80
View File
@@ -8,55 +8,40 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
} from '@/components/ui/sidebar';
import {
Home,
Library,
PanelsTopLeft,
PanelTop,
Search,
Users as UsersIcon,
Code2,
} from 'lucide-react';
import { PanelsTopLeft, Users as UsersIcon, Code2 } from 'lucide-react';
import { Link, useLocation } from 'react-router';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { useTheme } from './theme-provider';
import { getEffectiveTheme } from '../utils/effectiveTheme';
import { NavUser } from './NavUser';
import { Logo } from './logo';
import { Separator } from './ui/separator';
const AppSidebar = () => {
interface AppSidebarProps {
userName?: string;
profilePictureUrl?: string;
}
const AppSidebar = ({ userName, profilePictureUrl }: AppSidebarProps) => {
const location = useLocation();
const { theme } = useTheme();
const effectiveTheme = getEffectiveTheme(theme);
return (
<Sidebar variant="sidebar" collapsible="icon">
<Sidebar variant="floating" collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuButton
size="lg"
className="cursor-default hover:bg-transparent active:bg-transparent"
className="hover:bg-transparent active:bg-transparent"
asChild
>
<Avatar className="h-8 w-8 p-1 rounded-lg">
<AvatarImage
src={effectiveTheme === 'dark' ? '/logo.svg' : '/logo-dark.svg'}
alt={'Quay logo'}
/>
<AvatarFallback className="rounded-lg">{'Q'}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Quay</span>
<span className="truncate text-xs font-normal text-muted-foreground">
Static site deployment
</span>
</div>
<Link to="/">
<Logo />
</Link>
</SidebarMenuButton>
</SidebarMenu>
</SidebarHeader>
<Separator className="mb-2" />
<SidebarContent>
<SidebarGroup>
{/* <SidebarGroupLabel>Idk</SidebarGroupLabel> */}
{/* <SidebarGroupLabel>Sites</SidebarGroupLabel> */}
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
@@ -66,37 +51,6 @@ const AppSidebar = () => {
Sites
</Link>
</SidebarMenuButton>
<SidebarMenuSub>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link to={`/library?library=tensuramap`}>
TensuraMap
</Link>
</SidebarMenuButton>
<SidebarMenuButton asChild>
<Link to={`/library?library=tensuramap`}>bifrost</Link>
</SidebarMenuButton>
<SidebarMenuButton asChild>
<Link to={`/library?library=tensuramap`}>
spaltoon3api
</Link>
</SidebarMenuButton>
<SidebarMenuButton asChild>
<Link to={`/library?library=tensuramap`}>homepage</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenuSub>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname === '/users'}
>
<Link to={'/users'}>
<UsersIcon />
Users
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
@@ -109,34 +63,23 @@ const AppSidebar = () => {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* <SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname === '/library'}
>
<Link to={'/library'}>
<Library />
Library
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname === '/search'}
isActive={location.pathname === '/users'}
>
<Link to={'/search'}>
<Search />
Search
<Link to={'/users'}>
<UsersIcon />
Users
</Link>
</SidebarMenuButton>
</SidebarMenuItem> */}
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<NavUser />
<NavUser userName={userName} profilePictureUrl={profilePictureUrl} />
</SidebarFooter>
</Sidebar>
);
+18 -13
View File
@@ -10,14 +10,17 @@ import {
} from './ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { ChevronsUpDown, Laptop, LogOut, Moon, Sun } from 'lucide-react';
import { Link } from 'react-router';
export function NavUser() {
interface NavUserProps {
userName?: string;
profilePictureUrl?: string;
}
export function NavUser({ userName, profilePictureUrl }: NavUserProps) {
const { isMobile } = useSidebar();
const { theme, setTheme } = useTheme();
const userName = 'Jan';
const profilePictureUrl = '/api/v1/users/me/profile-image';
return (
<SidebarMenu>
<SidebarMenuItem>
@@ -25,7 +28,7 @@ export function NavUser() {
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
className="data-open:bg-sidebar-accent data-open:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage
@@ -34,10 +37,12 @@ export function NavUser() {
/>
<AvatarFallback className="rounded-lg">
{userName
? userName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()}
.toUpperCase()
: 'T'}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-md leading-tight">
@@ -61,10 +66,12 @@ export function NavUser() {
/>
<AvatarFallback className="rounded-lg">
{userName
? userName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()}
.toUpperCase()
: 'T'}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-md leading-tight">
@@ -75,7 +82,7 @@ export function NavUser() {
<DropdownMenuSeparator />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<SidebarMenuButton className="data-open:bg-sidebar-accent data-open:text-sidebar-accent-foreground">
{theme === 'light' ? (
<Sun className="text-muted-foreground" />
) : theme === 'dark' ? (
@@ -107,13 +114,11 @@ export function NavUser() {
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
// TODO logout logic
}}
>
<DropdownMenuItem asChild>
<Link to="/logout">
<LogOut />
Logout
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+15
View File
@@ -0,0 +1,15 @@
interface TopBarProps {
title: string;
button?: React.ReactNode;
}
const TopBar = ({ title, button }: TopBarProps) => {
return (
<div className="flex items-center justify-between my-4">
<h1 className="text-2xl font-semibold">{title}</h1>
{button}
</div>
);
};
export default TopBar;
@@ -0,0 +1,31 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+1 -1
View File
@@ -446,7 +446,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-[active=true]:bg-primary data-[active=true]:font-medium data-[active=true]:text-primary-foreground data-[active=true]:shadow-none data-[active=true]:hover:bg-primary data-[active=true]:hover:text-primary-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[active=true]:shadow-none data-[active=true]:hover:bg-sidebar-accent data-[active=true]:hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
{
variants: {
variant: {
+6 -6
View File
@@ -40,10 +40,10 @@
--chart-5: oklch(0.3742 0.1844 263.942);
--sidebar: oklch(0.9846 0.0017 247.8389);
--sidebar-foreground: oklch(0.3211 0 0);
--sidebar-primary: oklch(0.6225 0.2041 259.9027);
--sidebar-primary: oklch(0.4 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.951 0.0267 237.5723);
--sidebar-accent-foreground: oklch(0.3742 0.1844 263.942);
--sidebar-accent: oklch(0.9271 0.0075 260.7315);
--sidebar-accent-foreground: oklch(0.3211 0 0);
--sidebar-border: oklch(0.9271 0.0075 260.7315);
--sidebar-ring: oklch(0.6225 0.2041 259.9027);
--font-sans: Inter, sans-serif;
@@ -97,10 +97,10 @@
--chart-5: oklch(0.6225 0.2041 259.9027);
--sidebar: oklch(0.2046 0 0);
--sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.6225 0.2041 259.9027);
--sidebar-primary: oklch(0.7 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.6225 0.2041 259.9027);
--sidebar-accent-foreground: oklch(0.882 0.0588 253.9688);
--sidebar-accent: oklch(0.32 0 0);
--sidebar-accent-foreground: oklch(0.9219 0 0);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.6225 0.2041 259.9027);
--font-sans: Inter, sans-serif;
+7 -8
View File
@@ -34,6 +34,8 @@ import {
SelectValue,
} from '../../components/ui/select';
import GitServerTypeIcon from '../../components/GitServerTypeIcon';
import TopBar from '../../components/TopBar';
import { Card } from '../../components/ui/card';
const DeleteGitServerDialog = ({ gitServerId }: { gitServerId: string }) => {
const [open, setOpen] = useState(false);
@@ -50,7 +52,7 @@ const DeleteGitServerDialog = ({ gitServerId }: { gitServerId: string }) => {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon-sm">
<Button variant="ghost" size="icon-sm">
<Trash2 />
</Button>
</DialogTrigger>
@@ -81,7 +83,7 @@ const DeleteGitServerDialog = ({ gitServerId }: { gitServerId: string }) => {
const GitServerRow = ({ gitServer }: { gitServer: GitServer }) => {
return (
<div className="flex items-center gap-4 p-4 rounded-lg border">
<Card className="flex flex-row items-center justify-between px-4 py-3">
<div className="text-2xl">
<GitServerTypeIcon type={gitServer.type} size={32} />
</div>
@@ -95,7 +97,7 @@ const GitServerRow = ({ gitServer }: { gitServer: GitServer }) => {
<UpdateGitServerDialog gitServer={gitServer} />
<DeleteGitServerDialog gitServerId={gitServer.id} />
</div>
</div>
</Card>
);
};
@@ -328,7 +330,7 @@ const UpdateGitServerDialog = ({ gitServer }: { gitServer: GitServer }) => {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="icon-sm">
<Button variant="ghost" size="icon-sm">
<Edit2 />
</Button>
</DialogTrigger>
@@ -438,10 +440,7 @@ export const GitServers = () => {
return (
<Page title="Git Servers">
<div className="flex items-center justify-between mb-3">
<h1 className="text-2xl font-semibold">Git Servers</h1>
<CreateGitServerDialog />
</div>
<TopBar title="Git Servers" button={<CreateGitServerDialog />} />
<div className="flex flex-col gap-3">
{isLoading && <GitServersLoadingSkeleton />}
+13 -4
View File
@@ -18,12 +18,19 @@ import { Skeleton } from '../../components/ui/skeleton';
import { memo } from 'react';
import { useDeploymentsForSite } from '../../hooks/api/useDeploymentsForSite';
import { formatDateRelativeOrAbsolute } from '../../utils/dateTime';
import TopBar from '../../components/TopBar';
import { getSiteColor } from '../../utils/siteColors';
const SiteAvatar = ({ name, enabled }: { name: string; enabled: boolean }) => (
<div
className={`w-9 h-9 rounded-lg flex items-center justify-center text-sm font-medium shrink-0 ${
enabled ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
enabled ? `` : 'bg-muted text-muted-foreground'
}`}
style={
enabled
? { backgroundColor: getSiteColor(name).bg, color: getSiteColor(name).text }
: undefined
}
>
<span className="mt-0.75 ml-px">{name[0].toUpperCase()}</span>
</div>
@@ -86,15 +93,17 @@ const MainPage = () => {
return (
<Page title="Sites">
<div className="flex items-center justify-between mb-3">
<h1 className="text-2xl font-semibold">Sites</h1>
<TopBar
title="Sites"
button={
<Button size="default" variant={'default'} asChild>
<Link to="/sites/new">
<Plus className="w-4 h-4" />
New site
</Link>
</Button>
</div>
}
/>
<div className="flex flex-col gap-3">
{isLoading && <SitesLoadingSkeleton />}
+1 -1
View File
@@ -148,7 +148,7 @@ const NewSite = () => {
return (
<Page title="New Site">
<div className="">
<h1 className="text-2xl font-semibold mb-1">New Site</h1>
<h1 className="text-2xl font-semibold mb-1 mt-4">New Site</h1>
<p className="text-muted-foreground mb-6">
Deploy a static site from a Git repository.
</p>
+6 -3
View File
@@ -1,10 +1,11 @@
import { useEffect, type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router';
import Navbar from '../components/navbar';
import { useCurrentUser } from '../hooks/api/useCurrentUser';
import type { User } from '../api/types/user';
import { getToken } from '../utils/credentials';
import Footer from '../components/Footer';
import { SidebarProvider } from '../components/ui/sidebar';
import AppSidebar from '../components/AppSidebar';
interface PageProps {
title: string;
@@ -37,13 +38,15 @@ const Page = ({ children, title, className, requireAuth = true }: PropsWithChild
const userName = (user as User | undefined)?.name;
return (
<div>
<Navbar profilePictureUrl={''} userName={userName} />
<SidebarProvider>
<AppSidebar userName={userName} profilePictureUrl={''} />
<div className="w-full">
<main className={`max-w-(--breakpoint-xl) mx-auto px-4 ${className || ''}`}>
{children}
</main>
<Footer />
</div>
</SidebarProvider>
);
};
@@ -29,7 +29,7 @@ const DEFAULT_TAB: TabValue = 'overview';
const SiteOverviewSkeleton = memo(() => (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-2 mt-4">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32" />
@@ -130,7 +130,7 @@ const SiteOverview = () => {
{site && !isLoadingSite && !siteError && (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-2 mt-4">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold">{site.name}</h1>
+6 -7
View File
@@ -33,6 +33,8 @@ import {
SelectValue,
} from '../../components/ui/select';
import { useDeleteUser } from '../../hooks/api/users/useDeleteUser';
import TopBar from '../../components/TopBar';
import { Card } from '../../components/ui/card';
const DeleteUserDialog = ({ userId }: { userId: string }) => {
const [open, setOpen] = useState(false);
@@ -49,7 +51,7 @@ const DeleteUserDialog = ({ userId }: { userId: string }) => {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon-sm">
<Button variant="ghost" size="icon-sm">
<Trash2 />
</Button>
</DialogTrigger>
@@ -79,7 +81,7 @@ const DeleteUserDialog = ({ userId }: { userId: string }) => {
const UserRow = ({ user }: { user: User }) => {
return (
<div className="flex items-center gap-3 p-4 rounded-lg border">
<Card className="flex flex-row items-center justify-between px-4 py-3">
<Avatar size="lg">
<AvatarFallback>{user.name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
@@ -90,7 +92,7 @@ const UserRow = ({ user }: { user: User }) => {
<div className="ml-auto">
<DeleteUserDialog userId={user.id} />
</div>
</div>
</Card>
);
};
@@ -237,10 +239,7 @@ export const Users = () => {
return (
<Page title="Users">
<div className="flex items-center justify-between mb-3">
<h1 className="text-2xl font-semibold">Users</h1>
<CreateUserDialog />
</div>
<TopBar title="Users" button={<CreateUserDialog />} />
<div className="flex flex-col gap-3">
{loadingUsers && <UsersLoadingSkeleton />}
+42
View File
@@ -0,0 +1,42 @@
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash * 31 + str.charCodeAt(i)) >>> 0;
}
return hash;
}
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
export function getSiteColor(name: string): { bg: string; text: string } {
const hash = hashString(name.trim().toLowerCase());
// Hue between 210 and 260 (blue to purple)
const hue = 210 + (hash % 50);
const hash2 = hashString(hash.toString());
const saturation = 35 + (hash2 % 30); // 3565%
const lightness = 30 + (hash2 % 30); // 3060%
const bg = hslToHex(hue, saturation, lightness);
const r = parseInt(bg.slice(1, 3), 16);
const g = parseInt(bg.slice(3, 5), 16);
const b = parseInt(bg.slice(5, 7), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const text = luminance > 0.5 ? '#1a1a1a' : '#ffffff';
return { bg, text };
}