refactor(v5): migrate frontend from JSX to TypeScript

Convert all v5 JS/JSX source files to TS/TSX, add tsconfig.json,
install typescript and @types/react* dev deps, add typecheck npm
script, create types.ts with shared prop types, and update Tailwind
CSS source glob and shadcn components.json to include TS extensions.
This commit is contained in:
Andras Bacsai
2026-06-16 20:15:36 +02:00
parent 859a701669
commit 790bd2e28c
23 changed files with 442 additions and 320 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-lyra",
"rsc": false,
"tsx": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "resources/css/v5/app.css",
-9
View File
@@ -1,9 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["resources/js/v5/*"]
}
},
"include": ["resources/js/v5/**/*.js", "resources/js/v5/**/*.jsx"]
}
+59 -2
View File
@@ -25,12 +25,15 @@
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"laravel-vite-plugin": "2.0.1",
"postcss": "8.5.15",
"shadcn": "^4.11.0",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.18",
"typescript": "^6.0.3",
"vite": "7.3.2"
}
},
@@ -78,6 +81,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -1401,6 +1405,7 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -2268,6 +2273,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/validate-npm-package-name": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -2309,7 +2335,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/accepts": {
"version": "2.0.0",
@@ -2528,6 +2555,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -2919,6 +2947,13 @@
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -3383,6 +3418,7 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -3832,6 +3868,7 @@
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -5484,6 +5521,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5493,6 +5531,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6163,7 +6202,8 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -6300,6 +6340,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
@@ -6414,6 +6469,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6599,6 +6655,7 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+5 -1
View File
@@ -4,16 +4,20 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
"build": "vite build",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"laravel-vite-plugin": "2.0.1",
"postcss": "8.5.15",
"shadcn": "^4.11.0",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.18",
"typescript": "^6.0.3",
"vite": "7.3.2"
},
"dependencies": {
+1 -1
View File
@@ -3,7 +3,7 @@
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@source '../../js/v5/**/*.{js,jsx}';
@source '../../js/v5/**/*.{js,jsx,ts,tsx}';
@source '../../../storage/framework/views/*.php';
@custom-variant dark (&:where(.dark, .dark *));
@@ -1,6 +1,7 @@
import { Head } from '@inertiajs/react';
import { AppNavbar } from '@/components/app-navbar';
import type { V5HomeProps } from '@/types';
export default function Home({
flux,
@@ -8,7 +9,7 @@ export default function Home({
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
}) {
}: V5HomeProps) {
return (
<>
<Head title="Magic" />
@@ -5,7 +5,7 @@ createInertiaApp({
id: 'v5-app',
pages: {
path: './Pages',
extension: '.jsx',
extension: '.tsx',
},
strictMode: true,
});
@@ -2,8 +2,9 @@ import { useMemo, useState } from 'react';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { csrfToken } from '@/lib/csrf';
import type { SelectItemOption, V5HomeProps, V5Project } from '@/types';
function persistSelection(projectUuid, environmentUuid) {
function persistSelection(projectUuid: string, environmentUuid: string): void {
void fetch('/v5/selection', {
method: 'POST',
credentials: 'same-origin',
@@ -19,27 +20,33 @@ function persistSelection(projectUuid, environmentUuid) {
});
}
type AppNavbarProps = V5HomeProps;
export function AppNavbar({
flux,
clusters = [],
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
}) {
}: AppNavbarProps) {
const firstProject = projects[0] ?? null;
const [projectUuid, setProjectUuid] = useState(selectedProjectUuid ?? firstProject?.uuid ?? '');
const selectedProject = useMemo(
const [projectUuid, setProjectUuid] = useState<string>(selectedProjectUuid ?? firstProject?.uuid ?? '');
const selectedProject = useMemo<V5Project | null>(
() => projects.find((project) => project.uuid === projectUuid) ?? firstProject,
[firstProject, projectUuid, projects],
);
const firstEnvironment = selectedProject?.environments?.[0] ?? null;
const [environmentUuid, setEnvironmentUuid] = useState(selectedEnvironmentUuid ?? firstEnvironment?.uuid ?? '');
const [environmentUuid, setEnvironmentUuid] = useState<string>(selectedEnvironmentUuid ?? firstEnvironment?.uuid ?? '');
const selectedEnvironment = useMemo(
() => selectedProject?.environments?.find((environment) => environment.uuid === environmentUuid) ?? firstEnvironment,
[environmentUuid, firstEnvironment, selectedProject],
);
function selectProject(nextProjectUuid) {
function selectProject(nextProjectUuid: string | null): void {
if (nextProjectUuid === null) {
return;
}
const nextProject = projects.find((project) => project.uuid === nextProjectUuid);
const nextEnvironmentUuid = nextProject?.environments?.[0]?.uuid ?? '';
@@ -48,16 +55,20 @@ export function AppNavbar({
persistSelection(nextProjectUuid, nextEnvironmentUuid);
}
function selectEnvironment(nextEnvironmentUuid) {
function selectEnvironment(nextEnvironmentUuid: string | null): void {
if (nextEnvironmentUuid === null) {
return;
}
setEnvironmentUuid(nextEnvironmentUuid);
persistSelection(projectUuid, nextEnvironmentUuid);
}
const projectItems = projects.map((project) => ({
const projectItems: SelectItemOption[] = projects.map((project) => ({
label: project.name,
value: project.uuid,
}));
const environmentItems = (selectedProject?.environments ?? []).map((environment) => ({
const environmentItems: SelectItemOption[] = (selectedProject?.environments ?? []).map((environment) => ({
label: environment.name,
value: environment.uuid,
}));
@@ -120,7 +131,7 @@ export function AppNavbar({
<div className="ml-auto flex items-center gap-3">
<div
className="hidden rounded-md border border-border bg-muted/40 px-3 py-1 text-xs text-muted-foreground lg:block"
title={flux?.socket ?? flux?.message}
title={flux?.socket ?? flux?.message ?? undefined}
>
Flux: {flux?.label ?? 'Unknown'} · {clusters.length} clusters
</div>
-55
View File
@@ -1,55 +0,0 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 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-1 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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground 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",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] 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",
},
size: {
default:
"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-none px-2 text-xs 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-none px-2.5 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": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-none",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
}
export { Button, buttonVariants }
+46
View File
@@ -0,0 +1,46 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button';
import { cva, type VariantProps } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 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-1 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",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground 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',
secondary:
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] 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',
},
size: {
default: '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-none px-2 text-xs 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-none px-2.5 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': "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-none',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type ButtonProps = React.ComponentProps<typeof ButtonPrimitive> & VariantProps<typeof buttonVariants>;
function Button({ className, variant = 'default', size = 'default', ...props }: ButtonProps) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
-194
View File
@@ -1,194 +0,0 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { CaretDownIcon, CheckIcon, CaretUpIcon } from "@phosphor-icons/react"
const Select = SelectPrimitive.Root
function SelectGroup({
className,
...props
}) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1", className)}
{...props} />
);
}
function SelectValue({
className,
...props
}) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props} />
);
}
function SelectTrigger({
className,
size = "default",
variant = "default",
children,
...props
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
data-variant={variant}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-none whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 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",
variant === "default" && "border border-input bg-transparent py-2 pr-2 pl-2.5 text-xs focus-visible:border-ring dark:bg-input/30 dark:hover:bg-input/50",
variant === "ghost" && "h-auto border border-transparent bg-transparent px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent focus-visible:border-transparent dark:bg-transparent dark:hover:bg-transparent",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon
render={
<CaretDownIcon className="pointer-events-none size-4 text-muted-foreground" />
} />
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = position === "popper" ? false : true,
...props
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50">
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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}>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-2 py-2 text-xs text-muted-foreground", className)}
{...props} />
);
}
function SelectItem({
className,
children,
...props
}) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span
className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 h-px bg-border", className)}
{...props} />
);
}
function SelectScrollUpButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<CaretUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<CaretDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+178
View File
@@ -0,0 +1,178 @@
import { Select as SelectPrimitive } from '@base-ui/react/select';
import { CaretDownIcon, CaretUpIcon, CheckIcon } from '@phosphor-icons/react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
type SelectGroupProps = React.ComponentProps<typeof SelectPrimitive.Group>;
function SelectGroup({ className, ...props }: SelectGroupProps) {
return <SelectPrimitive.Group data-slot="select-group" className={cn('scroll-my-1', className)} {...props} />;
}
type SelectValueProps = React.ComponentProps<typeof SelectPrimitive.Value>;
function SelectValue({ className, ...props }: SelectValueProps) {
return <SelectPrimitive.Value data-slot="select-value" className={cn('flex flex-1 text-left', className)} {...props} />;
}
type SelectTriggerProps = React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'default' | 'sm';
variant?: 'default' | 'ghost';
};
function SelectTrigger({ className, size = 'default', variant = 'default', children, ...props }: SelectTriggerProps) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
data-variant={variant}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-none whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 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",
variant === 'default' &&
'border border-input bg-transparent py-2 pr-2 pl-2.5 text-xs focus-visible:border-ring dark:bg-input/30 dark:hover:bg-input/50',
variant === 'ghost' &&
'h-auto border border-transparent bg-transparent px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent focus-visible:border-transparent dark:bg-transparent dark:hover:bg-transparent',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<CaretDownIcon className="pointer-events-none size-4 text-muted-foreground" />} />
</SelectPrimitive.Trigger>
);
}
type SelectPositionerProps = React.ComponentProps<typeof SelectPrimitive.Positioner>;
type SelectPopupProps = React.ComponentProps<typeof SelectPrimitive.Popup>;
type SelectContentProps = SelectPopupProps & {
position?: 'popper' | 'item-aligned';
side?: SelectPositionerProps['side'];
sideOffset?: SelectPositionerProps['sideOffset'];
align?: SelectPositionerProps['align'];
alignOffset?: SelectPositionerProps['alignOffset'];
alignItemWithTrigger?: SelectPositionerProps['alignItemWithTrigger'];
};
function SelectContent({
className,
children,
position,
side = 'bottom',
sideOffset = 4,
align = 'center',
alignOffset = 0,
alignItemWithTrigger = position === 'popper' ? false : true,
...props
}: SelectContentProps) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
type SelectLabelProps = React.ComponentProps<typeof SelectPrimitive.GroupLabel>;
function SelectLabel({ className, ...props }: SelectLabelProps) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn('px-2 py-2 text-xs text-muted-foreground', className)}
{...props}
/>
);
}
type SelectItemProps = React.ComponentProps<typeof SelectPrimitive.Item>;
function SelectItem({ className, children, ...props }: SelectItemProps) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
type SelectSeparatorProps = React.ComponentProps<typeof SelectPrimitive.Separator>;
function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
return <SelectPrimitive.Separator data-slot="select-separator" className={cn('pointer-events-none -mx-1 h-px bg-border', className)} {...props} />;
}
type SelectScrollUpButtonProps = React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>;
function SelectScrollUpButton({ className, ...props }: SelectScrollUpButtonProps) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn('top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=\'size-\'])]:size-4', className)}
{...props}
>
<CaretUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
type SelectScrollDownButtonProps = React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>;
function SelectScrollDownButton({ className, ...props }: SelectScrollDownButtonProps) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn('bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=\'size-\'])]:size-4', className)}
{...props}
>
<CaretDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -1,24 +0,0 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props} />
);
}
export { Separator }
@@ -0,0 +1,24 @@
'use client';
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator';
import type * as React from 'react';
import { cn } from '@/lib/utils';
type SeparatorProps = React.ComponentProps<typeof SeparatorPrimitive>;
function Separator({ className, orientation = 'horizontal', ...props }: SeparatorProps) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className,
)}
{...props}
/>
);
}
export { Separator };
@@ -1,3 +1,3 @@
export function csrfToken() {
export function csrfToken(): string {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
}
-6
View File
@@ -1,6 +0,0 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+46
View File
@@ -0,0 +1,46 @@
export type FluxStatus = {
available?: boolean;
label?: string | null;
message?: string | null;
socket?: string | null;
};
export type V5Server = {
id: string;
name: string;
host: string;
status: string;
capabilities: string[];
};
export type V5Cluster = {
id: string;
name: string;
description: string | null;
serversCount: number;
servers: V5Server[];
};
export type V5Environment = {
uuid: string;
name: string;
};
export type V5Project = {
uuid: string;
name: string;
environments: V5Environment[];
};
export type V5HomeProps = {
flux: FluxStatus | null;
clusters?: V5Cluster[];
projects?: V5Project[];
selectedProjectUuid?: string | null;
selectedEnvironmentUuid?: string | null;
};
export type SelectItemOption = {
label: string;
value: string;
};
+1
View File
@@ -0,0 +1 @@
declare module '*.css';
+2 -2
View File
@@ -28,10 +28,10 @@
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="{{ $viteDevServerUrl }}/@@vite/client"></script>
<script type="module" src="{{ $viteDevServerUrl }}/resources/js/v5/app.jsx"></script>
<script type="module" src="{{ $viteDevServerUrl }}/resources/js/v5/app.tsx"></script>
@else
@viteReactRefresh
@vite('resources/js/v5/app.jsx')
@vite('resources/js/v5/app.tsx')
@endif
<x-inertia::head />
</head>
+12 -11
View File
@@ -317,8 +317,8 @@ it('rejects persisted v5 selections outside the current team', function () {
});
it('defines the v5 home page as a shadcn styled canvas shell', function () {
$homePage = file_get_contents(resource_path('js/v5/Pages/Home.jsx'));
$navbarPath = resource_path('js/v5/components/app-navbar.jsx');
$homePage = file_get_contents(resource_path('js/v5/Pages/Home.tsx'));
$navbarPath = resource_path('js/v5/components/app-navbar.tsx');
expect(file_exists($navbarPath))->toBeTrue();
@@ -377,18 +377,18 @@ it('defines the v5 home page as a shadcn styled canvas shell', function () {
});
it('defines a ghost variant for compact v5 select triggers', function () {
$select = file_get_contents(resource_path('js/v5/components/ui/select.jsx'));
$select = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
expect($select)
->toContain('@base-ui/react/select')
->not->toContain('radix-ui')
->toContain('variant = "default"')
->toContain('variant === "default"')
->toContain('variant === "ghost"')
->toContain("variant = 'default'")
->toContain("variant === 'default'")
->toContain("variant === 'ghost'")
->toContain('border-transparent')
->toContain('h-auto')
->toContain('text-sm')
->toContain('position === "popper" ? false : true');
->toContain("position === 'popper' ? false : true");
});
it('uses the requested shadcn preset configuration for v5', function () {
@@ -397,6 +397,7 @@ it('uses the requested shadcn preset configuration for v5', function () {
expect($components['style'])
->toBe('base-lyra')
->and($components['tsx'])->toBeTrue()
->and($components['iconLibrary'])->toBe('phosphor')
->and($components['tailwind']['css'])->toBe('resources/css/v5/app.css')
->and($components['tailwind']['baseColor'])->toBe('zinc')
@@ -454,7 +455,7 @@ it('serves v5 dev assets from the current request host', function (string $url,
->assertSuccessful()
->assertSee("import RefreshRuntime from 'http://{$viteHost}:5173/@react-refresh'", false)
->assertSee("src=\"http://{$viteHost}:5173/@vite/client\"", false)
->assertSee("src=\"http://{$viteHost}:5173/resources/js/v5/app.jsx\"", false)
->assertSee("src=\"http://{$viteHost}:5173/resources/js/v5/app.tsx\"", false)
->assertDontSee('configured-vite-host.test', false);
} finally {
if ($originalHotFile === null) {
@@ -509,7 +510,7 @@ it('shows when flux is unavailable', function () {
});
it('does not include coolify version controls on the v5 home page', function () {
$homePage = file_get_contents(resource_path('js/v5/Pages/Home.jsx'));
$homePage = file_get_contents(resource_path('js/v5/Pages/Home.tsx'));
expect($homePage)
->not->toContain('Check coolify version')
@@ -518,11 +519,11 @@ it('does not include coolify version controls on the v5 home page', function ()
});
it('renders flux status as a compact summary', function () {
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.jsx'));
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.tsx'));
expect($navbar)
->toContain('Flux: {flux?.label ??')
->toContain('title={flux?.socket ?? flux?.message}')
->toContain('title={flux?.socket ?? flux?.message ?? undefined}')
->toContain('{clusters.length} clusters')
->not->toContain('<h2 id="flux-status-heading">Flux status</h2>')
->not->toContain('<p>{flux.message}</p>')
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"resources/js/v5/*"
]
},
"baseUrl": ".",
"ignoreDeprecations": "6.0"
},
"include": [
"resources/js/v5/**/*.ts",
"resources/js/v5/**/*.tsx"
],
"references": []
}
+1 -1
View File
@@ -36,7 +36,7 @@ export default defineConfig(({ mode }) => {
input: [
"resources/css/app.css",
"resources/js/app.js",
"resources/js/v5/app.jsx",
"resources/js/v5/app.tsx",
],
refresh: true,
}),