mirror of
https://github.com/vitodeploy/vito.git
synced 2025-07-01 05:56:16 +00:00
Setup Inertia (#593)
This commit is contained in:
@ -1,40 +0,0 @@
|
||||
import ace from "brace";
|
||||
import "brace/mode/javascript";
|
||||
import "brace/mode/plain_text";
|
||||
import "brace/mode/sh";
|
||||
import "brace/mode/ini";
|
||||
import "brace/ext/searchbox";
|
||||
import "./theme-vito";
|
||||
import "./mode-env";
|
||||
import "./mode-nginx";
|
||||
|
||||
window.initAceEditor = function (options = {}) {
|
||||
const editorValue = JSON.parse(options.value || "");
|
||||
const editor = ace.edit(options.id);
|
||||
editor.setTheme("ace/theme/vito");
|
||||
editor.setValue(editorValue, -1);
|
||||
editor.clearSelection();
|
||||
editor.focus();
|
||||
editor.setOptions({
|
||||
// enableBasicAutocompletion: true,
|
||||
// enableSnippets: true,
|
||||
// enableLiveAutocompletion: true,
|
||||
printMargin: false,
|
||||
});
|
||||
|
||||
editor.renderer.setScrollMargin(15, 15, 0, 0);
|
||||
editor.renderer.setPadding(15);
|
||||
|
||||
editor.getSession().on("change", function () {
|
||||
document.getElementById(`textarea-${options.id}`).value =
|
||||
editor.getValue();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
editor.resize();
|
||||
});
|
||||
|
||||
document.getElementById(`textarea-${options.id}`).innerHTML = editorValue;
|
||||
|
||||
return editor;
|
||||
};
|
@ -1,158 +0,0 @@
|
||||
ace.define(
|
||||
"ace/mode/env",
|
||||
[
|
||||
"require",
|
||||
"exports",
|
||||
"module",
|
||||
"ace/lib/oop",
|
||||
"ace/mode/text",
|
||||
"ace/mode/env_highlight_rules",
|
||||
"ace/mode/folding/ini",
|
||||
"ace/mode/behaviour",
|
||||
],
|
||||
function (require, exports) {
|
||||
var oop = require("../lib/oop");
|
||||
var TextMode = require("./text").Mode;
|
||||
var Behaviour = require("./behaviour").Behaviour;
|
||||
var envHighlightRules =
|
||||
require("./env_highlight_rules").envHighlightRules;
|
||||
|
||||
var Mode = function () {
|
||||
this.HighlightRules = envHighlightRules;
|
||||
this.$behaviour = new Behaviour();
|
||||
};
|
||||
|
||||
oop.inherits(Mode, TextMode);
|
||||
|
||||
(function () {
|
||||
(this.lineCommentStart = "#"),
|
||||
(this.blockComment = null),
|
||||
(this.$id = "ace/mode/env");
|
||||
}).call(Mode.prototype),
|
||||
(exports.Mode = Mode);
|
||||
},
|
||||
);
|
||||
ace.define(
|
||||
"ace/mode/env_highlight_rules",
|
||||
[
|
||||
"require",
|
||||
"exports",
|
||||
"module",
|
||||
"ace/lib/oop",
|
||||
"ace/mode/text_highlight_rules",
|
||||
],
|
||||
function (require, exports, module) {
|
||||
"use strict";
|
||||
|
||||
var oop = require("../lib/oop");
|
||||
var TextHighlightRules =
|
||||
require("./text_highlight_rules").TextHighlightRules;
|
||||
|
||||
var envHighlightRules = function () {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: "punctuation.definition.comment.env",
|
||||
regex: "#.*",
|
||||
push_: [
|
||||
{
|
||||
token: "comment.line.number-sign.env",
|
||||
regex: "$|^",
|
||||
next: "pop",
|
||||
},
|
||||
{
|
||||
defaultToken: "comment.line.number-sign.env",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
token: "punctuation.definition.comment.env",
|
||||
regex: "#.*",
|
||||
push_: [
|
||||
{
|
||||
token: "comment.line.semicolon.env",
|
||||
regex: "$|^",
|
||||
next: "pop",
|
||||
},
|
||||
{
|
||||
defaultToken: "comment.line.semicolon.env",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
token: [
|
||||
"keyword.other.definition.env",
|
||||
"text",
|
||||
"punctuation.separator.key-value.env",
|
||||
],
|
||||
regex: "\\b([a-zA-Z0-9_.-]+)\\b(\\s*)(=)",
|
||||
},
|
||||
{
|
||||
token: [
|
||||
"punctuation.definition.entity.env",
|
||||
"constant.section.group-title.env",
|
||||
"punctuation.definition.entity.env",
|
||||
],
|
||||
regex: "^(\\[)(.*?)(\\])",
|
||||
},
|
||||
{
|
||||
token: "punctuation.definition.string.begin.env",
|
||||
regex: "'",
|
||||
push: [
|
||||
{
|
||||
token: "punctuation.definition.string.end.env",
|
||||
regex: "'",
|
||||
next: "pop",
|
||||
},
|
||||
{
|
||||
token: "constant.language.escape",
|
||||
regex: "\\\\(?:[\\\\0abtrn;#=:]|x[a-fA-F\\d]{4})",
|
||||
},
|
||||
{
|
||||
defaultToken: "string.quoted.single.env",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
token: "punctuation.definition.string.begin.env",
|
||||
regex: '"',
|
||||
push: [
|
||||
{
|
||||
token: "constant.language.escape",
|
||||
regex: "\\\\(?:[\\\\0abtrn;#=:]|x[a-fA-F\\d]{4})",
|
||||
},
|
||||
{
|
||||
token: "support.constant.color",
|
||||
regex: /\${[\w]+}/,
|
||||
},
|
||||
{
|
||||
token: "punctuation.definition.string.end.env",
|
||||
regex: '"',
|
||||
next: "pop",
|
||||
},
|
||||
{
|
||||
defaultToken: "string.quoted.double.env",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
token: "constant.language.boolean",
|
||||
regex: /(?:true|false)\b/,
|
||||
},
|
||||
],
|
||||
};
|
||||
this.normalizeRules();
|
||||
};
|
||||
|
||||
envHighlightRules.metaData = {
|
||||
fileTypes: ["env"],
|
||||
keyEquivalent: "^~I",
|
||||
name: "Env",
|
||||
scopeName: "source.env",
|
||||
};
|
||||
|
||||
oop.inherits(envHighlightRules, TextHighlightRules);
|
||||
|
||||
exports.envHighlightRules = envHighlightRules;
|
||||
},
|
||||
);
|
File diff suppressed because one or more lines are too long
@ -1,47 +0,0 @@
|
||||
ace.define(
|
||||
"ace/theme/vito",
|
||||
["require", "exports", "module", "ace/lib/dom"],
|
||||
function (require, exports) {
|
||||
(exports.isDark = true),
|
||||
(exports.cssClass = "ace-vito rounded-lg w-full"),
|
||||
(exports.cssText = `
|
||||
.ace-vito .ace_scrollbar::-webkit-scrollbar { width: 12px;}
|
||||
.ace-vito .ace_scrollbar::-webkit-scrollbar-track { background: #111827;}
|
||||
.ace-vito .ace_scrollbar::-webkit-scrollbar-thumb { background: #374151; border-radius: 4px;}
|
||||
.ace-vito .ace_gutter {background: #151c27;color: rgb(128,145,160)}
|
||||
.ace-vito .ace_print-margin {width: 1px;background: #555555}
|
||||
.ace-vito {background-color: #0f172a;color: #F9FAFB}
|
||||
.ace-vito .ace_cursor {color: #F9FAFB}
|
||||
.ace-vito .ace_marker-layer .ace_selection {background: rgba(179, 101, 57, 0.75)}
|
||||
.ace-vito.ace_multiselect .ace_selection.ace_start {box-shadow: 0 0 3px 0px #002240;}
|
||||
.ace-vito .ace_marker-layer .ace_step {background: rgb(127, 111, 19)}
|
||||
.ace-vito .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid rgba(255, 255, 255, 0.15)}
|
||||
.ace-vito .ace_marker-layer .ace_active-line {background: rgba(24, 182, 155, 0.10)}
|
||||
.ace-vito .ace_gutter-active-line {background-color: rgba(0, 0, 0, 0.35)}
|
||||
.ace-vito .ace_marker-layer .ace_selected-word {border: 1px solid rgba(179, 101, 57, 0.75)}
|
||||
.ace-vito .ace_invisible {color: rgba(255, 255, 255, 0.15)}
|
||||
.ace-vito .ace_keyword,.ace-vito .ace_meta {color: #FF9D00}
|
||||
.ace-vito .ace_constant,.ace-vito .ace_constant.ace_character,.ace-vito .ace_constant.ace_character.ace_escape,.ace-vito .ace_constant.ace_other {color: #FF628C}
|
||||
.ace-vito .ace_invalid {color: #F8F8F8;background-color: #800F00}
|
||||
.ace-vito .ace_support {color: #80FFBB}
|
||||
.ace-vito .ace_support.ace_constant {color: #EB939A}
|
||||
.ace-vito .ace_fold {background-color: #FF9D00;border-color: #F9FAFB}
|
||||
.ace-vito .ace_support.ace_function {color: #FFB054}
|
||||
.ace-vito .ace_storage {color: #FFEE80}
|
||||
.ace-vito .ace_entity {color: #FFDD00}
|
||||
.ace-vito .ace_string {color: #7cd827}
|
||||
.ace-vito .ace_string.ace_regexp {color: #80FFC2}
|
||||
.ace-vito .ace_comment {font-style: italic;color: #6B7280}
|
||||
.ace-vito .ace_heading,.ace-vito
|
||||
.ace_markup.ace_heading {color: #C8E4FD;background-color: #001221}
|
||||
.ace-vito .ace_list,.ace-vito .ace_markup.ace_list {background-color: #130D26}
|
||||
.ace-vito .ace_variable {color: #CCCCCC}
|
||||
.ace-vito .ace_variable.ace_language {color: #FF80E1}
|
||||
.ace-vito .ace_meta.ace_tag {color: #9EFFFF}
|
||||
.ace-vito .ace_indent-guide {background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHCLSvkPAAP3AgSDTRd4AAAAAElFTkSuQmCC) right repeat-y}
|
||||
`);
|
||||
|
||||
var dom = require("../lib/dom");
|
||||
dom.importCssString(exports.cssText, exports.cssClass);
|
||||
},
|
||||
);
|
@ -1,28 +0,0 @@
|
||||
import CodeEditorAlpinePlugin from "./components/editor";
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
window.Alpine.plugin(CodeEditorAlpinePlugin);
|
||||
});
|
||||
|
||||
window.copyToClipboard = async function (text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
|
||||
textArea.style.position = "absolute";
|
||||
textArea.style.left = "-999999px";
|
||||
|
||||
document.body.prepend(textArea);
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch (error) {
|
||||
//
|
||||
} finally {
|
||||
textArea.remove();
|
||||
}
|
||||
}
|
||||
};
|
24
resources/js/app.tsx
Normal file
24
resources/js/app.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import '../css/app.css';
|
||||
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { initializeTheme } from './hooks/use-appearance';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Vito';
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => `${title} - ${appName}`,
|
||||
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el);
|
||||
|
||||
root.render(<App {...props} />);
|
||||
},
|
||||
progress: {
|
||||
color: '#4B5563',
|
||||
},
|
||||
});
|
||||
|
||||
// This will set light / dark mode on load...
|
||||
initializeTheme();
|
18
resources/js/components/app-content.tsx
Normal file
18
resources/js/components/app-content.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { SidebarInset } from '@/components/ui/sidebar';
|
||||
import * as React from 'react';
|
||||
|
||||
interface AppContentProps extends React.ComponentProps<'main'> {
|
||||
variant?: 'header' | 'sidebar';
|
||||
}
|
||||
|
||||
export function AppContent({ variant = 'header', children, ...props }: AppContentProps) {
|
||||
if (variant === 'sidebar') {
|
||||
return <SidebarInset {...props}>{children}</SidebarInset>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl px-4" {...props}>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
204
resources/js/components/app-header.tsx
Normal file
204
resources/js/components/app-header.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { Icon } from '@/components/icon';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { UserMenuContent } from '@/components/user-menu-content';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type NavItem, type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { BookOpen, CogIcon, Folder, Menu, Search, ServerIcon, SlashIcon } from 'lucide-react';
|
||||
import AppLogo from './app-logo';
|
||||
import AppLogoIcon from './app-logo-icon';
|
||||
import { ProjectSwitch } from '@/components/project-switch';
|
||||
import { ServerSwitch } from '@/components/server-switch';
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbSeparator } from '@/components/ui/breadcrumb';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Servers',
|
||||
href: '/servers',
|
||||
activePath: '/servers',
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
href: '/settings/profile',
|
||||
activePath: '/settings',
|
||||
icon: CogIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const rightNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/vitodeploy/vito',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://vitodeploy.com',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItemStyles = '';
|
||||
|
||||
export function AppHeader() {
|
||||
const page = usePage<SharedData>();
|
||||
const { auth } = page.props;
|
||||
const getInitials = useInitials();
|
||||
return (
|
||||
<>
|
||||
<div className="border-sidebar-border/80 border-b">
|
||||
<div className="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
|
||||
{/* Mobile Menu */}
|
||||
<div className="lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="mr-2 h-[34px] w-[34px]">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="bg-sidebar flex h-full w-64 flex-col items-stretch justify-between">
|
||||
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
|
||||
<SheetHeader className="flex justify-start text-left">
|
||||
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
|
||||
</SheetHeader>
|
||||
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
|
||||
<div className="flex h-full flex-col justify-between text-sm">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{mainNavItems.map((item) => (
|
||||
<Link key={item.title} href={item.href} className="flex items-center space-x-2 font-medium">
|
||||
{item.icon && <Icon iconNode={item.icon} className="h-5 w-5" />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
{rightNavItems.map((item) => (
|
||||
<a
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 font-medium"
|
||||
>
|
||||
{item.icon && <Icon iconNode={item.icon} className="h-5 w-5" />}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Link href={route('home')} prefetch className="flex items-center space-x-2">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="ml-6 flex h-full items-center space-x-6">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<ProjectSwitch />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>
|
||||
<SlashIcon />
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<ServerSwitch />
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
<div className="relative flex items-center space-x-1">
|
||||
<Button variant="ghost" size="icon" className="group h-9 w-9 cursor-pointer">
|
||||
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
|
||||
</Button>
|
||||
<div className="hidden lg:flex">
|
||||
{rightNavItems.map((item) => (
|
||||
<TooltipProvider key={item.title} delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group text-accent-foreground ring-offset-background hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring ml-1 inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<span className="sr-only">{item.title}</span>
|
||||
{item.icon && <Icon iconNode={item.icon} className="size-5 opacity-80 group-hover:opacity-100" />}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="size-10 rounded-full p-1">
|
||||
<Avatar className="size-8 overflow-hidden rounded-full">
|
||||
<AvatarImage src={auth.user.avatar} alt={auth.user.name} />
|
||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{getInitials(auth.user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
<UserMenuContent user={auth.user} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{/* Desktop Navigation */}
|
||||
<div className="mx-auto hidden h-12 items-center px-2 md:max-w-7xl lg:flex">
|
||||
<NavigationMenu className="flex h-full items-stretch">
|
||||
<NavigationMenuList className="flex h-full items-stretch">
|
||||
{mainNavItems.map((item, index) => (
|
||||
<NavigationMenuItem key={index} className="relative flex h-full items-center">
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
navigationMenuTriggerStyle(),
|
||||
item.activePath && page.url.startsWith(item.activePath) && activeItemStyles,
|
||||
'h-9 cursor-pointer px-3',
|
||||
)}
|
||||
>
|
||||
{item.icon && <Icon iconNode={item.icon} className="mr-2 h-4 w-4" />}
|
||||
{item.title}
|
||||
</Link>
|
||||
{item.activePath && page.url.startsWith(item.activePath) && (
|
||||
<div className="absolute right-3 bottom-0 left-3 h-0.5 translate-y-px bg-black dark:bg-white"></div>
|
||||
)}
|
||||
</NavigationMenuItem>
|
||||
))}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
</div>
|
||||
{/*{breadcrumbs.length > 1 && (*/}
|
||||
{/* <div className="border-sidebar-border/70 flex w-full border-b">*/}
|
||||
{/* <div*/}
|
||||
{/* className="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl">*/}
|
||||
{/* <Breadcrumbs breadcrumbs={breadcrumbs} />*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
</>
|
||||
);
|
||||
}
|
32
resources/js/components/app-logo-icon.tsx
Normal file
32
resources/js/components/app-logo-icon.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
|
||||
export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" rx="50" fill="#312E81" />
|
||||
<rect width="1024" height="1024" rx="50" fill="url(#paint0_linear_29_4)" />
|
||||
<g filter="url(#filter0_d_29_4)">
|
||||
<path
|
||||
d="M512.206 814.3C500.806 814.3 491.506 811.6 484.306 806.2C477.106 800.8 471.406 792.7 467.206 781.9L219.706 215.8C215.506 205.6 214.306 197.2 216.106 190.6C217.906 183.4 221.806 177.7 227.806 173.5C234.406 169.3 241.906 167.2 250.306 167.2C261.706 167.2 270.106 169.9 275.506 175.3C281.506 180.1 286.306 187.3 289.906 196.9L527.506 748.6H498.706L735.406 196C739.606 187 744.706 180.1 750.706 175.3C756.706 169.9 765.106 167.2 775.906 167.2C784.306 167.2 791.206 169.3 796.606 173.5C802.606 177.7 806.206 183.4 807.406 190.6C809.206 197.8 808.006 206.2 803.806 215.8L556.306 781.9C552.106 792.7 546.406 800.8 539.206 806.2C532.606 811.6 523.606 814.3 512.206 814.3Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_29_4" x="209.406" y="167.2" width="604.7" height="655.1" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_29_4" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_29_4" result="shape" />
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_29_4" x1="512" y1="0" x2="512" y2="1024" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#6366F1" />
|
||||
<stop offset="1" stopColor="#393B8B" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
11
resources/js/components/app-logo.tsx
Normal file
11
resources/js/components/app-logo.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import AppLogoIcon from './app-logo-icon';
|
||||
|
||||
export default function AppLogo() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-md">
|
||||
<AppLogoIcon className="size-7 rounded-sm fill-current text-white dark:text-black" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
18
resources/js/components/app-shell.tsx
Normal file
18
resources/js/components/app-shell.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { SharedData } from '@/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'header' | 'sidebar';
|
||||
}
|
||||
|
||||
export function AppShell({ children, variant = 'header' }: AppShellProps) {
|
||||
const isOpen = usePage<SharedData>().props.sidebarOpen;
|
||||
|
||||
if (variant === 'header') {
|
||||
return <div className="flex min-h-screen w-full flex-col">{children}</div>;
|
||||
}
|
||||
|
||||
return <SidebarProvider defaultOpen={isOpen}>{children}</SidebarProvider>;
|
||||
}
|
14
resources/js/components/app-sidebar-header.tsx
Normal file
14
resources/js/components/app-sidebar-header.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: BreadcrumbItemType[] }) {
|
||||
return (
|
||||
<header className="border-sidebar-border/50 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
56
resources/js/components/app-sidebar.tsx
Normal file
56
resources/js/components/app-sidebar.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { NavFooter } from '@/components/nav-footer';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, ServerIcon } from 'lucide-react';
|
||||
import AppLogo from './app-logo';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Servers',
|
||||
href: route('servers'),
|
||||
icon: ServerIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/vitodeploy/vito',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://vitodeploy.com',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="sidebar">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href={route('servers')} prefetch>
|
||||
<AppLogo />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain items={mainNavItems} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter items={footerNavItems} className="mt-auto" />
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
53
resources/js/components/appearance-dropdown.tsx
Normal file
53
resources/js/components/appearance-dropdown.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
|
||||
const getCurrentIcon = () => {
|
||||
switch (appearance) {
|
||||
case 'dark':
|
||||
return <Moon className="h-5 w-5" />;
|
||||
case 'light':
|
||||
return <Sun className="h-5 w-5" />;
|
||||
default:
|
||||
return <Monitor className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
|
||||
{getCurrentIcon()}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => updateAppearance('light')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Sun className="h-5 w-5" />
|
||||
Light
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateAppearance('dark')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Moon className="h-5 w-5" />
|
||||
Dark
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateAppearance('system')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
System
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
33
resources/js/components/appearance-tabs.tsx
Normal file
33
resources/js/components/appearance-tabs.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Appearance, useAppearance } from '@/hooks/use-appearance';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
export default function AppearanceToggleTab({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
|
||||
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
||||
{ value: 'light', icon: Sun, label: 'Light' },
|
||||
{ value: 'dark', icon: Moon, label: 'Dark' },
|
||||
{ value: 'system', icon: Monitor, label: 'System' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full items-center justify-between gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800', className)} {...props}>
|
||||
{tabs.map(({ value, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => updateAppearance(value)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center rounded-md px-3.5 py-1.5 transition-colors',
|
||||
appearance === value
|
||||
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
|
||||
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
||||
)}
|
||||
>
|
||||
<Icon className="-ml-1 h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
34
resources/js/components/breadcrumbs.tsx
Normal file
34
resources/js/components/breadcrumbs.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export function Breadcrumbs({ breadcrumbs }: { breadcrumbs: BreadcrumbItemType[] }) {
|
||||
return (
|
||||
<>
|
||||
{breadcrumbs.length > 0 && (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((item, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{item.title}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={item.href}>{item.title}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
5
resources/js/components/container.tsx
Normal file
5
resources/js/components/container.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function Container({ children }: { children?: ReactNode }) {
|
||||
return <div className="container mx-auto space-y-5 py-10">{children}</div>;
|
||||
}
|
55
resources/js/components/data-table.tsx
Normal file
55
resources/js/components/data-table.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
89
resources/js/components/delete-user.tsx
Normal file
89
resources/js/components/delete-user.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { FormEventHandler, useRef } from 'react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
|
||||
export default function DeleteUser() {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const { data, setData, delete: destroy, processing, reset, errors, clearErrors } = useForm<Required<{ password: string }>>({ password: '' });
|
||||
|
||||
const deleteUser: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
destroy(route('profile.destroy'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => passwordInput.current?.focus(),
|
||||
onFinish: () => reset(),
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
clearErrors();
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall title="Delete account" description="Delete your account and all of its resources" />
|
||||
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
|
||||
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
|
||||
<p className="font-medium">Warning</p>
|
||||
<p className="text-sm">Please proceed with caution, this cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">Delete account</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>Are you sure you want to delete your account?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you
|
||||
would like to permanently delete your account.
|
||||
</DialogDescription>
|
||||
<form className="space-y-6" onSubmit={deleteUser}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
ref={passwordInput}
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary" onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button variant="destructive" disabled={processing} asChild>
|
||||
<button type="submit">Delete account</button>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import ace from "brace";
|
||||
import "brace/mode/ini";
|
||||
import "brace/ext/searchbox";
|
||||
import "../ace-editor/theme-vito";
|
||||
import "../ace-editor/mode-env";
|
||||
import "../ace-editor/mode-nginx";
|
||||
|
||||
export default (Alpine) => {
|
||||
Alpine.data("codeEditorFormComponent", ({ state, options }) => {
|
||||
return {
|
||||
state,
|
||||
options,
|
||||
init: function () {
|
||||
this.render();
|
||||
},
|
||||
render() {
|
||||
this.editor = null;
|
||||
|
||||
const editorValue = JSON.parse(this.options.value || "");
|
||||
this.editor = ace.edit(this.options.id);
|
||||
this.editor.$blockScrolling = Infinity;
|
||||
this.editor.setTheme("ace/theme/vito");
|
||||
this.editor.setValue(editorValue, -1);
|
||||
this.editor
|
||||
.getSession()
|
||||
.setMode(`ace/mode/${this.options.lang || "plain_text"}`);
|
||||
this.editor.clearSelection();
|
||||
this.editor.focus();
|
||||
this.editor.setOptions({
|
||||
printMargin: false,
|
||||
});
|
||||
|
||||
this.editor.renderer.setScrollMargin(15, 15, 0, 0);
|
||||
this.editor.renderer.setPadding(15);
|
||||
|
||||
this.editor.getSession().on("change", () => {
|
||||
this.state = this.editor.getValue();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
this.editor.resize();
|
||||
});
|
||||
|
||||
this.state = editorValue;
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
8
resources/js/components/heading-small.tsx
Normal file
8
resources/js/components/heading-small.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
export default function HeadingSmall({ title, description }: { title: string; description?: string }) {
|
||||
return (
|
||||
<header>
|
||||
<h3 className="mb-0.5 text-base font-medium">{title}</h3>
|
||||
{description && <p className="text-muted-foreground text-sm">{description}</p>}
|
||||
</header>
|
||||
);
|
||||
}
|
8
resources/js/components/heading.tsx
Normal file
8
resources/js/components/heading.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
export default function Heading({ title, description }: { title: string; description?: string }) {
|
||||
return (
|
||||
<div className="mb-8 space-y-0.5">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||
{description && <p className="text-muted-foreground text-sm">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
11
resources/js/components/icon.tsx
Normal file
11
resources/js/components/icon.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type LucideProps } from 'lucide-react';
|
||||
import { type ComponentType } from 'react';
|
||||
|
||||
interface IconProps extends Omit<LucideProps, 'ref'> {
|
||||
iconNode: ComponentType<LucideProps>;
|
||||
}
|
||||
|
||||
export function Icon({ iconNode: IconComponent, className, ...props }: IconProps) {
|
||||
return <IconComponent className={cn('h-4 w-4', className)} {...props} />;
|
||||
}
|
10
resources/js/components/input-error.tsx
Normal file
10
resources/js/components/input-error.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type HTMLAttributes } from 'react';
|
||||
|
||||
export default function InputError({ message, className = '', ...props }: HTMLAttributes<HTMLParagraphElement> & { message?: string }) {
|
||||
return message ? (
|
||||
<p {...props} className={cn('text-sm text-red-600 dark:text-red-400', className)}>
|
||||
{message}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
31
resources/js/components/nav-footer.tsx
Normal file
31
resources/js/components/nav-footer.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Icon } from '@/components/icon';
|
||||
import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
||||
import { type NavItem } from '@/types';
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export function NavFooter({
|
||||
items,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SidebarGroup> & {
|
||||
items: NavItem[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup {...props} className={`group-data-[collapsible=icon]:p-0 ${className || ''}`}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild className="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100">
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer">
|
||||
{item.icon && <Icon iconNode={item.icon} className="h-5 w-5" />}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
24
resources/js/components/nav-main.tsx
Normal file
24
resources/js/components/nav-main.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
|
||||
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
||||
const page = usePage();
|
||||
return (
|
||||
<SidebarGroup className="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={item.href === page.url} tooltip={{ children: item.title }}>
|
||||
<Link href={item.href} prefetch>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
36
resources/js/components/nav-user.tsx
Normal file
36
resources/js/components/nav-user.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar';
|
||||
import { UserInfo } from '@/components/user-info';
|
||||
import { UserMenuContent } from '@/components/user-menu-content';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { type SharedData } from '@/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
export function NavUser() {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
const { state } = useSidebar();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton size="lg" className="text-sidebar-accent-foreground data-[state=open]:bg-sidebar-accent group">
|
||||
<UserInfo user={auth.user} />
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="end"
|
||||
side={isMobile ? 'bottom' : state === 'collapsed' ? 'left' : 'bottom'}
|
||||
>
|
||||
<UserMenuContent user={auth.user} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
67
resources/js/components/project-switch.tsx
Normal file
67
resources/js/components/project-switch.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { type SharedData } from '@/types';
|
||||
import { Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
export function ProjectSwitch() {
|
||||
const page = usePage<SharedData>();
|
||||
const { auth } = page.props;
|
||||
const [selectedProject, setSelectedProject] = useState(auth.currentProject?.id?.toString() ?? '');
|
||||
const initials = useInitials();
|
||||
const form = useForm();
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
const selectedProject = auth.projects.find((project) => project.id.toString() === projectId);
|
||||
if (selectedProject) {
|
||||
setSelectedProject(selectedProject.id.toString());
|
||||
form.post(route('projects.switch', { project: projectId }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link href={route('servers')}>
|
||||
<Button variant="ghost" className="px-2">
|
||||
<Avatar className="size-7 rounded-md">
|
||||
<AvatarFallback className="rounded-md">{initials(auth.currentProject?.name ?? '')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:flex">{auth.currentProject?.name}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="px-1!">
|
||||
<ChevronsUpDownIcon size={5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
{auth.projects.map((project) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={project.id.toString()}
|
||||
checked={selectedProject === project.id.toString()}
|
||||
onCheckedChange={() => handleProjectChange(project.id.toString())}
|
||||
>
|
||||
{project.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-0">
|
||||
<PlusIcon size={5} />
|
||||
<span className="ml-2">Create new project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
80
resources/js/components/server-switch.tsx
Normal file
80
resources/js/components/server-switch.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { type Server } from '@/types/server';
|
||||
import type { SharedData } from '@/types';
|
||||
|
||||
export function ServerSwitch() {
|
||||
const page = usePage<SharedData>();
|
||||
const [selectedServer, setSelectedServer] = useState(page.props.server || null);
|
||||
const initials = useInitials();
|
||||
const form = useForm();
|
||||
|
||||
const handleServerChange = (server: Server) => {
|
||||
setSelectedServer(server);
|
||||
form.post(route('servers.switch', { server: server.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{selectedServer && (
|
||||
<Link href={route('servers.show', { server: selectedServer?.id || '' })}>
|
||||
<Button variant="ghost" className="px-2">
|
||||
<Avatar className="size-7 rounded-md">
|
||||
<AvatarFallback className="rounded-md">{initials(selectedServer?.name ?? '')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:flex">{selectedServer?.name}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!selectedServer && (
|
||||
<Button variant="ghost" className="cursor-default px-2">
|
||||
<Avatar className="size-7 rounded-md">
|
||||
<AvatarFallback className="rounded-md">S</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:flex">Select a server</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="px-1!">
|
||||
<ChevronsUpDownIcon size={5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
{page.props.projectServers.map((server) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={`server-${server.id.toString()}`}
|
||||
checked={selectedServer?.id === server.id}
|
||||
onCheckedChange={() => handleServerChange(server)}
|
||||
>
|
||||
{server.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-0">
|
||||
<Link href={route('servers', { action: 'create' })}>
|
||||
<div className="flex items-center">
|
||||
<PlusIcon size={5} />
|
||||
<span className="ml-2">Create new server</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
19
resources/js/components/text-link.tsx
Normal file
19
resources/js/components/text-link.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
type LinkProps = ComponentProps<typeof Link>;
|
||||
|
||||
export default function TextLink({ className = '', children, ...props }: LinkProps) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
'text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
39
resources/js/components/ui/alert.tsx
Normal file
39
resources/js/components/ui/alert.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive: 'text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="alert-title" className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)} {...props} />;
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
26
resources/js/components/ui/avatar.tsx
Normal file
26
resources/js/components/ui/avatar.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root data-slot="avatar" className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return <AvatarPrimitive.Image data-slot="avatar-image" className={cn('aspect-square size-full', className)} {...props} />;
|
||||
}
|
||||
|
||||
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn('bg-brand/20 flex size-full items-center justify-center rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
38
resources/js/components/ui/badge.tsx
Normal file
38
resources/js/components/ui/badge.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
success: 'border-badge-success text-badge-success-foreground [a&]:hover:bg-badge-success/90',
|
||||
info: 'border-badge-info text-badge-info-foreground [a&]:hover:bg-badge-info/90',
|
||||
warning: 'border-badge-warning text-badge-warning-foreground [a&]:hover:bg-badge-warning/90',
|
||||
danger: 'border-badge-danger text-badge-danger-foreground [a&]:hover:bg-badge-danger/90',
|
||||
gray: 'border-badge-gray text-badge-gray-foreground [a&]:hover:bg-badge-gray/90',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span';
|
||||
|
||||
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
73
resources/js/components/ui/breadcrumb.tsx
Normal file
73
resources/js/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn('text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return <li data-slot="breadcrumb-item" className={cn('inline-flex items-center gap-1.5', className)} {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return <Comp data-slot="breadcrumb-link" className={cn('hover:text-foreground transition-colors', 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('text-foreground font-normal', 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 ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis };
|
49
resources/js/components/ui/button.tsx
Normal file
49
resources/js/components/ui/button.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
|
||||
outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
31
resources/js/components/ui/card.tsx
Normal file
31
resources/js/components/ui/card.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div data-slot="card" className={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-header" className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-description" className={cn('text-muted-foreground text-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-footer" className={cn('flex items-center px-6', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
24
resources/js/components/ui/checkbox.tsx
Normal file
24
resources/js/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="flex items-center justify-center text-current transition-none">
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
15
resources/js/components/ui/collapsible.tsx
Normal file
15
resources/js/components/ui/collapsible.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
74
resources/js/components/ui/dialog.tsx
Normal file
74
resources/js/components/ui/dialog.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="dialog-footer" className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return <DialogPrimitive.Title data-slot="dialog-title" className={cn('text-lg leading-none font-semibold', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return <DialogPrimitive.Description data-slot="dialog-description" className={cn('text-muted-foreground text-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger };
|
189
resources/js/components/ui/dropdown-menu.tsx
Normal file
189
resources/js/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function 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(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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 left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</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, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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 left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return <DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn('bg-border -mx-1 my-1 h-px', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return <span data-slot="dropdown-menu-shortcut" className={cn('text-muted-foreground ml-auto text-xs tracking-widest', 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(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
26
resources/js/components/ui/form.tsx
Normal file
26
resources/js/components/ui/form.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Form({ className, children, ...props }: React.ComponentProps<'form'>) {
|
||||
return (
|
||||
<form {...props} className={cn('flex w-full flex-col gap-6', className)}>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormFields({ className, children, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div className={cn('grid gap-6', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormField({ className, children, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div className={cn('grid gap-2', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
14
resources/js/components/ui/icon.tsx
Normal file
14
resources/js/components/ui/icon.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface IconProps {
|
||||
iconNode?: LucideIcon | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Icon({ iconNode: IconComponent, className }: IconProps) {
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <IconComponent className={className} />;
|
||||
}
|
21
resources/js/components/ui/input.tsx
Normal file
21
resources/js/components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
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(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
19
resources/js/components/ui/label.tsx
Normal file
19
resources/js/components/ui/label.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
123
resources/js/components/ui/navigation-menu.tsx
Normal file
123
resources/js/components/ui/navigation-menu.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import * as React from 'react';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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-1', 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 inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-accent/50 data-[state=open]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1',
|
||||
);
|
||||
|
||||
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-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]: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(
|
||||
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out 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 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
|
||||
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 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-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
|
||||
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 bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_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(
|
||||
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
77
resources/js/components/ui/pagination.tsx
Normal file
77
resources/js/components/ui/pagination.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return <ul data-slot="pagination-content" className={cn('flex flex-row items-center gap-1', className)} {...props} />;
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
|
||||
return <li data-slot="pagination-item" {...props} />;
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
||||
React.ComponentProps<'a'>;
|
||||
|
||||
function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn('gap-1 px-2.5 sm:pl-2.5', className)} {...props}>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn('gap-1 px-2.5 sm:pr-2.5', className)} {...props}>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span aria-hidden data-slot="pagination-ellipsis" className={cn('flex size-9 items-center justify-center', className)} {...props}>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { Pagination, PaginationContent, PaginationLink, PaginationItem, PaginationPrevious, PaginationNext, PaginationEllipsis };
|
20
resources/js/components/ui/placeholder-pattern.tsx
Normal file
20
resources/js/components/ui/placeholder-pattern.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
interface PlaceholderPatternProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PlaceholderPattern({ className }: PlaceholderPatternProps) {
|
||||
const patternId = useId();
|
||||
|
||||
return (
|
||||
<svg className={className} fill="none">
|
||||
<defs>
|
||||
<pattern id={patternId} x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M-3 13 15-5M-5 5l18-18M-1 21 17 3"></path>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect stroke="none" fill={`url(#${patternId})`} width="100%" height="100%"></rect>
|
||||
</svg>
|
||||
);
|
||||
}
|
22
resources/js/components/ui/progress.tsx
Normal file
22
resources/js/components/ui/progress.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
39
resources/js/components/ui/scroll-area.tsx
Normal file
39
resources/js/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn('relative', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({ className, orientation = 'vertical', ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb data-slot="scroll-area-thumb" className="bg-border relative flex-1 rounded-full" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
131
resources/js/components/ui/select.tsx
Normal file
131
resources/js/components/ui/select.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({ className, children, position = 'popper', ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return <SelectPrimitive.Label data-slot="select-label" className={cn('px-2 py-1.5 text-sm font-medium', className)} {...props} />;
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none 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}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator data-slot="select-separator" className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
21
resources/js/components/ui/separator.tsx
Normal file
21
resources/js/components/ui/separator.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({ className, orientation = 'horizontal', decorative = true, ...props }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
85
resources/js/components/ui/sheet.tsx
Normal file
85
resources/js/components/ui/sheet.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' && 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l',
|
||||
side === 'left' && 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r',
|
||||
side === 'top' && 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' && 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="sheet-header" className={cn('flex flex-col gap-1.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-foreground font-semibold', className)} {...props} />;
|
||||
}
|
||||
|
||||
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return <SheetPrimitive.Description data-slot="sheet-description" className={cn('text-muted-foreground text-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
|
594
resources/js/components/ui/sidebar.tsx
Normal file
594
resources/js/components/ui/sidebar.tsx
Normal file
@ -0,0 +1,594 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { VariantProps, cva } from 'class-variance-authority';
|
||||
import { PanelLeftIcon } from 'lucide-react';
|
||||
|
||||
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, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
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 SidebarContext = {
|
||||
state: 'expanded' | 'collapsed';
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContext | 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<SidebarContext>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<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 has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = 'left',
|
||||
variant = 'sidebar',
|
||||
collapsible = 'offcanvas',
|
||||
className,
|
||||
children,
|
||||
...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('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden 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
|
||||
className={cn(
|
||||
'relative h-svh 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
|
||||
className={cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// 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"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{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"
|
||||
className={cn('h-7 w-7', 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(
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||
'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',
|
||||
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
'[[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(
|
||||
'bg-background relative flex min-h-svh max-w-full flex-1 flex-col',
|
||||
'peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] 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-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
|
||||
return <Input data-slot="sidebar-input" data-sidebar="input" className={cn('bg-background h-8 w-full 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('bg-sidebar-border mx-2 w-auto', className)} {...props} />;
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn('flex min-h-0 flex-1 flex-col gap-2 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 : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({ className, asChild = false, ...props }: React.ComponentProps<'button'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
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 flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] 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 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
{
|
||||
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 : 'button';
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
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 : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
showOnHover &&
|
||||
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]: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(
|
||||
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
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(
|
||||
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l 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 : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
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,
|
||||
};
|
7
resources/js/components/ui/skeleton.tsx
Normal file
7
resources/js/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="skeleton" className={cn('bg-primary/10 animate-pulse rounded-md', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
23
resources/js/components/ui/sonner.tsx
Normal file
23
resources/js/components/ui/sonner.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner, ToasterProps } from 'sonner';
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
58
resources/js/components/ui/table.tsx
Normal file
58
resources/js/components/ui/table.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
<table data-slot="table" className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return <tbody data-slot="table-body" className={cn('[&_tr:last-child]:border-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
return <tfoot data-slot="table-footer" className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr data-slot="table-row" className={cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'text-foreground h-10 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn('p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
|
||||
return <caption data-slot="table-caption" className={cn('text-muted-foreground mt-4 text-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
62
resources/js/components/ui/toggle-group.tsx
Normal file
62
resources/js/components/ui/toggle-group.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toggleVariants } from '@/components/ui/toggle';
|
||||
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn('group/toggle-group flex items-center rounded-md data-[variant=outline]:shadow-xs', className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
'min-w-0 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
32
resources/js/components/ui/toggle.tsx
Normal file
32
resources/js/components/ui/toggle.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-2 min-w-9',
|
||||
sm: 'h-8 px-1.5 min-w-8',
|
||||
lg: 'h-10 px-2.5 min-w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({ className, variant, size, ...props }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return <TogglePrimitive.Root data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
41
resources/js/components/ui/tooltip.tsx
Normal file
41
resources/js/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
20
resources/js/components/user-info.tsx
Normal file
20
resources/js/components/user-info.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { type User } from '@/types/user';
|
||||
|
||||
export function UserInfo({ user, showEmail = false }: { user: User; showEmail?: boolean }) {
|
||||
const getInitials = useInitials();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Avatar className="h-8 w-8 overflow-hidden rounded-full">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">{getInitials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
{showEmail && <span className="text-muted-foreground truncate text-xs">{user.email}</span>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
48
resources/js/components/user-menu-content.tsx
Normal file
48
resources/js/components/user-menu-content.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
||||
import { UserInfo } from '@/components/user-info';
|
||||
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
||||
import { type User } from '@/types/user';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { LogOut, Settings } from 'lucide-react';
|
||||
import AppearanceToggleTab from '@/components/appearance-tabs';
|
||||
|
||||
interface UserMenuContentProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function UserMenuContent({ user }: UserMenuContentProps) {
|
||||
const cleanup = useMobileNavigation();
|
||||
|
||||
const handleLogout = () => {
|
||||
cleanup();
|
||||
router.flushAll();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<UserInfo user={user} showEmail={true} />
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<AppearanceToggleTab />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="block w-full" href={route('profile.edit')} as="button" prefetch onClick={cleanup}>
|
||||
<Settings className="mr-2" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="block w-full" method="post" href={route('logout')} as="button" onClick={handleLogout}>
|
||||
<LogOut className="mr-2" />
|
||||
Log out
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
73
resources/js/hooks/use-appearance.tsx
Normal file
73
resources/js/hooks/use-appearance.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export type Appearance = 'light' | 'dark' | 'system';
|
||||
|
||||
const prefersDark = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
};
|
||||
|
||||
const applyTheme = (appearance: Appearance) => {
|
||||
const isDark = appearance === 'dark' || (appearance === 'system' && prefersDark());
|
||||
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
};
|
||||
|
||||
const mediaQuery = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)');
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
const currentAppearance = localStorage.getItem('appearance') as Appearance;
|
||||
applyTheme(currentAppearance || 'system');
|
||||
};
|
||||
|
||||
export function initializeTheme() {
|
||||
const savedAppearance = (localStorage.getItem('appearance') as Appearance) || 'system';
|
||||
|
||||
applyTheme(savedAppearance);
|
||||
|
||||
// Add the event listener for system theme changes...
|
||||
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
|
||||
}
|
||||
|
||||
export function useAppearance() {
|
||||
const [appearance, setAppearance] = useState<Appearance>('system');
|
||||
|
||||
const updateAppearance = useCallback((mode: Appearance) => {
|
||||
setAppearance(mode);
|
||||
|
||||
// Store in localStorage for client-side persistence...
|
||||
localStorage.setItem('appearance', mode);
|
||||
|
||||
// Store in cookie for SSR...
|
||||
setCookie('appearance', mode);
|
||||
|
||||
applyTheme(mode);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedAppearance = localStorage.getItem('appearance') as Appearance | null;
|
||||
updateAppearance(savedAppearance || 'system');
|
||||
|
||||
return () => mediaQuery()?.removeEventListener('change', handleSystemThemeChange);
|
||||
}, [updateAppearance]);
|
||||
|
||||
return { appearance, updateAppearance } as const;
|
||||
}
|
15
resources/js/hooks/use-initials.tsx
Normal file
15
resources/js/hooks/use-initials.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useInitials() {
|
||||
return useCallback((fullName: string): string => {
|
||||
const names = fullName.trim().split(' ');
|
||||
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return names[0].charAt(0).toUpperCase();
|
||||
|
||||
const firstInitial = names[0].charAt(0);
|
||||
const lastInitial = names[names.length - 1].charAt(0);
|
||||
|
||||
return `${firstInitial}${lastInitial}`.toUpperCase();
|
||||
}, []);
|
||||
}
|
8
resources/js/hooks/use-mobile-navigation.ts
Normal file
8
resources/js/hooks/use-mobile-navigation.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useMobileNavigation() {
|
||||
return useCallback(() => {
|
||||
// Remove pointer-events style from body...
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
}, []);
|
||||
}
|
22
resources/js/hooks/use-mobile.tsx
Normal file
22
resources/js/hooks/use-mobile.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState<boolean>();
|
||||
|
||||
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;
|
||||
}
|
10
resources/js/layouts/app-layout.tsx
Normal file
10
resources/js/layouts/app-layout.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import AppLayoutTemplate from '@/layouts/app/app-header-layout';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export default ({ children, ...props }: AppLayoutProps) => <AppLayoutTemplate {...props}>{children}</AppLayoutTemplate>;
|
19
resources/js/layouts/app/app-header-layout.tsx
Normal file
19
resources/js/layouts/app/app-header-layout.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { AppContent } from '@/components/app-content';
|
||||
import { AppHeader } from '@/components/app-header';
|
||||
import { AppShell } from '@/components/app-shell';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { usePoll } from '@inertiajs/react';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
export default function AppHeaderLayout({ children }: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) {
|
||||
usePoll(10000);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<AppHeader />
|
||||
<AppContent>{children}</AppContent>
|
||||
<Toaster />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
18
resources/js/layouts/app/app-sidebar-layout.tsx
Normal file
18
resources/js/layouts/app/app-sidebar-layout.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { AppContent } from '@/components/app-content';
|
||||
import { AppShell } from '@/components/app-shell';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { AppSidebarHeader } from '@/components/app-sidebar-header';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function AppSidebarLayout({ children, breadcrumbs = [] }: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) {
|
||||
return (
|
||||
<AppShell variant="sidebar">
|
||||
<AppSidebar />
|
||||
<AppContent variant="sidebar">
|
||||
<AppSidebarHeader breadcrumbs={breadcrumbs} />
|
||||
{children}
|
||||
</AppContent>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
11
resources/js/layouts/auth-layout.tsx
Normal file
11
resources/js/layouts/auth-layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
export default function AuthLayout({ children, title, description, ...props }: { children: React.ReactNode; title: string; description: string }) {
|
||||
return (
|
||||
<AuthLayoutTemplate title={title} description={description} {...props}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</AuthLayoutTemplate>
|
||||
);
|
||||
}
|
36
resources/js/layouts/auth/auth-card-layout.tsx
Normal file
36
resources/js/layouts/auth/auth-card-layout.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function AuthCardLayout({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
}: PropsWithChildren<{
|
||||
name?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Link href={route('home')} className="flex items-center gap-2 self-center font-medium">
|
||||
<div className="flex h-9 w-9 items-center justify-center">
|
||||
<AppLogoIcon className="size-9 fill-current text-black dark:text-white" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card className="rounded-xl">
|
||||
<CardHeader className="px-10 pt-8 pb-0 text-center">
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-10 py-8">{children}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
34
resources/js/layouts/auth/auth-simple-layout.tsx
Normal file
34
resources/js/layouts/auth/auth-simple-layout.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
name?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function AuthSimpleLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Link href={route('home')} className="flex flex-col items-center gap-2 font-medium">
|
||||
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md">
|
||||
<AppLogoIcon className="size-9 fill-current text-[var(--foreground)] dark:text-white" />
|
||||
</div>
|
||||
<span className="sr-only">{title}</span>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
<p className="text-muted-foreground text-center text-sm">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
45
resources/js/layouts/auth/auth-split-layout.tsx
Normal file
45
resources/js/layouts/auth/auth-split-layout.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function AuthSplitLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) {
|
||||
const { name, quote } = usePage<SharedData>().props;
|
||||
|
||||
return (
|
||||
<div className="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div className="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-r">
|
||||
<div className="absolute inset-0 bg-zinc-900" />
|
||||
<Link href={route('home')} className="relative z-20 flex items-center text-lg font-medium">
|
||||
<AppLogoIcon className="mr-2 size-8 fill-current text-white" />
|
||||
{name}
|
||||
</Link>
|
||||
{quote && (
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg">“{quote.message}”</p>
|
||||
<footer className="text-sm text-neutral-300">{quote.author}</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full lg:p-8">
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<Link href={route('home')} className="relative z-20 flex items-center justify-center lg:hidden">
|
||||
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
|
||||
</Link>
|
||||
<div className="flex flex-col items-start gap-2 text-left sm:items-center sm:text-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
68
resources/js/layouts/settings/layout.tsx
Normal file
68
resources/js/layouts/settings/layout.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import Container from '@/components/container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { ListIcon, LockIcon, UserIcon } from 'lucide-react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
const sidebarNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Profile',
|
||||
href: '/settings/profile',
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
title: 'Projects',
|
||||
href: '/',
|
||||
icon: ListIcon,
|
||||
},
|
||||
{
|
||||
title: 'Password',
|
||||
href: '/settings/password',
|
||||
icon: LockIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||
// When server-side rendering, we only render the layout on the client...
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex flex-col space-y-8 lg:flex-row lg:space-y-0 lg:space-x-12">
|
||||
<aside className="w-full max-w-xl lg:w-60">
|
||||
<nav className="flex flex-col space-y-1 space-x-0">
|
||||
{sidebarNavItems.map((item, index) => (
|
||||
<Button
|
||||
key={`${item.href}-${index}`}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
asChild
|
||||
className={cn('w-full justify-start', {
|
||||
'bg-muted': currentPath === item.href,
|
||||
})}
|
||||
>
|
||||
<Link href={item.href} prefetch>
|
||||
{item.icon && <item.icon />}
|
||||
{item.title}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Separator className="my-6 md:hidden" />
|
||||
|
||||
<div className="flex-1">
|
||||
<section className="space-y-12">{children}</section>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
6
resources/js/lib/utils.ts
Normal file
6
resources/js/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
57
resources/js/pages/auth/confirm-password.tsx
Normal file
57
resources/js/pages/auth/confirm-password.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
// Components
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function ConfirmPassword() {
|
||||
const { data, setData, post, processing, errors, reset } = useForm<Required<{ password: string }>>({
|
||||
password: '',
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
post(route('password.confirm'), {
|
||||
onFinish: () => reset('password'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Confirm your password" description="This is a secure area of the application. Please confirm your password before continuing.">
|
||||
<Head title="Confirm password" />
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
value={data.password}
|
||||
autoFocus
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button className="w-full" disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Confirm password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
63
resources/js/pages/auth/forgot-password.tsx
Normal file
63
resources/js/pages/auth/forgot-password.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
// Components
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
const { data, setData, post, processing, errors } = useForm<Required<{ email: string }>>({
|
||||
email: '',
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
post(route('password.email'));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Forgot password" description="Enter your email to receive a password reset link">
|
||||
<Head title="Forgot password" />
|
||||
|
||||
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
|
||||
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={submit}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="off"
|
||||
value={data.email}
|
||||
autoFocus
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="my-6 flex items-center justify-start">
|
||||
<Button className="w-full" disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Email password reset link
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="text-muted-foreground space-x-1 text-center text-sm">
|
||||
<span>Or, return to</span>
|
||||
<TextLink href={route('login')}>log in</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
97
resources/js/pages/auth/login.tsx
Normal file
97
resources/js/pages/auth/login.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
type LoginForm = {
|
||||
email: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
};
|
||||
|
||||
interface LoginProps {
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
}
|
||||
|
||||
export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
const { data, setData, post, processing, errors, reset } = useForm<Required<LoginForm>>({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
post(route('login'), {
|
||||
onFinish: () => reset('password'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Log in to your account" description="Enter your email and password below to log in">
|
||||
<Head title="Log in" />
|
||||
|
||||
<form className="flex flex-col gap-6" onSubmit={submit}>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
{canResetPassword && (
|
||||
<TextLink href={route('password.request')} className="ml-auto text-sm" tabIndex={5}>
|
||||
Forgot password?
|
||||
</TextLink>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="current-password"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox id="remember" name="remember" checked={data.remember} onClick={() => setData('remember', !data.remember)} tabIndex={3} />
|
||||
<Label htmlFor="remember">Remember me</Label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-4 w-full" tabIndex={4} disabled={processing}>
|
||||
{processing && <LoaderCircle className="animate-spin" />}
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
98
resources/js/pages/auth/reset-password.tsx
Normal file
98
resources/js/pages/auth/reset-password.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
interface ResetPasswordProps {
|
||||
token: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
type ResetPasswordForm = {
|
||||
token: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
};
|
||||
|
||||
export default function ResetPassword({ token, email }: ResetPasswordProps) {
|
||||
const { data, setData, post, processing, errors, reset } = useForm<Required<ResetPasswordForm>>({
|
||||
token: token,
|
||||
email: email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
post(route('password.store'), {
|
||||
onFinish: () => reset('password', 'password_confirmation'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Reset password" description="Please enter your new password below">
|
||||
<Head title="Reset password" />
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={data.email}
|
||||
className="mt-1 block w-full"
|
||||
readOnly
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.email} className="mt-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
value={data.password}
|
||||
className="mt-1 block w-full"
|
||||
autoFocus
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">Confirm password</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
name="password_confirmation"
|
||||
autoComplete="new-password"
|
||||
value={data.password_confirmation}
|
||||
className="mt-1 block w-full"
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
<InputError message={errors.password_confirmation} className="mt-2" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-4 w-full" disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Reset password
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
75
resources/js/pages/server-logs/columns.tsx
Normal file
75
resources/js/pages/server-logs/columns.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { ColumnDef, Row } from '@tanstack/react-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EyeIcon, LoaderCircleIcon } from 'lucide-react';
|
||||
import type { ServerLog } from '@/types/server-log';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
|
||||
const LogActionCell = ({ row }: { row: Row<ServerLog> }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const showLog = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(route('logs.show', { server: row.original.server_id, log: row.original.id }));
|
||||
setContent(response.data);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
setContent(error.message);
|
||||
} else {
|
||||
setContent('An unknown error occurred.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={showLog} disabled={loading}>
|
||||
{loading ? <LoaderCircleIcon className="animate-spin" /> : <EyeIcon />}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>View Log</DialogTitle>
|
||||
<DialogDescription>This is all content of the log</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="border-border relative h-[500px] w-full rounded-md border bg-black p-3 font-mono text-sm whitespace-pre-line text-gray-50">
|
||||
{content}
|
||||
<ScrollBar orientation="vertical" />
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<ServerLog>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Event',
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at_by_timezone',
|
||||
header: 'Created At',
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <LogActionCell row={row} />,
|
||||
},
|
||||
];
|
159
resources/js/pages/server-providers/create-server-provider.tsx
Normal file
159
resources/js/pages/server-providers/create-server-provider.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { LoaderCircle, PlusIcon, WifiIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useForm, usePage } from '@inertiajs/react';
|
||||
import { FormEventHandler, useEffect, useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Form, FormField, FormFields } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SharedData } from '@/types';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
type ServerProviderForm = {
|
||||
provider: string;
|
||||
name: string;
|
||||
global: boolean;
|
||||
};
|
||||
|
||||
export default function CreateServerProvider({
|
||||
trigger,
|
||||
providers,
|
||||
defaultProvider,
|
||||
onProviderAdded,
|
||||
}: {
|
||||
trigger: 'icon' | 'button';
|
||||
providers: string[];
|
||||
defaultProvider?: string;
|
||||
onProviderAdded?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const page = usePage<SharedData>();
|
||||
|
||||
const form = useForm<Required<ServerProviderForm>>({
|
||||
provider: 'aws',
|
||||
name: '',
|
||||
global: false,
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
form.post(route('server-providers.store'), {
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
if (onProviderAdded) {
|
||||
onProviderAdded();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.setData('provider', defaultProvider ?? 'aws');
|
||||
}, [defaultProvider]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline">
|
||||
{trigger === 'icon' && <WifiIcon />}
|
||||
{trigger === 'button' && (
|
||||
<>
|
||||
<PlusIcon />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect</DialogTitle>
|
||||
<DialogDescription>Connect to a new server provider</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form id="create-server-provider-form" onSubmit={submit} className="py-4">
|
||||
<FormFields>
|
||||
<FormField>
|
||||
<Label htmlFor="provider">Provider</Label>
|
||||
<Select
|
||||
value={form.data.provider}
|
||||
onValueChange={(value) => {
|
||||
form.setData('provider', value);
|
||||
form.clearErrors();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="provider">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={form.errors.provider} />
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Name"
|
||||
value={form.data.name}
|
||||
onChange={(e) => form.setData('name', e.target.value)}
|
||||
/>
|
||||
<InputError message={form.errors.name} />
|
||||
</FormField>
|
||||
{page.props.configs.server_providers_custom_fields[form.data.provider]?.map((item: string) => (
|
||||
<FormField key={item}>
|
||||
<Label htmlFor={item}>{item}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name={item}
|
||||
id={item}
|
||||
placeholder={item}
|
||||
value={(form.data[item as keyof ServerProviderForm] as string) ?? ''}
|
||||
onChange={(e) => form.setData(item as keyof ServerProviderForm, e.target.value)}
|
||||
/>
|
||||
<InputError message={form.errors[item as keyof ServerProviderForm]} />
|
||||
</FormField>
|
||||
))}
|
||||
<FormField>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox id="global" name="global" checked={form.data.global} onClick={() => form.setData('global', !form.data.global)} />
|
||||
<Label htmlFor="global">Is global (accessible in all projects)</Label>
|
||||
</div>
|
||||
<InputError message={form.errors.global} />
|
||||
</FormField>
|
||||
</FormFields>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" onClick={submit} disabled={form.processing}>
|
||||
{form.processing && <LoaderCircle className="animate-spin" />}
|
||||
Connect
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
55
resources/js/pages/servers/columns.tsx
Normal file
55
resources/js/pages/servers/columns.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Server } from '@/types/server';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EyeIcon } from 'lucide-react';
|
||||
|
||||
export const columns: ColumnDef<Server>[] = [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
header: 'IP',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={route('servers.show', { server: row.original.id })}>
|
||||
<Button variant="outline" size="sm">
|
||||
<EyeIcon />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
348
resources/js/pages/servers/create-server.tsx
Normal file
348
resources/js/pages/servers/create-server.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
import { ClipboardCheckIcon, ClipboardIcon, LoaderCircle, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { useForm, usePage } from '@inertiajs/react';
|
||||
import React, { FormEventHandler, useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ServerProvider } from '@/types/server-provider';
|
||||
import CreateServerProvider from '@/pages/server-providers/create-server-provider';
|
||||
import axios from 'axios';
|
||||
import { Form, FormField, FormFields } from '@/components/ui/form';
|
||||
import type { SharedData } from '@/types';
|
||||
|
||||
type CreateServerForm = {
|
||||
provider: string;
|
||||
server_provider: number;
|
||||
name: string;
|
||||
os: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
region: string;
|
||||
plan: string;
|
||||
webserver: string;
|
||||
database: string;
|
||||
php: string;
|
||||
};
|
||||
|
||||
export default function CreateServer({ children }: { children: React.ReactNode }) {
|
||||
const page = usePage<SharedData>();
|
||||
|
||||
const form = useForm<Required<CreateServerForm>>({
|
||||
provider: 'custom',
|
||||
server_provider: 0,
|
||||
name: '',
|
||||
os: '',
|
||||
ip: '',
|
||||
port: 22,
|
||||
region: '',
|
||||
plan: '',
|
||||
webserver: '',
|
||||
database: '',
|
||||
php: '',
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
form.post(route('servers'));
|
||||
};
|
||||
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(page.props.publicKeyText).then(() => {
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => {
|
||||
setCopySuccess(false);
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const [serverProviders, setServerProviders] = useState<ServerProvider[]>([]);
|
||||
const fetchServerProviders = async () => {
|
||||
const serverProviders = await axios.get(route('server-providers.all'));
|
||||
setServerProviders(serverProviders.data);
|
||||
};
|
||||
const selectProvider = (provider: string) => {
|
||||
form.setData('provider', provider);
|
||||
form.clearErrors();
|
||||
if (provider !== 'custom') {
|
||||
form.setData('server_provider', 0);
|
||||
form.setData('region', '');
|
||||
form.setData('plan', '');
|
||||
fetchServerProviders();
|
||||
}
|
||||
};
|
||||
|
||||
const selectServerProvider = async (serverProvider: string) => {
|
||||
form.setData('server_provider', parseInt(serverProvider));
|
||||
await fetchRegions(parseInt(serverProvider));
|
||||
};
|
||||
|
||||
const [regions, setRegions] = useState<{ [key: string]: string }>({});
|
||||
const fetchRegions = async (serverProvider: number) => {
|
||||
const regions = await axios.get(route('server-providers.regions', { serverProvider: serverProvider }));
|
||||
setRegions(regions.data);
|
||||
};
|
||||
const selectRegion = async (region: string) => {
|
||||
form.setData('region', region);
|
||||
if (region !== '') {
|
||||
await fetchPlans(form.data.server_provider, region);
|
||||
}
|
||||
};
|
||||
|
||||
const [plans, setPlans] = useState<{ [key: string]: string }>({});
|
||||
const fetchPlans = async (serverProvider: number, region: string) => {
|
||||
const plans = await axios.get(route('server-providers.plans', { serverProvider: serverProvider, region: region }));
|
||||
setPlans(plans.data);
|
||||
};
|
||||
const selectPlan = (plan: string) => {
|
||||
form.setData('plan', plan);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>{children}</SheetTrigger>
|
||||
<SheetContent className="w-full lg:max-w-4xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create new server</SheetTitle> <SheetDescription>Fill in the details to create a new server.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Form id="create-server-form" className="p-4" onSubmit={submit}>
|
||||
<FormFields>
|
||||
<FormField>
|
||||
<Label htmlFor="provider">Provider</Label>
|
||||
<Select value={form.data.provider} onValueChange={(value) => selectProvider(value)}>
|
||||
<SelectTrigger id="provider">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{page.props.configs.server_providers.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError />
|
||||
</FormField>
|
||||
|
||||
{form.data.provider && form.data.provider !== 'custom' && (
|
||||
<FormField>
|
||||
<Label htmlFor="server-provider">Server provider connection</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={form.data.server_provider.toString()} onValueChange={selectServerProvider}>
|
||||
<SelectTrigger id="provider">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{serverProviders
|
||||
.filter((item: ServerProvider) => item.provider === form.data.provider)
|
||||
.map((provider) => (
|
||||
<SelectItem key={`server-provider-${provider.id}`} value={provider.id.toString()}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CreateServerProvider
|
||||
trigger="icon"
|
||||
providers={page.props.configs.server_providers.filter((item) => item !== 'custom')}
|
||||
defaultProvider={form.data.provider}
|
||||
onProviderAdded={fetchServerProviders}
|
||||
/>
|
||||
</div>
|
||||
<InputError />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{form.data.provider && form.data.provider !== 'custom' && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<FormField>
|
||||
<Label htmlFor="region">Region</Label>
|
||||
<Select value={form.data.region} onValueChange={selectRegion} disabled={form.data.server_provider === 0}>
|
||||
<SelectTrigger id="region">
|
||||
<SelectValue placeholder="Select a region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(regions).map(([key, value]) => (
|
||||
<SelectItem key={`region-${key}`} value={key}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={form.errors.region} />
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="plan">Plan</Label>
|
||||
<Select value={form.data.plan} onValueChange={selectPlan} disabled={form.data.region === ''}>
|
||||
<SelectTrigger id="plan">
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(plans).map(([key, value]) => (
|
||||
<SelectItem key={`plan-${key}`} value={key}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={form.errors.plan} />
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.data.provider === 'custom' && (
|
||||
<>
|
||||
<Alert>
|
||||
<TriangleAlert size={5} />
|
||||
<AlertDescription>
|
||||
Your server needs to have a new unused installation of supported operating systems and must have a root user. To get started, add
|
||||
our public key to /root/.ssh/authorized_keys file by running the bellow command on your server as root.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FormField>
|
||||
<Label htmlFor="public_key">Public Key</Label>
|
||||
<Button
|
||||
onClick={copyToClipboard}
|
||||
variant="outline"
|
||||
id="public_key"
|
||||
type="button"
|
||||
value={page.props.publicKeyText}
|
||||
className="justify-between truncate font-normal"
|
||||
>
|
||||
<span className="w-full max-w-2/3 overflow-x-hidden overflow-ellipsis">{page.props.publicKeyText}</span>
|
||||
{copySuccess ? <ClipboardCheckIcon size={5} className="text-success!" /> : <ClipboardIcon size={5} />}
|
||||
</Button>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 items-start gap-6">
|
||||
<FormField>
|
||||
<Label htmlFor="name">Server Name</Label>
|
||||
<Input id="name" type="text" autoComplete="name" value={form.data.name} onChange={(e) => form.setData('name', e.target.value)} />
|
||||
<InputError message={form.errors.name} />
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Label htmlFor="os">Operating System</Label>
|
||||
<Select value={form.data.os} onValueChange={(value) => form.setData('os', value)}>
|
||||
<SelectTrigger id="os">
|
||||
<SelectValue placeholder="Select an operating system" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{page.props.configs.operating_systems.map((value) => (
|
||||
<SelectItem key={`os-${value}`} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={form.errors.os} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{form.data.provider === 'custom' && (
|
||||
<div className="grid grid-cols-2 items-start gap-6">
|
||||
<FormField>
|
||||
<Label htmlFor="ip">SSH IP</Label>
|
||||
<Input id="ip" type="text" autoComplete="ip" value={form.data.ip} onChange={(e) => form.setData('ip', e.target.value)} />
|
||||
<InputError message={form.errors.ip} />
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="port">SSH Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="text"
|
||||
autoComplete="port"
|
||||
value={form.data.port}
|
||||
onChange={(e) => form.setData('port', parseInt(e.target.value))}
|
||||
/>
|
||||
<InputError message={form.errors.port} />
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 items-start gap-6">
|
||||
<FormField>
|
||||
<Label htmlFor="webserver">Webserver</Label>
|
||||
<Select value={form.data.webserver} onValueChange={(value) => form.setData('webserver', value)}>
|
||||
<SelectTrigger id="webserver">
|
||||
<SelectValue placeholder="Select webserver" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{page.props.configs.webservers.map((value) => (
|
||||
<SelectItem key={`webserver-${value}`} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={form.errors.webserver} />
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Label htmlFor="database">Database</Label>
|
||||
<Select value={form.data.database} onValueChange={(value) => form.setData('database', value)}>
|
||||
<SelectTrigger id="database">
|
||||
<SelectValue placeholder="Select database" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{page.props.configs.databases.map((value) => (
|
||||
<SelectItem key={`database-${value}`} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={form.errors.database} />
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Label htmlFor="php">PHP</Label>
|
||||
<Select value={form.data.php} onValueChange={(value) => form.setData('php', value)}>
|
||||
<SelectTrigger id="php">
|
||||
<SelectValue placeholder="Select PHP version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{page.props.configs.php_versions.map((value) => (
|
||||
<SelectItem key={`php-${value}`} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={form.errors.php} />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormFields>
|
||||
</Form>
|
||||
<SheetFooter>
|
||||
<div className="flex items-center">
|
||||
<Button type="submit" form="create-server-form" tabIndex={4} disabled={form.processing}>
|
||||
{form.processing && <LoaderCircle className="animate-spin" />} Create
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
45
resources/js/pages/servers/index.tsx
Normal file
45
resources/js/pages/servers/index.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
|
||||
import { type Configs } from '@/types';
|
||||
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { columns } from '@/pages/servers/columns';
|
||||
import { Server } from '@/types/server';
|
||||
import Heading from '@/components/heading';
|
||||
import CreateServer from '@/pages/servers/create-server';
|
||||
import Container from '@/components/container';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React from 'react';
|
||||
|
||||
type Response = {
|
||||
servers: {
|
||||
data: Server[];
|
||||
};
|
||||
public_key: string;
|
||||
configs: Configs;
|
||||
};
|
||||
|
||||
export default function Servers() {
|
||||
const page = usePage<Response>();
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title="Servers" />
|
||||
|
||||
<Container>
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title="Servers" description="All of the servers on your project are here" />
|
||||
<div className="flex items-center gap-2">
|
||||
<CreateServer>
|
||||
<Button variant="outline">
|
||||
<PlusIcon /> Create new server
|
||||
</Button>
|
||||
</CreateServer>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} data={page.props.servers.data} />
|
||||
</Container>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
30
resources/js/pages/servers/installing.tsx
Normal file
30
resources/js/pages/servers/installing.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import type { Server } from '@/types/server';
|
||||
import type { ServerLog } from '@/types/server-log';
|
||||
import Container from '@/components/container';
|
||||
import Heading from '@/components/heading';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { columns } from '@/pages/server-logs/columns';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function InstallingServer() {
|
||||
const page = usePage<{
|
||||
server: Server;
|
||||
logs: {
|
||||
data: ServerLog[];
|
||||
};
|
||||
}>();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title={`Installing ${page.props.server.name}`} description="Your server is being installed" />
|
||||
{page.props.server.status === 'installation_failed' && <Button variant="destructive">Delete</Button>}
|
||||
</div>
|
||||
<Progress value={parseInt(page.props.server.progress || '0')} />
|
||||
<div className="mt-2 text-center">{page.props.server.progress}%</div>
|
||||
<DataTable columns={columns} data={page.props.logs.data} />
|
||||
</Container>
|
||||
);
|
||||
}
|
31
resources/js/pages/servers/show.tsx
Normal file
31
resources/js/pages/servers/show.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
|
||||
import { type Configs } from '@/types';
|
||||
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { type Server } from '@/types/server';
|
||||
import InstallingServer from '@/pages/servers/installing';
|
||||
import type { ServerLog } from '@/types/server-log';
|
||||
|
||||
type Response = {
|
||||
servers: {
|
||||
data: Server[];
|
||||
};
|
||||
logs: {
|
||||
data: ServerLog[];
|
||||
};
|
||||
server: Server;
|
||||
public_key: string;
|
||||
configs: Configs;
|
||||
};
|
||||
|
||||
export default function ShowServer() {
|
||||
const page = usePage<Response>();
|
||||
return (
|
||||
<AppLayout>
|
||||
<Head title={page.props.server.name} />
|
||||
|
||||
{['installing', 'installation_failed'].includes(page.props.server.status) && <InstallingServer />}
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
128
resources/js/pages/settings/password.tsx
Normal file
128
resources/js/pages/settings/password.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import InputError from '@/components/input-error';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { FormEventHandler, useRef } from 'react';
|
||||
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Password settings',
|
||||
href: '/settings/password',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Password() {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const currentPasswordInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data, setData, errors, put, reset, processing, recentlySuccessful } = useForm({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const updatePassword: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
put(route('password.update'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => reset(),
|
||||
onError: (errors) => {
|
||||
if (errors.password) {
|
||||
reset('password', 'password_confirmation');
|
||||
passwordInput.current?.focus();
|
||||
}
|
||||
|
||||
if (errors.current_password) {
|
||||
reset('current_password');
|
||||
currentPasswordInput.current?.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Profile settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall title="Update password" description="Ensure your account is using a long, random password to stay secure" />
|
||||
|
||||
<form onSubmit={updatePassword} className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current_password">Current password</Label>
|
||||
|
||||
<Input
|
||||
id="current_password"
|
||||
ref={currentPasswordInput}
|
||||
value={data.current_password}
|
||||
onChange={(e) => setData('current_password', e.target.value)}
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="current-password"
|
||||
placeholder="Current password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.current_password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
ref={passwordInput}
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">Confirm password</Label>
|
||||
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
value={data.password_confirmation}
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.password_confirmation} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button disabled={processing}>Save password</Button>
|
||||
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-neutral-600">Saved</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
129
resources/js/pages/settings/profile.tsx
Normal file
129
resources/js/pages/settings/profile.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import DeleteUser from '@/components/delete-user';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
title: 'Profile settings',
|
||||
href: '/settings/profile',
|
||||
},
|
||||
];
|
||||
|
||||
type ProfileForm = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: boolean; status?: string }) {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
|
||||
const { data, setData, patch, errors, processing, recentlySuccessful } = useForm<Required<ProfileForm>>({
|
||||
name: auth.user.name,
|
||||
email: auth.user.email,
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
patch(route('profile.update'), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Profile settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall title="Profile information" description="Update your name and email address" />
|
||||
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
|
||||
<Input
|
||||
id="name"
|
||||
className="mt-1 block w-full"
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder="Full name"
|
||||
/>
|
||||
|
||||
<InputError className="mt-2" message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
className="mt-1 block w-full"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
|
||||
<InputError className="mt-2" message={errors.email} />
|
||||
</div>
|
||||
|
||||
{mustVerifyEmail && auth.user.email_verified_at === null && (
|
||||
<div>
|
||||
<p className="text-muted-foreground -mt-4 text-sm">
|
||||
Your email address is unverified.{' '}
|
||||
<Link
|
||||
href={route('verification.send')}
|
||||
method="post"
|
||||
as="button"
|
||||
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
>
|
||||
Click here to resend the verification email.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{status === 'verification-link-sent' && (
|
||||
<div className="mt-2 text-sm font-medium text-green-600">A new verification link has been sent to your email address.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button disabled={processing}>Save</Button>
|
||||
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-neutral-600">Saved</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<DeleteUser />
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
30
resources/js/ssr.tsx
Normal file
30
resources/js/ssr.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import createServer from '@inertiajs/react/server';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import { type RouteName, route } from 'ziggy-js';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Vito';
|
||||
|
||||
createServer((page) =>
|
||||
createInertiaApp({
|
||||
page,
|
||||
render: ReactDOMServer.renderToString,
|
||||
title: (title) => `${title} - ${appName}`,
|
||||
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
|
||||
setup: ({ App, props }) => {
|
||||
/* eslint-disable */
|
||||
// @ts-expect-error
|
||||
global.route<RouteName> = (name, params, absolute) =>
|
||||
route(name, params as any, absolute, {
|
||||
// @ts-expect-error
|
||||
...page.props.ziggy,
|
||||
// @ts-expect-error
|
||||
location: new URL(page.props.ziggy.location),
|
||||
});
|
||||
/* eslint-enable */
|
||||
|
||||
return <App {...props} />;
|
||||
},
|
||||
}),
|
||||
);
|
5
resources/js/types/global.d.ts
vendored
Normal file
5
resources/js/types/global.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
import type { route as routeFn } from 'ziggy-js';
|
||||
|
||||
declare global {
|
||||
const route: typeof routeFn;
|
||||
}
|
57
resources/js/types/index.d.ts
vendored
Normal file
57
resources/js/types/index.d.ts
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import type { Config } from 'ziggy-js';
|
||||
import type { Server } from '@/types/server';
|
||||
|
||||
export interface Auth {
|
||||
user: User;
|
||||
projects: Project[];
|
||||
currentProject?: Project;
|
||||
}
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
href: string;
|
||||
activePath?: string;
|
||||
icon?: LucideIcon | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface Configs {
|
||||
server_providers: string[];
|
||||
server_providers_custom_fields: {
|
||||
[provider: string]: string[];
|
||||
};
|
||||
operating_systems: string[];
|
||||
service_versions: {
|
||||
[service: string]: string[];
|
||||
};
|
||||
webservers: string[];
|
||||
databases: string[];
|
||||
php_versions: string[];
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SharedData {
|
||||
name: string;
|
||||
quote: { message: string; author: string };
|
||||
auth: Auth;
|
||||
ziggy: Config & { location: string };
|
||||
sidebarOpen: boolean;
|
||||
configs: Configs;
|
||||
projectServers: Server[];
|
||||
server?: Server;
|
||||
publicKeyText: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
7
resources/js/types/project.d.ts
vendored
Normal file
7
resources/js/types/project.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
[key: string]: unknown;
|
||||
}
|
15
resources/js/types/server-log.d.ts
vendored
Normal file
15
resources/js/types/server-log.d.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
export interface ServerLog {
|
||||
id: number;
|
||||
server_id: number;
|
||||
site_id?: number;
|
||||
type: string;
|
||||
name: string;
|
||||
disk: string;
|
||||
is_remote: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_at_by_timezone: string;
|
||||
updated_at_by_timezone: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
12
resources/js/types/server-provider.d.ts
vendored
Normal file
12
resources/js/types/server-provider.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
export interface ServerProvider {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
provider: string;
|
||||
connected: boolean;
|
||||
project_id?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
32
resources/js/types/server.d.ts
vendored
Normal file
32
resources/js/types/server.d.ts
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
export interface Server {
|
||||
id: number;
|
||||
project_id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
ssh_user: string;
|
||||
ip: string;
|
||||
local_ip?: string;
|
||||
port: number;
|
||||
os: string;
|
||||
type: string;
|
||||
type_data: string;
|
||||
provider: string;
|
||||
provider_id: number;
|
||||
provider_data: string;
|
||||
authentication: string;
|
||||
public_key: string;
|
||||
status: string;
|
||||
auto_update: boolean;
|
||||
available_updates: number;
|
||||
security_updates: string;
|
||||
progress?: string;
|
||||
progress_step?: string;
|
||||
updates?: string;
|
||||
last_update_check?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_at_by_timezone: string;
|
||||
updated_at_by_timezone: string;
|
||||
status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger';
|
||||
[key: string]: unknown;
|
||||
}
|
10
resources/js/types/user.d.ts
vendored
Normal file
10
resources/js/types/user.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
email_verified_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
[key: string]: unknown; // This allows for additional properties...
|
||||
}
|
1
resources/js/types/vite-env.d.ts
vendored
Normal file
1
resources/js/types/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
Reference in New Issue
Block a user