* wip

* fix plugin uninstall

* marketplace
This commit is contained in:
Saeed Vaziry
2025-06-19 14:07:15 +02:00
committed by GitHub
parent 131b828807
commit 342a3aa4c6
35 changed files with 1973 additions and 934 deletions

View File

@ -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);

View File

@ -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 };

View 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 };

View File

@ -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,
},
{

View 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>
);
}

View 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 &lt;repository-url&gt;</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View File

@ -0,0 +1,4 @@
export interface Plugin {
name: string;
version: string;
}

102
resources/js/types/repo.d.ts vendored Normal file
View 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;
}