Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 157 additions & 4 deletions apps/frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,162 @@
import { Button } from "@/components/ui/button";
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'motion/react';
import { $api } from '@lib/providers/api';
import { IconLayoutGrid, IconSettings, IconLogout, IconChevronDown } from '@tabler/icons-react';
import Link from 'next/link';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@components/ui/dropdown-menu';
import { Button } from '@components/ui/button';

function AppCard({ name, icon }: { name: string; icon?: string | null }) {
return (
<div className="flex flex-col items-center gap-3 rounded-xl border border-border bg-card p-6 hover:bg-accent/50 transition-colors cursor-pointer">
{icon ? (
<img src={icon} alt={name} className="size-12 rounded-lg" />
) : (
<div className="size-12 rounded-lg bg-primary/10 flex items-center justify-center">
<span className="text-lg font-semibold text-primary">
{name.charAt(0).toUpperCase()}
</span>
</div>
)}
<span className="text-sm font-medium text-foreground">{name}</span>
</div>
);
}

function PortalSkeleton() {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="size-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
);
}

export default function HomePage() {
const router = useRouter();

const profileQuery = $api.useQuery(
'get',
'/api/settings/profile',
{},
{
retry: false,
refetchOnWindowFocus: false,
}
);

const appsQuery = $api.useQuery(
'get',
'/api/applications',
{},
{
retry: false,
refetchOnWindowFocus: false,
enabled: !!profileQuery.data,
}
);

useEffect(() => {
const err = profileQuery.error as unknown as { error?: string } | null;
if (err?.error === 'Unauthorized') {
router.replace('/login');
}
}, [profileQuery.error, router]);

const logoutMutation = $api.useMutation('post', '/api/logout', {
onSuccess: () => router.replace('/login'),
});

if (profileQuery.isLoading || (!profileQuery.data && !profileQuery.error)) {
return <PortalSkeleton />;
}

const profile = profileQuery.data;
if (!profile) return null;

const apps = appsQuery.data ?? [];

export default function Home() {
return (
<div>
<Button>Click me</Button>
<div className="min-h-screen bg-background">
<div className="mx-auto max-w-3xl px-6 py-14">
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-8"
>
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Applications</h1>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<div className="size-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-xs font-semibold text-primary">
{profile.display_name.charAt(0).toUpperCase()}
</span>
</div>
<span className="max-w-[120px] truncate">{profile.display_name}</span>
<IconChevronDown className="size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="font-normal">
<p className="text-sm font-medium">{profile.display_name}</p>
<p className="text-xs text-muted-foreground truncate">{profile.email}</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/dashboard" className="flex items-center gap-2 cursor-pointer">
<IconSettings className="size-4" />
Account Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={logoutMutation.isPending}
onClick={() => logoutMutation.mutate({})}
>
<IconLogout className="size-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

{/* Applications */}
<div>
<div className="flex items-center gap-2 mb-4">
<IconLayoutGrid className="size-5 text-muted-foreground" />
<h2 className="text-lg font-medium">Your applications</h2>
</div>
{apps.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{apps.map((app) => (
<AppCard key={app.slug} name={app.name} icon={app.icon} />
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border p-8 text-center">
<IconLayoutGrid className="size-8 text-muted-foreground/50 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
No applications available
</p>
</div>
)}
</div>
</motion.div>
</div>
</div>
);
}
206 changes: 206 additions & 0 deletions apps/frontend/components/ui/dropdown-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import * as React from 'react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { IconCheck, IconChevronRight, IconCircle } from '@tabler/icons-react';

import { cn } from '@/lib/utils';

const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;

function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
);
}

function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}

function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}

function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}

function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<IconCheck className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}

function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<IconCircle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}

function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-xs font-medium text-muted-foreground data-[inset]:pl-8',
className
)}
{...props}
/>
);
}

function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}

function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
}

export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
Loading
Loading