Add frontend #1

Merged
KartoffelChipss merged 50 commits from feature/frontend into main 2026-05-06 20:16:59 +02:00
38 changed files with 2687 additions and 111 deletions
Showing only changes of commit a6f60a5a38 - Show all commits
+3
View File
@@ -89,6 +89,9 @@ func validateIncomingSite(site *models.Site) error {
if site == nil {
return errors.New("site is required")
}
if site.Name == "" {
return errors.New("site name is required")
}
if site.GitServer == "" {
return errors.New("git server required")
}
+1
View File
@@ -23,6 +23,7 @@ type CustomHeaders struct {
type Site struct {
ID string `json:"id"`
Name string `json:"name"`
GitServer string `json:"git_server"`
Owner string `json:"owner"`
Repository string `json:"repository"`
+1
View File
@@ -9,6 +9,7 @@ func InitializeSQLite(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
git_server TEXT NOT NULL,
owner TEXT NOT NULL,
repository TEXT NOT NULL,
+9 -9
View File
@@ -25,7 +25,7 @@ var _ repository.SiteRepository = (*SQLiteSiteRepository)(nil)
func (r *SQLiteSiteRepository) GetSite(id string) (*models.Site, error) {
row := r.db.QueryRow(`
SELECT id, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
FROM sites WHERE id = ?`, id)
s, err := scanSite(row)
@@ -41,7 +41,7 @@ func (r *SQLiteSiteRepository) GetSite(id string) (*models.Site, error) {
func (r *SQLiteSiteRepository) GetSiteByDomain(domain string) (*models.Site, error) {
row := r.db.QueryRow(`
SELECT id, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
FROM sites WHERE domain = ?`, domain)
s, err := scanSite(row)
@@ -60,7 +60,7 @@ func (r *SQLiteSiteRepository) GetSiteByDomain(domain string) (*models.Site, err
func (r *SQLiteSiteRepository) ListSites() ([]models.Site, error) {
rows, err := r.db.Query(`
SELECT id, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
FROM sites`)
if err != nil {
return nil, fmt.Errorf("list sites: %w", err)
@@ -98,9 +98,9 @@ func (r *SQLiteSiteRepository) CreateSite(s *models.Site) error {
s.ID = uuid.NewString()
_, err = tx.Exec(`
INSERT INTO sites (id, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.ID, s.GitServer, s.Owner, s.Repository, s.Branch,
INSERT INTO sites (id, name, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.ID, s.Name, s.GitServer, s.Owner, s.Repository, s.Branch,
s.Domain, s.DeployToken, s.Enabled, s.NotFoundFile,
)
if err != nil {
@@ -130,9 +130,9 @@ func (r *SQLiteSiteRepository) UpdateSite(s *models.Site) error {
defer tx.Rollback()
_, err = tx.Exec(`
UPDATE sites SET git_server=?, owner=?, repository=?, branch=?, domain=?,
UPDATE sites SET name=?, git_server=?, owner=?, repository=?, branch=?, domain=?,
deploy_token=?, enabled=?, not_found_file=? WHERE id=?`,
s.GitServer, s.Owner, s.Repository, s.Branch, s.Domain,
s.Name, s.GitServer, s.Owner, s.Repository, s.Branch, s.Domain,
s.DeployToken, s.Enabled, s.NotFoundFile, s.ID,
)
if err != nil {
@@ -333,7 +333,7 @@ func scanSite(s scanner) (*models.Site, error) {
var site models.Site
var enabled int
err := s.Scan(
&site.ID, &site.GitServer, &site.Owner, &site.Repository,
&site.ID, &site.Name, &site.GitServer, &site.Owner, &site.Repository,
&site.Branch, &site.Domain, &site.DeployToken, &enabled, &site.NotFoundFile,
)
if err != nil {
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.96.2",
"class-variance-authority": "^0.7.1",
+77
View File
@@ -11,6 +11,9 @@ importers:
'@fontsource-variable/geist':
specifier: ^5.2.8
version: 5.2.8
'@radix-ui/react-visually-hidden':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.1)(jiti@2.6.1))
@@ -825,6 +828,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.4':
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-progress@1.1.7':
resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==}
peerDependencies:
@@ -925,6 +941,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-switch@1.2.6':
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
peerDependencies:
@@ -1110,6 +1135,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-visually-hidden@1.2.4':
resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@@ -1148,36 +1186,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
@@ -1253,24 +1297,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
@@ -2231,24 +2279,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -3888,6 +3940,15 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
@@ -4014,6 +4075,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -4196,6 +4264,15 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/rect@1.1.1': {}
'@rolldown/binding-android-arm64@1.0.0-rc.12':
+7
View File
@@ -0,0 +1,7 @@
export const API_BASE_URL = '/api/v1';
export const makeApiUrl = (endpoint: string) => {
const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL;
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${base}${path}`;
};
+24
View File
@@ -0,0 +1,24 @@
import { makeApiUrl } from '.';
export const getSites = async () => {
const response = await fetch(makeApiUrl('/sites'), {
method: 'GET',
});
if (response.status === 404) {
return { sites: [], total: 0 };
}
if (!response.ok) {
throw new Error('Failed to fetch sites');
}
return response.json();
};
export const getSite = async (id: string) => {
const response = await fetch(makeApiUrl(`/sites/${id}`), {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch site');
}
return response.json();
};
+46
View File
@@ -0,0 +1,46 @@
export interface ForwardRule {
id: string;
source: string;
destination: string;
status_code: number;
regex: boolean;
}
export interface Header {
id: string;
key: string;
value: string;
}
export interface CustomHeaders {
id: string;
source: string;
regex: boolean;
headers: Header[];
}
export interface Site {
id: string;
name: string;
git_server: string;
owner: string;
repository: string;
branch: string;
domain: string;
deploy_token: string;
enabled: boolean;
spa: boolean;
not_found_file: string;
forward_rules: ForwardRule[];
custom_headers: CustomHeaders[];
}
export interface GetAllSitesResponse {
sites: Site[];
total: number;
}
export interface CreateSiteResponse {
site: Site;
raw_deploy_token: string;
}
+115
View File
@@ -0,0 +1,115 @@
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
} from '@/components/ui/sidebar';
import { Home, Library, PanelsTopLeft, PanelTop, Search } 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';
const AppSidebar = () => {
const location = useLocation();
const { theme } = useTheme();
const effectiveTheme = getEffectiveTheme(theme);
return (
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuButton
size="lg"
className="cursor-default hover:bg-transparent active:bg-transparent"
>
<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>
</SidebarMenuButton>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
{/* <SidebarGroupLabel>Idk</SidebarGroupLabel> */}
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === '/'}>
<Link to={'/'}>
<PanelsTopLeft />
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 === '/library'}
>
<Link to={'/library'}>
<Library />
Library
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname === '/search'}
>
<Link to={'/search'}>
<Search />
Search
</Link>
</SidebarMenuButton>
</SidebarMenuItem> */}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
);
};
export default AppSidebar;
+123
View File
@@ -0,0 +1,123 @@
import { useTheme } from './theme-provider';
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from './ui/sidebar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { ChevronsUpDown, Laptop, LogOut, Moon, Sun } from 'lucide-react';
export function NavUser() {
const { isMobile } = useSidebar();
const { theme, setTheme } = useTheme();
const userName = 'Jan';
const profilePictureUrl = '/api/v1/users/me/profile-image';
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage
src={profilePictureUrl}
alt={userName + ' profile image'}
/>
<AvatarFallback className="rounded-lg">
{userName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-md leading-tight">
<span className="truncate font-medium">{userName}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage
src={profilePictureUrl}
alt={userName + ' profile image'}
/>
<AvatarFallback className="rounded-lg">
{userName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-md leading-tight">
<span className="truncate font-medium">{userName}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
{theme === 'light' ? (
<Sun className="text-muted-foreground" />
) : theme === 'dark' ? (
<Moon className="text-muted-foreground" />
) : (
<Laptop className="text-muted-foreground" />
)}
Theme
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="text-muted-foreground" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="text-muted-foreground" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<Laptop className="text-muted-foreground" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
// TODO logout logic
}}
>
<LogOut />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
+19
View File
@@ -0,0 +1,19 @@
export const Logo = () => (
<svg
fill="none"
height="32"
id="logo-7"
viewBox="0 0 124 32"
width="124"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="fill-foreground"
d="M36.87 10.07H39.87V22.2H36.87V10.07ZM41.06 17.62C41.06 14.62 42.9 12.83 45.74 12.83C48.58 12.83 50.42 14.62 50.42 17.62C50.42 20.62 48.62 22.42 45.74 22.42C42.86 22.42 41.06 20.67 41.06 17.62ZM47.41 17.62C47.41 15.97 46.76 15 45.74 15C44.72 15 44.08 16 44.08 17.62C44.08 19.24 44.71 20.22 45.74 20.22C46.77 20.22 47.41 19.3 47.41 17.63V17.62ZM51.55 22.79H54.43C54.5671 23.0945 54.7988 23.3466 55.0907 23.5088C55.3826 23.6709 55.7191 23.7345 56.05 23.69C57.19 23.69 57.79 23.07 57.79 22.17V20.49H57.73C57.491 21.0049 57.1031 21.4363 56.6165 21.7287C56.1299 22.021 55.5668 22.1608 55 22.13C52.81 22.13 51.36 20.46 51.36 17.59C51.36 14.72 52.74 12.91 55.04 12.91C55.6246 12.8871 56.2022 13.0434 56.6955 13.3579C57.1888 13.6725 57.5742 14.1303 57.8 14.67V14.67V13H60.8V22.1C60.8 24.29 58.87 25.65 56.02 25.65C53.37 25.65 51.72 24.46 51.55 22.8V22.79ZM57.8 17.61C57.8 16.15 57.13 15.23 56.07 15.23C55.01 15.23 54.36 16.14 54.36 17.61C54.36 19.08 55 19.91 56.07 19.91C57.14 19.91 57.8 19.1 57.8 17.62V17.61ZM61.93 17.61C61.93 14.61 63.77 12.82 66.61 12.82C69.45 12.82 71.3 14.61 71.3 17.61C71.3 20.61 69.5 22.41 66.61 22.41C63.72 22.41 61.93 20.67 61.93 17.62V17.61ZM68.28 17.61C68.28 15.96 67.63 14.99 66.61 14.99C65.59 14.99 65 16 65 17.63C65 19.26 65.63 20.23 66.65 20.23C67.67 20.23 68.28 19.3 68.28 17.63V17.61ZM72.44 10.82C72.4321 10.5171 72.5144 10.2187 72.6763 9.96261C72.8383 9.70651 73.0726 9.50427 73.3496 9.38151C73.6266 9.25875 73.9338 9.221 74.2323 9.27305C74.5308 9.32511 74.8071 9.46462 75.0262 9.67389C75.2454 9.88317 75.3974 10.1528 75.4631 10.4486C75.5288 10.7444 75.5052 11.053 75.3952 11.3354C75.2853 11.6177 75.094 11.8611 74.8456 12.0346C74.5973 12.2081 74.3029 12.304 74 12.31C73.7992 12.3238 73.5977 12.2959 73.4082 12.2281C73.2186 12.1603 73.0452 12.0541 72.8987 11.916C72.7522 11.778 72.6358 11.6111 72.5569 11.4259C72.4779 11.2408 72.4381 11.0413 72.44 10.84V10.82ZM72.44 13.02H75.44V22.2H72.44V13.02ZM86.33 17.61C86.33 20.61 85 22.32 82.72 22.32C82.1354 22.3575 81.5533 22.2146 81.0525 21.9106C80.5517 21.6065 80.1564 21.156 79.92 20.62H79.86V25.14H76.86V13H79.86V14.64H79.92C80.1454 14.0951 80.5332 13.6329 81.0306 13.3162C81.528 12.9995 82.1109 12.8437 82.7 12.87C85 12.91 86.37 14.63 86.37 17.63L86.33 17.61ZM83.33 17.61C83.33 16.15 82.66 15.22 81.61 15.22C80.56 15.22 79.89 16.16 79.88 17.61C79.87 19.06 80.56 19.99 81.61 19.99C82.66 19.99 83.33 19.08 83.33 17.63V17.61ZM91.48 12.81C93.97 12.81 95.48 13.99 95.55 15.88H92.82C92.82 15.23 92.28 14.82 91.45 14.82C90.62 14.82 90.25 15.14 90.25 15.61C90.25 16.08 90.58 16.23 91.25 16.37L93.17 16.76C95 17.15 95.78 17.89 95.78 19.28C95.78 21.18 94.05 22.4 91.5 22.4C88.95 22.4 87.28 21.18 87.15 19.31H90.04C90.13 19.99 90.67 20.39 91.55 20.39C92.43 20.39 92.83 20.1 92.83 19.62C92.83 19.14 92.55 19.04 91.83 18.89L90.1 18.52C88.31 18.15 87.37 17.2 87.37 15.8C87.39 14 89 12.83 91.48 12.83V12.81ZM105.79 22.18H102.9V20.47H102.84C102.681 21.0441 102.331 21.5466 101.847 21.8941C101.363 22.2415 100.775 22.413 100.18 22.38C99.7242 22.4059 99.2682 22.3337 98.8427 22.1682C98.4172 22.0027 98.0322 21.7479 97.7137 21.4208C97.3952 21.0938 97.1505 20.7021 96.9964 20.2724C96.8422 19.8427 96.7821 19.3849 96.82 18.93V13H99.82V18.24C99.82 19.33 100.38 19.91 101.31 19.91C101.528 19.9104 101.744 19.8643 101.943 19.7746C102.141 19.6849 102.319 19.5537 102.463 19.3899C102.606 19.226 102.714 19.0333 102.777 18.8247C102.84 18.616 102.859 18.3962 102.83 18.18V13H105.83L105.79 22.18ZM107.24 13H110.14V14.77H110.2C110.359 14.2035 110.702 13.7057 111.174 13.3547C111.646 13.0037 112.222 12.8191 112.81 12.83C113.409 12.7821 114.003 12.9612 114.476 13.3318C114.948 13.7024 115.264 14.2372 115.36 14.83H115.42C115.601 14.2309 115.977 13.7093 116.488 13.3472C116.998 12.9851 117.615 12.8031 118.24 12.83C118.648 12.8163 119.054 12.8886 119.432 13.0422C119.811 13.1957 120.152 13.4272 120.435 13.7214C120.718 14.0157 120.936 14.3662 121.075 14.7501C121.213 15.134 121.27 15.5429 121.24 15.95V22.2H118.24V16.75C118.24 15.75 117.79 15.29 116.95 15.29C116.763 15.2884 116.577 15.327 116.406 15.4032C116.235 15.4794 116.082 15.5914 115.958 15.7317C115.834 15.872 115.741 16.0372 115.686 16.2163C115.631 16.3955 115.616 16.5843 115.64 16.77V22.2H112.79V16.71C112.79 15.79 112.34 15.29 111.52 15.29C111.331 15.2901 111.143 15.3303 110.971 15.408C110.798 15.4858 110.643 15.5993 110.518 15.741C110.392 15.8827 110.298 16.0495 110.241 16.2304C110.185 16.4112 110.167 16.6019 110.19 16.79V22.2H107.19L107.24 13Z"
/>
<path
className="fill-foreground"
d="M28.48 10.62C27.9711 9.45636 27.2976 8.37193 26.48 7.4C25.2715 5.92034 23.7633 4.71339 22.0547 3.8586C20.3461 3.00382 18.4758 2.52057 16.567 2.44066C14.6582 2.36075 12.7541 2.68599 10.98 3.39499C9.20597 4.10398 7.60217 5.18065 6.2742 6.55413C4.94622 7.9276 3.92417 9.56675 3.27532 11.3637C2.62647 13.1606 2.36552 15.0746 2.50966 16.9796C2.65381 18.8847 3.19976 20.7376 4.1116 22.4164C5.02344 24.0953 6.28049 25.562 7.80001 26.72C8.77501 27.4779 9.85236 28.094 11 28.55C12.609 29.2094 14.3311 29.549 16.07 29.55C19.6594 29.5421 23.0992 28.1113 25.6355 25.5713C28.1717 23.0313 29.5974 19.5894 29.6 16C29.6026 14.1485 29.2213 12.3166 28.48 10.62V10.62ZM16.06 5.18999C17.6216 5.18983 19.1643 5.53113 20.58 6.18999V6.18999C20.2348 6.33916 19.8718 6.44335 19.5 6.5C18.2766 6.67709 17.1433 7.24507 16.2692 8.11917C15.3951 8.99326 14.8271 10.1266 14.65 11.35C14.5723 12.0361 14.2602 12.6744 13.7665 13.1572C13.2728 13.64 12.6277 13.9376 11.94 14C10.7166 14.1771 9.58327 14.7451 8.70918 15.6192C7.83509 16.4933 7.2671 17.6266 7.09001 18.85C7.03005 19.5024 6.7517 20.1155 6.30001 20.59V20.59C5.52066 18.9433 5.17056 17.1261 5.28228 15.3077C5.394 13.4893 5.96391 11.7287 6.93898 10.1897C7.91404 8.65079 9.26258 7.38351 10.8591 6.50584C12.4556 5.62817 14.2482 5.16864 16.07 5.16999L16.06 5.18999ZM7.79001 23C7.91001 22.89 8.03001 22.79 8.15001 22.67C9.03966 21.8075 9.61072 20.6689 9.77001 19.44C9.83459 18.7492 10.143 18.104 10.64 17.62C11.1183 17.1222 11.762 16.8163 12.45 16.76C13.6734 16.5829 14.8067 16.0149 15.6808 15.1408C16.5549 14.2667 17.1229 13.1334 17.3 11.91C17.3433 11.1875 17.6533 10.5068 18.17 10C18.6601 9.51185 19.3099 9.2171 20 9.16999C21.1239 9.01536 22.1721 8.51571 23 7.74C23.9427 8.52207 24.7413 9.46289 25.36 10.52C25.322 10.5713 25.2784 10.6183 25.23 10.66C24.7527 11.1622 24.1098 11.4748 23.42 11.54C22.1953 11.714 21.0603 12.281 20.1856 13.1556C19.311 14.0303 18.744 15.1653 18.57 16.39C18.4995 17.0784 18.1932 17.7213 17.703 18.2097C17.2127 18.6982 16.5687 19.0021 15.88 19.07C14.653 19.2457 13.5155 19.8126 12.6363 20.6863C11.7572 21.5601 11.1833 22.6941 11 23.92C10.9462 24.4087 10.7783 24.878 10.51 25.29C9.484 24.6808 8.5651 23.9072 7.79001 23V23ZM16.06 26.86C15.0453 26.8611 14.0354 26.7197 13.06 26.44C13.3937 25.818 13.6106 25.1401 13.7 24.44C13.7701 23.7531 14.075 23.1114 14.5632 22.6232C15.0514 22.135 15.6931 21.8301 16.38 21.76C17.6052 21.5849 18.7408 21.0178 19.6169 20.1435C20.4929 19.2693 21.0624 18.1348 21.24 16.91C21.3101 16.2231 21.615 15.5814 22.1032 15.0932C22.5914 14.605 23.2331 14.3001 23.92 14.23C24.842 14.1101 25.7208 13.7668 26.48 13.23C26.9016 14.8279 26.9515 16.5011 26.626 18.1213C26.3005 19.7415 25.6081 21.2657 24.6021 22.5768C23.5961 23.8878 22.3032 24.9511 20.8224 25.6849C19.3417 26.4187 17.7126 26.8036 16.06 26.81V26.86Z"
/>
</svg>
);
+36
View File
@@ -0,0 +1,36 @@
import type { ComponentProps } from 'react';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import { Link } from 'react-router';
export const NavMenu = (props: ComponentProps<typeof NavigationMenu>) => (
<NavigationMenu {...props}>
<NavigationMenuList className="space-x-0 data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-start data-[orientation=vertical]:justify-start">
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="#">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="#">Blog</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="#">About</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="#">Contact Us</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
+44
View File
@@ -0,0 +1,44 @@
import { Logo } from '@/components/logo';
import { NavMenu } from '@/components/nav-menu';
import { NavigationSheet } from '@/components/navigation-sheet';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
interface NavbarProps {
userName?: string;
profilePictureUrl?: string;
}
const Navbar = ({ userName, profilePictureUrl }: NavbarProps) => {
return (
<nav className="inset-x-4 my-6 mx-auto h-16 max-w-(--breakpoint-xl) rounded-full border bg-background">
<div className="mx-auto flex h-full items-center justify-between px-4">
<Logo />
{/* Desktop Menu */}
<NavMenu className="hidden md:block" />
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={profilePictureUrl} alt={userName + ' profile image'} />
<AvatarFallback className="rounded-lg">
{userName
? userName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
: 'A'}
</AvatarFallback>
</Avatar>
{/* Mobile Menu */}
<div className="md:hidden">
<NavigationSheet />
</div>
</div>
</div>
</nav>
);
};
export default Navbar;
@@ -0,0 +1,26 @@
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Logo } from '@/components/logo';
import { NavMenu } from '@/components/nav-menu';
export const NavigationSheet = () => {
return (
<Sheet>
<VisuallyHidden>
<SheetTitle>Navigation Menu</SheetTitle>
</VisuallyHidden>
<SheetTrigger asChild>
<Button className="rounded-full" size="icon" variant="outline">
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="px-6 py-3">
<Logo />
<NavMenu className="mt-6 [&>div]:h-full" orientation="vertical" />
</SheetContent>
</Sheet>
);
};
+110
View File
@@ -0,0 +1,110 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+122
View File
@@ -0,0 +1,122 @@
import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+28 -29
View File
@@ -1,57 +1,56 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
);
function Button({
className,
variant = "default",
size = "default",
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
@@ -61,7 +60,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,267 @@
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 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 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
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(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</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-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
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(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon 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("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,164 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"top-0 left-0 w-full p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 duration-100 md:w-(--radix-navigation-menu-viewport-width) data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import { Separator as SeparatorPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'w-px self-stretch',
className
)}
{...props}
/>
);
}
export { Separator };
+145
View File
@@ -0,0 +1,145 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+673
View File
@@ -0,0 +1,673 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { PanelLeftIcon } from 'lucide-react';
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
dir,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
return (
<div
data-slot="sidebar"
className={cn(
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className
)}
{...props}
/>
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('h-8 w-full bg-background shadow-none', className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'div';
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
className
)}
{...props}
/>
);
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
);
}
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',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive ? 'true' : undefined}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground',
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'a';
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden 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-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+10
View File
@@ -0,0 +1,10 @@
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }
+57
View File
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+10
View File
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { getSites } from '../../api/sites.api';
import type { GetAllSitesResponse } from '../../api/types/site';
export function useSites() {
return useQuery<GetAllSitesResponse>({
queryKey: ['sites'],
queryFn: getSites,
});
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+28 -28
View File
@@ -1,38 +1,38 @@
@import "tailwindcss";
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1.0000 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.3211 0 0);
--card: oklch(1.0000 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.3211 0 0);
--popover: oklch(1.0000 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.3211 0 0);
--primary: oklch(0.6225 0.2041 259.9027);
--primary-foreground: oklch(1.0000 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9665 0.0045 258.3247);
--secondary-foreground: oklch(0.4419 0.0375 257.2811);
--muted: oklch(0.9846 0.0017 247.8389);
--muted-foreground: oklch(0.5471 0.0321 263.2921);
--accent: oklch(0.9510 0.0267 237.5723);
--accent-foreground: oklch(0.3742 0.1844 263.9420);
--accent: oklch(0.951 0.0267 237.5723);
--accent-foreground: oklch(0.3742 0.1844 263.942);
--destructive: oklch(0.6496 0.2362 26.9032);
--destructive-foreground: oklch(1.0000 0 0);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9271 0.0075 260.7315);
--input: oklch(0.9271 0.0075 260.7315);
--ring: oklch(0.6225 0.2041 259.9027);
--chart-1: oklch(0.6225 0.2041 259.9027);
--chart-2: oklch(0.5469 0.2507 262.8085);
--chart-3: oklch(0.4902 0.2693 263.7106);
--chart-4: oklch(0.4234 0.2370 263.9162);
--chart-5: oklch(0.3742 0.1844 263.9420);
--chart-4: oklch(0.4234 0.237 263.9162);
--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-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9510 0.0267 237.5723);
--sidebar-accent-foreground: oklch(0.3742 0.1844 263.9420);
--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-border: oklch(0.9271 0.0075 260.7315);
--sidebar-ring: oklch(0.6225 0.2041 259.9027);
--font-sans: Inter, sans-serif;
@@ -47,11 +47,11 @@
--shadow-color: oklch(0 0 0);
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
@@ -65,15 +65,15 @@
--popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9219 0 0);
--primary: oklch(0.6225 0.2041 259.9027);
--primary-foreground: oklch(1.0000 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2393 0 0);
--muted-foreground: oklch(0.7155 0 0);
--accent: oklch(0.5802 0.1915 259.7416);
--accent-foreground: oklch(0.8820 0.0588 253.9688);
--accent-foreground: oklch(0.882 0.0588 253.9688);
--destructive: oklch(0.6496 0.2362 26.9032);
--destructive-foreground: oklch(1.0000 0 0);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0);
--ring: oklch(0.6225 0.2041 259.9027);
@@ -85,9 +85,9 @@
--sidebar: oklch(0.2046 0 0);
--sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.6225 0.2041 259.9027);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.6225 0.2041 259.9027);
--sidebar-accent-foreground: oklch(0.8820 0.0588 253.9688);
--sidebar-accent-foreground: oklch(0.882 0.0588 253.9688);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.6225 0.2041 259.9027);
--font-sans: Inter, sans-serif;
@@ -102,11 +102,11 @@
--shadow-color: oklch(0 0 0);
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { BrowserRouter, Route, Routes } from 'react-router';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import MainPage from './pages/Main/Main';
import { ThemeProvider } from './components/theme-provider';
import './index.css';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
+133 -5
View File
@@ -1,10 +1,138 @@
const MainPage = () => {
return (
<div>
<h1>Main Page</h1>
<p>Welcome to the main page of our application!</p>
import { Check, ChevronRight, FolderOpen, Plus, X } from 'lucide-react';
import { Button } from '../../components/ui/button';
import Page from '../Page';
import { Card } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { Link } from 'react-router';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { useSites } from '../../hooks/api/useSites';
import type { Site } from '../../api/types/site';
import { Skeleton } from '../../components/ui/skeleton';
import { memo } from 'react';
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'
}`}
>
<span className="mt-0.75 ml-px">{name[0].toUpperCase()}</span>
</div>
);
const SiteRow = ({ site }: { site: Site }) => {
return (
<Link to={`/sites/${site.id}`}>
<Card className="flex flex-row items-center justify-between px-4 py-3 cursor-pointer hover:border-border transition-colors">
<div className="flex items-center gap-3">
<SiteAvatar name={site.name} enabled={site.enabled} />
<div>
<p
className={`text-sm font-medium leading-tight ${!site.enabled && 'text-muted-foreground'}`}
>
{site.name}
</p>
<p className="text-xs text-muted-foreground font-mono mt-0.5">
{site.domain}
</p>
</div>
</div>
<div className="flex items-center gap-5">
<div className="text-right hidden sm:block">
{/* TODO: Replace with actual deployment time */}
<p className="text-[11px] uppercase tracking-wide text-muted-foreground mb-0.5">
Last deployed
</p>
<p className="text-sm text-muted-foreground">1 hour ago</p>
</div>
<Badge variant={site.enabled ? 'default' : 'secondary'} className="gap-1.5">
{site.enabled ? <Check /> : <X />}
<span>{site.enabled ? 'Enabled' : 'Disabled'}</span>
</Badge>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
</Card>
</Link>
);
};
const SitesLoadingSkeleton = memo(() => {
return (
<div className="w-full space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
);
});
const MainPage = () => {
const { data: sites, isLoading, error } = useSites();
const handleAddSite = () => {};
return (
<Page title="Sites">
<div className="flex items-center justify-between mb-3">
<h1 className="text-2xl font-semibold">Sites</h1>
<Button size="default" onClick={handleAddSite}>
<Plus className="w-4 h-4" />
New site
</Button>
</div>
<div className="flex flex-col gap-3">
{isLoading && <SitesLoadingSkeleton />}
{error && (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<X />
</EmptyMedia>
<EmptyTitle className="text-xl">Failed to load sites</EmptyTitle>
<EmptyDescription>
An error occurred while fetching your sites. Please try again later.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{sites &&
sites.sites.length > 0 &&
!error &&
!isLoading &&
sites.sites.map((site) => <SiteRow key={site.id} site={site} />)}
{sites && !error && !isLoading && sites.sites.length === 0 && (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderOpen />
</EmptyMedia>
<EmptyTitle className="text-xl font-semibold">
No sites found
</EmptyTitle>
<EmptyDescription>
You haven&apos;t added any sites yet.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant={'secondary'} onClick={handleAddSite}>
<Plus />
New site
</Button>
</EmptyContent>
</Empty>
)}
</div>
</Page>
);
};
export default MainPage;
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, type PropsWithChildren } from 'react';
import Navbar from '../components/navbar';
interface PageProps {
title: string;
}
const Page = ({ children, title }: PropsWithChildren<PageProps>) => {
useEffect(() => {
if (title) document.title = title;
}, [title]);
return (
<div>
<Navbar
profilePictureUrl="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.pinimg.com%2Foriginals%2Fca%2Fa9%2F89%2Fcaa98995f578f038373cb6874b68fdfb.jpg&f=1&nofb=1&ipt=0e7adaafec6ff834508c5ea329c5686552e35fe6bc06c299f38ebee88b22cf0a"
userName="jan"
/>
<main className="max-w-(--breakpoint-xl) mx-auto px-4">{children}</main>
</div>
);
};
export default Page;
+9
View File
@@ -0,0 +1,9 @@
import type { Theme } from '@/components/theme-provider';
export function getEffectiveTheme(theme: Theme): 'light' | 'dark' {
return theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme;
}
+9 -9
View File
@@ -1,14 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from "path";
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
'@': path.resolve(__dirname, './src'),
},
},
server: {
@@ -16,9 +16,9 @@ export default defineConfig({
allowedHosts: ['mbjan.local'],
proxy: {
'/api/v1': {
target: 'http://localhost:4321/api/v1',
target: 'http://localhost:4321',
changeOrigin: true,
},
}
}
})
},
},
});