mirror of
https://github.com/vitodeploy/vito.git
synced 2025-07-07 08:52:35 +00:00
@ -47,7 +47,7 @@ .dark {
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(51.1% 0.262 276.966);
|
||||
--primary: oklch(0.544 0.242 279.973);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
|
@ -1,53 +1,142 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: React.ComponentProps<typeof DayPicker>) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
className={cn(
|
||||
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row gap-2',
|
||||
month: 'flex flex-col gap-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center w-full',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'flex items-center gap-1',
|
||||
nav_button: cn(buttonVariants({ variant: 'outline' }), 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100'),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-x-1',
|
||||
head_row: 'flex',
|
||||
head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: cn(
|
||||
'[&:has([aria-selected])]:bg-accent relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected].day-range-end)]:rounded-r-md',
|
||||
props.mode === 'range'
|
||||
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
|
||||
: '[&:has([aria-selected])]:rounded-md',
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
day: cn(buttonVariants({ variant: 'ghost' }), 'size-8 p-0 font-normal aria-selected:opacity-100'),
|
||||
day_range_start: 'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||
day_range_end: 'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||
day_selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn('flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)', defaultClassNames.month_caption),
|
||||
dropdowns: cn('flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium', defaultClassNames.dropdowns),
|
||||
dropdown_root: cn(
|
||||
'has-focus:border-ring border-input has-focus:ring-ring/50 relative rounded-md border shadow-xs has-focus:ring-[3px]',
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn('absolute inset-0 opacity-0', defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
'font-medium select-none',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5',
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn('text-muted-foreground flex-1 rounded-md text-[0.8rem] font-normal select-none', defaultClassNames.weekday),
|
||||
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
|
||||
week_number: cn('text-muted-foreground text-[0.8rem] select-none', defaultClassNames.week_number),
|
||||
day: cn(
|
||||
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn('bg-accent rounded-l-md', defaultClassNames.range_start),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end),
|
||||
today: cn('bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none', defaultClassNames.today),
|
||||
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
||||
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ className, ...props }) => <ChevronLeft className={cn('size-4', className)} {...props} />,
|
||||
IconRight: ({ className, ...props }) => <ChevronRight className={cn('size-4', className)} {...props} />,
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return <ChevronRightIcon className={cn('size-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
return <ChevronDownIcon className={cn('size-4', className)} {...props} />;
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar };
|
||||
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
|
37
resources/js/components/ui/tabs.tsx
Normal file
37
resources/js/components/ui/tabs.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return <TabsPrimitive.Root data-slot="tabs" className={cn('flex flex-col gap-2', className)} {...props} />;
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn('bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return <TabsPrimitive.Content data-slot="tabs-content" className={cn('flex-1 outline-none', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
@ -1,5 +1,5 @@
|
||||
import { type BreadcrumbItem, type NavItem } from '@/types';
|
||||
import { BellIcon, CloudIcon, CodeIcon, DatabaseIcon, KeyIcon, ListIcon, PlugIcon, TagIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||
import { BellIcon, CloudIcon, CodeIcon, CommandIcon, DatabaseIcon, KeyIcon, ListIcon, PlugIcon, TagIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import Layout from '@/layouts/app/layout';
|
||||
import VitoIcon from '@/icons/vito';
|
||||
@ -53,6 +53,11 @@ const sidebarNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'API Keys',
|
||||
href: route('api-keys'),
|
||||
icon: CommandIcon,
|
||||
},
|
||||
{
|
||||
title: 'Plugins',
|
||||
href: route('plugins'),
|
||||
icon: PlugIcon,
|
||||
},
|
||||
{
|
||||
|
84
resources/js/pages/plugins/components/community.tsx
Normal file
84
resources/js/pages/plugins/components/community.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { Repo } from '@/types/repo';
|
||||
import { LoaderCircleIcon, StarIcon } from 'lucide-react';
|
||||
import { CardRow } from '@/components/ui/card';
|
||||
import React, { Fragment } from 'react';
|
||||
import Install from '@/pages/plugins/components/install';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export default function CommunityPlugins() {
|
||||
const query = useInfiniteQuery<{
|
||||
total_count: number;
|
||||
incomplete_results: boolean;
|
||||
items: Repo[];
|
||||
next_page?: number;
|
||||
}>({
|
||||
queryKey: ['official-plugins'],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const data = (await axios.get('https://api.github.com/search/repositories?q=topic:vitodeploy-plugin&per_page=10&page=' + pageParam)).data;
|
||||
data.items = data.items.filter((repo: Repo) => repo.owner.login !== 'vitodeploy');
|
||||
if (data.items.length == 10) {
|
||||
data.next_page = (pageParam as number) + 1;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
retry: false,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.next_page,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{query.isLoading ? (
|
||||
<CardRow className="items-center justify-center">
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
</CardRow>
|
||||
) : query.data && query.data.pages.length > 0 && query.data.pages[0].items.length > 0 ? (
|
||||
<>
|
||||
{query.data.pages.map((page) =>
|
||||
page.items.map((repo) => (
|
||||
<Fragment key={repo.id}>
|
||||
<CardRow>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={repo.html_url} target="_blank" className="hover:text-primary">
|
||||
{repo.name}
|
||||
</a>
|
||||
<Badge variant="outline">by {repo.owner.login}</Badge>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">{repo.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => window.open(repo.html_url, '_blank')}>
|
||||
<StarIcon />
|
||||
{repo.stargazers_count}
|
||||
</Button>
|
||||
<Install repo={repo} />
|
||||
</div>
|
||||
</CardRow>
|
||||
{!(page.items[page.items.length - 1].id === repo.id && page === query.data.pages[query.data.pages.length - 1]) && (
|
||||
<Separator className="my-2" />
|
||||
)}
|
||||
</Fragment>
|
||||
)),
|
||||
)}
|
||||
{query.hasNextPage && (
|
||||
<div className="flex items-center justify-center p-5">
|
||||
<Button variant="outline" onClick={() => query.fetchNextPage()}>
|
||||
{query.isFetchingNextPage && <LoaderCircleIcon className="animate-spin" />}
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<CardRow className="items-center justify-center">
|
||||
<span className="text-muted-foreground">No plugins found</span>
|
||||
</CardRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
106
resources/js/pages/plugins/components/install.tsx
Normal file
106
resources/js/pages/plugins/components/install.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { Repo } from '@/types/repo';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { DownloadIcon, LoaderCircleIcon } from 'lucide-react';
|
||||
import { Form, FormField, FormFields } from '@/components/ui/form';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import InputError from '@/components/ui/input-error';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
|
||||
export default function Install({ repo }: { repo?: Repo }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const page = usePage<{
|
||||
plugins: Plugin[];
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
url: repo?.html_url || '',
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('plugins.install'), {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={repo && page.props.plugins.filter((plugin) => plugin.name === repo.full_name).length > 0}>
|
||||
<DownloadIcon />
|
||||
{repo && page.props.plugins.filter((plugin) => plugin.name === repo.full_name).length > 0 ? 'Installed' : 'Install'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install plugin</DialogTitle>
|
||||
<DialogDescription className="sr-only">Install plugin {repo?.full_name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form className="p-4" id="install-plugin-form" onSubmit={submit}>
|
||||
{repo ? (
|
||||
<p>
|
||||
Are you sure you want to install the plugin{' '}
|
||||
<strong className="text-primary hover:underline">
|
||||
<a href={repo.html_url} target="_blank">
|
||||
{repo.full_name}
|
||||
</a>
|
||||
</strong>
|
||||
? This will clone the repository and set it up as a Vito plugin.
|
||||
</p>
|
||||
) : (
|
||||
<FormFields>
|
||||
<FormField>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<p>You can use this form to install a plugin or use the following command on your Vito instance</p>
|
||||
<pre className="bg-muted rounded-md px-2 py-1">
|
||||
<code>php artisan plugins:install <repository-url></code>
|
||||
</pre>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Label htmlFor="url">Repository URL</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="text"
|
||||
name="url"
|
||||
autoComplete="url"
|
||||
value={form.data.url}
|
||||
onChange={(e) => form.setData('url', e.target.value)}
|
||||
/>
|
||||
<InputError message={form.errors.url} />
|
||||
</FormField>
|
||||
</FormFields>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={submit} disabled={form.processing}>
|
||||
{form.processing && <LoaderCircleIcon className="animate-spin" />}
|
||||
Install
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
28
resources/js/pages/plugins/components/installed.tsx
Normal file
28
resources/js/pages/plugins/components/installed.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { CardRow } from '@/components/ui/card';
|
||||
import React from 'react';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import Uninstall from '@/pages/plugins/components/uninstall';
|
||||
|
||||
export default function InstalledPlugins({ plugins }: { plugins: Plugin[] }) {
|
||||
return (
|
||||
<div>
|
||||
{plugins.length > 0 ? (
|
||||
plugins.map((plugin, index) => (
|
||||
<CardRow key={`plugin-${index}`}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">{plugin.name}</div>
|
||||
<span className="text-muted-foreground text-xs">{plugin.version}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Uninstall plugin={plugin} />
|
||||
</div>
|
||||
</CardRow>
|
||||
))
|
||||
) : (
|
||||
<CardRow className="items-center justify-center">
|
||||
<span className="text-muted-foreground">No plugins installed</span>
|
||||
</CardRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
84
resources/js/pages/plugins/components/official.tsx
Normal file
84
resources/js/pages/plugins/components/official.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { Repo } from '@/types/repo';
|
||||
import { BadgeCheckIcon, LoaderCircleIcon, StarIcon } from 'lucide-react';
|
||||
import { CardRow } from '@/components/ui/card';
|
||||
import React, { Fragment } from 'react';
|
||||
import Install from '@/pages/plugins/components/install';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export default function OfficialPlugins() {
|
||||
const query = useInfiniteQuery<{
|
||||
total_count: number;
|
||||
incomplete_results: boolean;
|
||||
items: Repo[];
|
||||
next_page?: number;
|
||||
}>({
|
||||
queryKey: ['official-plugins'],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const data = (
|
||||
await axios.get('https://api.github.com/search/repositories?q=owner:vitodeploy%20topic:vitodeploy-plugin&per_page=10&page=' + pageParam)
|
||||
).data;
|
||||
if (data.items.length == 10) {
|
||||
data.next_page = (pageParam as number) + 1;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
retry: false,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.next_page,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{query.isLoading ? (
|
||||
<CardRow className="items-center justify-center">
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
</CardRow>
|
||||
) : query.data && query.data.pages.length > 0 && query.data.pages[0].items.length > 0 ? (
|
||||
<>
|
||||
{query.data.pages.map((page) =>
|
||||
page.items.map((repo) => (
|
||||
<Fragment key={repo.id}>
|
||||
<CardRow key={repo.id}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={repo.html_url} target="_blank" className="hover:text-primary">
|
||||
{repo.name}
|
||||
</a>
|
||||
<BadgeCheckIcon className="text-primary size-4" />
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">{repo.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => window.open(repo.html_url, '_blank')}>
|
||||
<StarIcon />
|
||||
{repo.stargazers_count}
|
||||
</Button>
|
||||
<Install repo={repo} />
|
||||
</div>
|
||||
</CardRow>
|
||||
{!(page.items[page.items.length - 1].id === repo.id && page === query.data.pages[query.data.pages.length - 1]) && (
|
||||
<Separator className="my-2" />
|
||||
)}
|
||||
</Fragment>
|
||||
)),
|
||||
)}
|
||||
{query.hasNextPage && (
|
||||
<div className="flex items-center justify-center p-5">
|
||||
<Button variant="outline" onClick={() => query.fetchNextPage()}>
|
||||
{query.isFetchingNextPage && <LoaderCircleIcon className="animate-spin" />}
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<CardRow className="items-center justify-center">
|
||||
<span className="text-muted-foreground">No plugins found</span>
|
||||
</CardRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
58
resources/js/pages/plugins/components/uninstall.tsx
Normal file
58
resources/js/pages/plugins/components/uninstall.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { LoaderCircleIcon } from 'lucide-react';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
|
||||
export default function Uninstall({ plugin }: { plugin: Plugin }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
name: plugin.name,
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.delete(route('plugins.uninstall'), {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Uninstall</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Uninstall plugin</DialogTitle>
|
||||
<DialogDescription className="sr-only">Uninstall plugin {plugin.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="p-4">
|
||||
Are you sure you want to uninstall the plugin <strong>{plugin.name}</strong>?
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" onClick={submit} disabled={form.processing}>
|
||||
{form.processing && <LoaderCircleIcon className="animate-spin" />}
|
||||
Uninstall
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
83
resources/js/pages/plugins/index.tsx
Normal file
83
resources/js/pages/plugins/index.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import Heading from '@/components/heading';
|
||||
import React, { useState } from 'react';
|
||||
import Container from '@/components/container';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import OfficialPlugins from '@/pages/plugins/components/official';
|
||||
import InstalledPlugins from '@/pages/plugins/components/installed';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import CommunityPlugins from '@/pages/plugins/components/community';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BookOpenIcon } from 'lucide-react';
|
||||
import Install from '@/pages/plugins/components/install';
|
||||
|
||||
export default function Plugins() {
|
||||
const [tab, setTab] = useState('installed');
|
||||
const page = usePage<{
|
||||
plugins: Plugin[];
|
||||
}>();
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Head title="Plugins" />
|
||||
|
||||
<Container className="max-w-5xl">
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title="Plugins" description="Here you can install/uninstall plugins" />
|
||||
<div className="flex items-center gap-2">
|
||||
<a href="https://vitodeploy.com/docs/plugins" target="_blank">
|
||||
<Button variant="outline">
|
||||
<BookOpenIcon />
|
||||
<span className="hidden lg:block">Docs</span>
|
||||
</Button>
|
||||
</a>
|
||||
<Install />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="installed">Installed</TabsTrigger>
|
||||
<TabsTrigger value="official">Official</TabsTrigger>
|
||||
<TabsTrigger value="community">Community</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="installed">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Installed plugins</CardTitle>
|
||||
<CardDescription>All the installed plugins</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InstalledPlugins plugins={page.props.plugins} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="official">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Official plugins</CardTitle>
|
||||
<CardDescription>These plugins are developed and maintained by VitoDeploy's team</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OfficialPlugins />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="community">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Community plugins</CardTitle>
|
||||
<CardDescription>These plugins are developed and maintained by the community.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CommunityPlugins />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
4
resources/js/types/plugin.d.ts
vendored
Normal file
4
resources/js/types/plugin.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Plugin {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
102
resources/js/types/repo.d.ts
vendored
Normal file
102
resources/js/types/repo.d.ts
vendored
Normal file
@ -0,0 +1,102 @@
|
||||
export interface Repo {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
owner: {
|
||||
login: string;
|
||||
id: number;
|
||||
node_id: string;
|
||||
avatar_url: string;
|
||||
gravatar_id: string;
|
||||
url: string;
|
||||
html_url: string;
|
||||
followers_url: string;
|
||||
following_url: string;
|
||||
gists_url: string;
|
||||
starred_url: string;
|
||||
subscriptions_url: string;
|
||||
organizations_url: string;
|
||||
repos_url: string;
|
||||
events_url: string;
|
||||
received_events_url: string;
|
||||
type: string;
|
||||
user_view_type: string;
|
||||
site_admin: boolean;
|
||||
};
|
||||
html_url: string;
|
||||
description: string | null;
|
||||
fork: boolean;
|
||||
url: string;
|
||||
forks_url: string;
|
||||
keys_url: string;
|
||||
collaborators_url: string;
|
||||
teams_url: string;
|
||||
hooks_url: string;
|
||||
issue_events_url: string;
|
||||
events_url: string;
|
||||
assignees_url: string;
|
||||
branches_url: string;
|
||||
tags_url: string;
|
||||
blobs_url: string;
|
||||
git_tags_url: string;
|
||||
git_refs_url: string;
|
||||
trees_url: string;
|
||||
statuses_url: string;
|
||||
languages_url: string;
|
||||
stargazers_url: string;
|
||||
contributors_url: string;
|
||||
subscribers_url: string;
|
||||
subscription_url: string;
|
||||
commits_url: string;
|
||||
git_commits_url: string;
|
||||
comments_url: string;
|
||||
issue_comment_url: string;
|
||||
contents_url: string;
|
||||
compare_url: string;
|
||||
merges_url: string;
|
||||
archive_url: string;
|
||||
downloads_url: string;
|
||||
issues_url: string;
|
||||
pulls_url: string;
|
||||
milestones_url: string;
|
||||
notifications_url: string;
|
||||
labels_url: string;
|
||||
releases_url: string;
|
||||
deployments_url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
pushed_at: string;
|
||||
git_url: string;
|
||||
ssh_url: string;
|
||||
clone_url: string;
|
||||
svn_url: string;
|
||||
homepage: string;
|
||||
size: number;
|
||||
stargazers_count: number;
|
||||
watchers_count: number;
|
||||
language: string | null;
|
||||
has_issues: boolean;
|
||||
has_projects: boolean;
|
||||
has_downloads: boolean;
|
||||
has_wiki: boolean;
|
||||
has_pages: boolean;
|
||||
has_discussions: boolean;
|
||||
forks_count: number;
|
||||
mirror_url: string | null;
|
||||
archived: boolean;
|
||||
disabled: boolean;
|
||||
open_issues_count: number;
|
||||
license?: string;
|
||||
allow_forking: boolean;
|
||||
is_template: boolean;
|
||||
web_commit_signoff_required: boolean;
|
||||
topics: string[];
|
||||
visibility: string;
|
||||
forks: number;
|
||||
open_issues: number;
|
||||
watchers: number;
|
||||
default_branch: string;
|
||||
score: number;
|
||||
}
|
Reference in New Issue
Block a user