Merge branch 'develop' into feature/server-mounts

This commit is contained in:
Matthew Penner 2020-07-11 12:29:30 -06:00 committed by GitHub
commit 295f09ca43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
195 changed files with 5395 additions and 5417 deletions

View file

@ -1,38 +1,76 @@
parser: "@typescript-eslint/parser"
parserOptions:
ecmaVersion: 6
ecmaFeatures:
jsx: true
project: "./tsconfig.json"
tsconfigRootDir: "./"
settings:
react:
pragma: "React"
version: "detect"
linkComponents:
- name: Link
linkAttribute: to
- name: NavLink
linkAttribute: to
env:
browser: true
es6: true
plugins:
- "@typescript-eslint"
- "react"
- "react-hooks"
- "@typescript-eslint"
extends:
- "standard"
- "plugin:react/recommended"
- "plugin:@typescript-eslint/recommended"
globals:
tw: "readonly"
rules:
indent:
- error
- 4
- SwitchCase: 1
semi:
- error
- always
comma-dangle:
- error
- always-multiline
array-bracket-spacing:
- warn
- always
"react-hooks/rules-of-hooks":
- error
"react-hooks/exhaustive-deps": 0
"@typescript-eslint/explicit-function-return-type": 0
"@typescript-eslint/explicit-member-accessibility": 0
"@typescript-eslint/ban-ts-ignore": 0
"@typescript-eslint/no-unused-vars": 0
"@typescript-eslint/no-explicit-any": 0
"@typescript-eslint/no-non-null-assertion": 0
"@typescript-eslint/ban-ts-comment": 0
# This would be nice to have, but don't want to deal with the warning spam at the moment.
"@typescript-eslint/explicit-module-boundary-types": 0
no-restricted-imports:
- error
- paths:
- name: styled-components
message: Please import from styled-components/macro.
patterns:
- "!styled-components/macro"
# Not sure, this rule just doesn't work right and is protected by our use of Typescript anyways
# so I'm just not going to worry about it.
"react/prop-types": 0
"react/display-name": 0
"react/jsx-indent-props":
- warn
- 4
"react/jsx-boolean-value":
- warn
- never
"react/jsx-closing-bracket-location":
- 1
- "line-aligned"
"react/jsx-closing-tag-location": 1
overrides:
- files:
- "**/*.tsx"

View file

@ -1,21 +1,30 @@
import React from 'react';
import { Route } from 'react-router';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import { SwitchTransition } from 'react-transition-group';
import Fade from '@/components/elements/Fade';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
type Props = Readonly<{
children: React.ReactNode;
}>;
const StyledSwitchTransition = styled(SwitchTransition)`
${tw`relative`};
& section {
${tw`absolute w-full top-0 left-0`};
}
`;
export default ({ children }: Props) => (
const TransitionRouter: React.FC = ({ children }) => (
<Route
render={({ location }) => (
<TransitionGroup className={'route-transition-group'}>
<CSSTransition key={location.key} timeout={250} in={true} appear={true} classNames={'fade'}>
<StyledSwitchTransition>
<Fade timeout={150} key={location.key} in appear unmountOnExit>
<section>
{children}
</section>
</CSSTransition>
</TransitionGroup>
</Fade>
</StyledSwitchTransition>
)}
/>
);
export default TransitionRouter;

View file

@ -3,13 +3,13 @@ import { ApiKey, rawDataToApiKey } from '@/api/account/getApiKeys';
export default (description: string, allowedIps: string): Promise<ApiKey & { secretToken: string }> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/account/api-keys`, {
http.post('/api/client/account/api-keys', {
description,
// eslint-disable-next-line @typescript-eslint/camelcase
allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [],
})
.then(({ data }) => resolve({
...rawDataToApiKey(data.attributes),
// eslint-disable-next-line camelcase
secretToken: data.meta?.secret_token ?? '',
}))
.catch(reject);

View file

@ -9,10 +9,8 @@ interface Data {
export default ({ current, password, confirmPassword }: Data): Promise<void> => {
return new Promise((resolve, reject) => {
http.put('/api/client/account/password', {
// eslint-disable-next-line @typescript-eslint/camelcase
current_password: current,
password: password,
// eslint-disable-next-line @typescript-eslint/camelcase
password_confirmation: confirmPassword,
})
.then(() => resolve())

View file

@ -4,11 +4,9 @@ import { LoginResponse } from '@/api/auth/login';
export default (token: string, code: string, recoveryToken?: string): Promise<LoginResponse> => {
return new Promise((resolve, reject) => {
http.post('/auth/login/checkpoint', {
/* eslint-disable @typescript-eslint/camelcase */
confirmation_token: token,
authentication_code: code,
recovery_token: (recoveryToken && recoveryToken.length > 0) ? recoveryToken : undefined,
/* eslint-enable @typescript-eslint/camelcase */
})
.then(response => resolve({
complete: response.data.data.complete,

View file

@ -17,7 +17,6 @@ export default (email: string, data: Data): Promise<PasswordResetResponse> => {
email,
token: data.token,
password: data.password,
// eslint-disable-next-line @typescript-eslint/camelcase
password_confirmation: data.passwordConfirmation,
})
.then(response => resolve({

View file

@ -3,16 +3,15 @@ import http, { getPaginationSet, PaginatedResult } from '@/api/http';
export default (query?: string, includeAdmin?: boolean): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => {
http.get(`/api/client`, {
http.get('/api/client', {
params: {
include: [ 'allocation' ],
// eslint-disable-next-line @typescript-eslint/camelcase
filter: includeAdmin ? 'all' : undefined,
query,
type: includeAdmin ? 'all' : undefined,
'filter[name]': query,
},
})
.then(({ data }) => resolve({
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum)),
pagination: getPaginationSet(data.meta.pagination),
}))
.catch(reject);

View file

@ -3,7 +3,7 @@ import http from '@/api/http';
export default (): Promise<PanelPermissions> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/permissions`)
http.get('/api/client/permissions')
.then(({ data }) => resolve(data.attributes.permissions))
.catch(reject);
});

View file

@ -5,7 +5,7 @@ const http: AxiosInstance = axios.create({
timeout: 20000,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': (window as any).X_CSRF_TOKEN as string || '',
},
@ -75,12 +75,15 @@ export interface FractalResponseData {
object: string;
attributes: {
[k: string]: any;
relationships?: {
[k: string]: FractalResponseData;
};
relationships?: Record<string, FractalResponseData | FractalResponseList>;
};
}
export interface FractalResponseList {
object: 'list';
data: FractalResponseData[];
}
export interface PaginatedResult<T> {
items: T[];
pagination: PaginationDataSet;

View file

@ -14,23 +14,21 @@ export interface FileObject {
modifiedAt: Date;
}
export default (uuid: string, directory?: string): Promise<FileObject[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/files/list`, {
params: { directory },
})
.then(response => resolve((response.data.data || []).map((item: any): FileObject => ({
uuid: v4(),
name: item.attributes.name,
mode: item.attributes.mode,
size: Number(item.attributes.size),
isFile: item.attributes.is_file,
isSymlink: item.attributes.is_symlink,
isEditable: item.attributes.is_editable,
mimetype: item.attributes.mimetype,
createdAt: new Date(item.attributes.created_at),
modifiedAt: new Date(item.attributes.modified_at),
}))))
.catch(reject);
export default async (uuid: string, directory?: string): Promise<FileObject[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/files/list`, {
params: { directory },
});
return (data.data || []).map((item: any): FileObject => ({
uuid: v4(),
name: item.attributes.name,
mode: item.attributes.mode,
size: Number(item.attributes.size),
isFile: item.attributes.is_file,
isSymlink: item.attributes.is_symlink,
isEditable: item.attributes.is_editable,
mimetype: item.attributes.mimetype,
createdAt: new Date(item.attributes.created_at),
modifiedAt: new Date(item.attributes.modified_at),
}));
};

View file

@ -8,9 +8,7 @@ interface Data {
export default (uuid: string, { renameFrom, renameTo }: Data): Promise<void> => {
return new Promise((resolve, reject) => {
http.put(`/api/client/servers/${uuid}/files/rename`, {
// eslint-disable-next-line @typescript-eslint/camelcase
rename_from: renameFrom,
// eslint-disable-next-line @typescript-eslint/camelcase
rename_to: renameTo,
})
.then(() => resolve())

View file

@ -1,10 +1,13 @@
import http from '@/api/http';
import http, { FractalResponseData, FractalResponseList } from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export interface Allocation {
id: number;
ip: string;
alias: string | null;
port: number;
default: boolean;
notes: string | null;
isDefault: boolean;
}
export interface Server {
@ -35,7 +38,7 @@ export interface Server {
isInstalling: boolean;
}
export const rawDataToServerObject = (data: any): Server => ({
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
id: data.identifier,
uuid: data.uuid,
name: data.name,
@ -45,24 +48,20 @@ export const rawDataToServerObject = (data: any): Server => ({
port: data.sftp_details.port,
},
description: data.description ? ((data.description.length > 0) ? data.description : null) : null,
allocations: [ {
ip: data.allocation.ip,
alias: null,
port: data.allocation.port,
default: true,
} ],
limits: { ...data.limits },
featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended,
isInstalling: data.is_installing,
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
});
export default (uuid: string): Promise<[ Server, string[] ]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}`)
.then(({ data }) => resolve([
rawDataToServerObject(data.attributes),
data.meta?.is_server_owner ? ['*'] : (data.meta?.user_permissions || []),
rawDataToServerObject(data),
// eslint-disable-next-line camelcase
data.meta?.is_server_owner ? [ '*' ] : (data.meta?.user_permissions || []),
]))
.catch(reject);
});

View file

@ -18,7 +18,7 @@ export const rawDataToServerDatabase = (data: any): ServerDatabase => ({
password: data.relationships && data.relationships.password ? data.relationships.password.attributes.password : undefined,
});
export default (uuid: string, includePassword: boolean = true): Promise<ServerDatabase[]> => {
export default (uuid: string, includePassword = true): Promise<ServerDatabase[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/databases`, {
params: includePassword ? { include: 'password' } : undefined,

View file

@ -0,0 +1,4 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
export default async (uuid: string, id: number): Promise<Allocation> => await http.delete(`/api/client/servers/${uuid}/network/allocations/${id}`);

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
import { Allocation } from '@/api/server/getServer';
export default async (uuid: string): Promise<Allocation[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/network/allocations`);
return (data.data || []).map(rawDataToServerAllocation);
};

View file

@ -0,0 +1,9 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export default async (uuid: string, id: number): Promise<Allocation> => {
const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations/${id}/primary`);
return rawDataToServerAllocation(data);
};

View file

@ -0,0 +1,9 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export default async (uuid: string, id: number, notes: string | null): Promise<Allocation> => {
const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations/${id}`, { notes });
return rawDataToServerAllocation(data);
};

View file

@ -6,4 +6,4 @@ export default (uuid: string): Promise<void> => {
.then(() => resolve())
.catch(reject);
});
}
};

View file

@ -11,7 +11,6 @@ export default (uuid: string, schedule: number, task: number | undefined, { time
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, {
...data,
// eslint-disable-next-line @typescript-eslint/camelcase
time_offset: timeOffset,
})
.then(({ data }) => resolve(rawDataToServerTask(data.attributes)))

View file

@ -5,5 +5,5 @@ export default (uuid: string, scheduleId: number, taskId: number): Promise<void>
http.delete(`/api/client/servers/${uuid}/schedules/${scheduleId}/tasks/${taskId}`)
.then(() => resolve())
.catch(reject);
})
});
};

View file

@ -5,7 +5,7 @@ export default (uuid: string, schedule: number): Promise<Schedule> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/schedules/${schedule}`, {
params: {
include: ['tasks'],
include: [ 'tasks' ],
},
})
.then(({ data }) => resolve(rawDataToServerSchedule(data.attributes)))

View file

@ -64,7 +64,7 @@ export default (uuid: string): Promise<Schedule[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/schedules`, {
params: {
include: ['tasks'],
include: [ 'tasks' ],
},
})
.then(({ data }) => resolve((data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes))))

View file

@ -15,4 +15,4 @@ export default (uuid: string, params: Params, subuser?: Subuser): Promise<Subuse
.then(data => resolve(rawDataToServerSubuser(data.data)))
.catch(reject);
});
}
};

View file

@ -0,0 +1,11 @@
import { Allocation } from '@/api/server/getServer';
import { FractalResponseData } from '@/api/http';
export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
id: data.attributes.id,
ip: data.attributes.ip,
alias: data.attributes.ip_alias,
port: data.attributes.port,
notes: data.attributes.notes,
isDefault: data.attributes.is_default,
});

View file

@ -0,0 +1,35 @@
import tw from 'twin.macro';
import { createGlobalStyle } from 'styled-components/macro';
export default createGlobalStyle`
body {
${tw`font-sans bg-neutral-800 text-neutral-200`};
letter-spacing: 0.015em;
}
h1, h2, h3, h4, h5, h6 {
${tw`font-medium tracking-normal font-header`};
}
p {
${tw`text-neutral-200 leading-snug font-sans`};
}
form {
${tw`m-0`};
}
textarea, select, input, button, button:focus, button:focus-visible {
${tw`outline-none`};
}
input[type=number]::-webkit-outer-spin-button,
input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none !important;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield !important;
}
`;

View file

@ -8,9 +8,10 @@ import ServerRouter from '@/routers/ServerRouter';
import AuthenticationRouter from '@/routers/AuthenticationRouter';
import { Provider } from 'react-redux';
import { SiteSettings } from '@/state/settings';
import { DefaultTheme, ThemeProvider } from 'styled-components';
import ProgressBar from '@/components/elements/ProgressBar';
import NotFound from '@/components/screens/NotFound';
import tw from 'twin.macro';
import GlobalStylesheet from '@/assets/css/GlobalStylesheet';
interface ExtendedWindow extends Window {
SiteConfiguration?: SiteSettings;
@ -18,24 +19,16 @@ interface ExtendedWindow extends Window {
uuid: string;
username: string;
email: string;
/* eslint-disable camelcase */
root_admin: boolean;
use_totp: boolean;
language: string;
updated_at: string;
created_at: string;
/* eslint-enable camelcase */
};
}
const theme: DefaultTheme = {
breakpoints: {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
},
};
const App = () => {
const { PterodactylUser, SiteConfiguration } = (window as ExtendedWindow);
if (PterodactylUser && !store.getState().user.data) {
@ -56,11 +49,12 @@ const App = () => {
}
return (
<ThemeProvider theme={theme}>
<>
<GlobalStylesheet/>
<StoreProvider store={store}>
<Provider store={store}>
<ProgressBar/>
<div className={'mx-auto w-auto'}>
<div css={tw`mx-auto w-auto`}>
<BrowserRouter basename={'/'} key={'root-router'}>
<Switch>
<Route path="/server/:id" component={ServerRouter}/>
@ -72,7 +66,7 @@ const App = () => {
</div>
</Provider>
</StoreProvider>
</ThemeProvider>
</>
);
};

View file

@ -1,38 +1,35 @@
import React from 'react';
import MessageBox from '@/components/MessageBox';
import { State, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { useStoreState } from 'easy-peasy';
import tw from 'twin.macro';
type Props = Readonly<{
byKey?: string;
spacerClass?: string;
className?: string;
}>;
export default ({ className, spacerClass, byKey }: Props) => {
const flashes = useStoreState((state: State<ApplicationStore>) => state.flashes.items);
let filtered = flashes;
if (byKey) {
filtered = flashes.filter(flash => flash.key === byKey);
}
if (filtered.length === 0) {
return null;
}
const FlashMessageRender = ({ byKey, className }: Props) => {
const flashes = useStoreState(state => state.flashes.items.filter(
flash => byKey ? flash.key === byKey : true,
));
return (
<div className={className}>
{
filtered.map((flash, index) => (
<React.Fragment key={flash.id || flash.type + flash.message}>
{index > 0 && <div className={spacerClass || 'mt-2'}></div>}
<MessageBox type={flash.type} title={flash.title}>
{flash.message}
</MessageBox>
</React.Fragment>
))
}
</div>
flashes.length ?
<div className={className}>
{
flashes.map((flash, index) => (
<React.Fragment key={flash.id || flash.type + flash.message}>
{index > 0 && <div css={tw`mt-2`}></div>}
<MessageBox type={flash.type} title={flash.title}>
{flash.message}
</MessageBox>
</React.Fragment>
))
}
</div>
:
null
);
};
export default FlashMessageRender;

View file

@ -1,4 +1,6 @@
import * as React from 'react';
import tw, { TwStyle } from 'twin.macro';
import styled from 'styled-components/macro';
export type FlashMessageType = 'success' | 'info' | 'warning' | 'error';
@ -8,11 +10,60 @@ interface Props {
type?: FlashMessageType;
}
export default ({ title, children, type }: Props) => (
<div className={`lg:inline-flex alert ${type}`} role={'alert'}>
{title && <span className={'title'}>{title}</span>}
<span className={'message'}>
const styling = (type?: FlashMessageType): TwStyle | string => {
switch (type) {
case 'error':
return tw`bg-red-600 border-red-800`;
case 'info':
return tw`bg-primary-600 border-primary-800`;
case 'success':
return tw`bg-green-600 border-green-800`;
case 'warning':
return tw`bg-yellow-600 border-yellow-800`;
default:
return '';
}
};
const getBackground = (type?: FlashMessageType): TwStyle | string => {
switch (type) {
case 'error':
return tw`bg-red-500`;
case 'info':
return tw`bg-primary-500`;
case 'success':
return tw`bg-green-500`;
case 'warning':
return tw`bg-yellow-500`;
default:
return '';
}
};
const Container = styled.div<{ $type?: FlashMessageType }>`
${tw`p-2 border items-center leading-normal rounded flex w-full text-sm text-white`};
${props => styling(props.$type)};
`;
Container.displayName = 'MessageBox.Container';
const MessageBox = ({ title, children, type }: Props) => (
<Container css={tw`lg:inline-flex`} $type={type} role={'alert'}>
{title &&
<span
className={'title'}
css={[
tw`flex rounded-full uppercase px-2 py-1 text-xs font-bold mr-3 leading-none`,
getBackground(type),
]}
>
{title}
</span>
}
<span css={tw`mr-2 text-left flex-auto`}>
{children}
</span>
</div>
</Container>
);
MessageBox.displayName = 'MessageBox';
export default MessageBox;

View file

@ -1,51 +1,77 @@
import * as React from 'react';
import { Link, NavLink } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLayerGroup } from '@fortawesome/free-solid-svg-icons/faLayerGroup';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons/faUserCircle';
import { faSignOutAlt } from '@fortawesome/free-solid-svg-icons/faSignOutAlt';
import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook';
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
import { faCogs, faLayerGroup, faSignOutAlt, faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import SearchContainer from '@/components/dashboard/search/SearchContainer';
import tw from 'twin.macro';
import styled from 'styled-components/macro';
// @ts-ignore
import * as config from '@/../../tailwind.config.js';
const Navigation = styled.div`
${tw`w-full bg-neutral-900 shadow-md`};
& > div {
${tw`mx-auto w-full flex items-center`};
}
& #logo {
${tw`flex-1`};
& > a {
${tw`text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150`};
}
}
`;
const RightNavigation = styled.div`
${tw`flex h-full items-center justify-center`};
& > a, & > .navigation-link {
${tw`flex items-center h-full no-underline text-neutral-300 px-6 cursor-pointer transition-all duration-150`};
&:active, &:hover {
${tw`text-neutral-100 bg-black`};
}
&:active, &:hover, &.active {
box-shadow: inset 0 -2px ${config.theme.colors.cyan['700']};
}
}
`;
export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!);
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
return (
<div id={'navigation'}>
<div className={'mx-auto w-full flex items-center'} style={{ maxWidth: '1200px', height: '3.5rem' }}>
<Navigation>
<div css={tw`mx-auto w-full flex items-center`} style={{ maxWidth: '1200px', height: '3.5rem' }}>
<div id={'logo'}>
<Link to={'/'}>
{name}
</Link>
</div>
<div className={'right-navigation'}>
<RightNavigation>
<SearchContainer/>
<NavLink to={'/'} exact={true}>
<NavLink to={'/'} exact>
<FontAwesomeIcon icon={faLayerGroup}/>
</NavLink>
<NavLink to={'/account'}>
<FontAwesomeIcon icon={faUserCircle}/>
</NavLink>
{user.rootAdmin &&
<a href={'/admin'} target={'_blank'}>
<a href={'/admin'} target={'_blank'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faCogs}/>
</a>
}
{process.env.NODE_ENV !== 'production' &&
<NavLink to={'/design'}>
<FontAwesomeIcon icon={faSwatchbook}/>
</NavLink>
}
<a href={'/auth/logout'}>
<FontAwesomeIcon icon={faSignOutAlt}/>
</a>
</div>
</RightNavigation>
</div>
</div>
</Navigation>
);
};

View file

@ -1,13 +0,0 @@
import * as React from 'react';
import MessageBox from '@/components/MessageBox';
export default ({ message }: { message: string | undefined | null }) => (
!message ?
null
:
<div className={'mb-4'}>
<MessageBox type={'error'} title={'Error'}>
{message}
</MessageBox>
</div>
);

View file

@ -1,13 +0,0 @@
import * as React from 'react';
import { NavLink } from 'react-router-dom';
export default class ServerOverviewContainer extends React.PureComponent {
render () {
return (
<div className={'mt-10'}>
<NavLink className={'text-neutral-100 text-sm block mb-2 no-underline hover:underline'} to={'/account'}>Account</NavLink>
<NavLink className={'text-neutral-100 text-sm block mb-2 no-underline hover:underline'} to={'/account/design'}>Design</NavLink>
</div>
);
}
}

View file

@ -8,6 +8,8 @@ import { ApplicationStore } from '@/state';
import Field from '@/components/elements/Field';
import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
email: string;
@ -43,33 +45,30 @@ export default () => {
{({ isSubmitting }) => (
<LoginFormContainer
title={'Request Password Reset'}
className={'w-full flex'}
css={tw`w-full flex`}
>
<Field
light={true}
light
label={'Email'}
description={'Enter your account email address to receive instructions on resetting your password.'}
name={'email'}
type={'email'}
/>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
type={'submit'}
className={'btn btn-primary btn-jumbo flex justify-center'}
size={'xlarge'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
{isSubmitting ?
<div className={'spinner-circle spinner-sm spinner-white'}></div>
:
'Send Email'
}
</button>
Send Email
</Button>
</div>
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<Link
type={'button'}
to={'/auth/login'}
className={'text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
Return to Login
</Link>

View file

@ -5,19 +5,19 @@ import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { ActionCreator } from 'easy-peasy';
import { StaticContext } from 'react-router';
import Spinner from '@/components/elements/Spinner';
import { useFormikContext, withFormik } from 'formik';
import { object, string } from 'yup';
import useFlash from '@/plugins/useFlash';
import { FlashStore } from '@/state/flashes';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
code: string;
recoveryCode: '',
}
type OwnProps = RouteComponentProps<{}, StaticContext, { token?: string }>
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>
type Props = OwnProps & {
addError: ActionCreator<FlashStore['addError']['payload']>;
@ -29,13 +29,10 @@ const LoginCheckpointContainer = () => {
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
return (
<LoginFormContainer
title={'Device Checkpoint'}
className={'w-full flex'}
>
<div className={'mt-6'}>
<LoginFormContainer title={'Device Checkpoint'} css={tw`w-full flex`}>
<div css={tw`mt-6`}>
<Field
light={true}
light
name={isMissingDevice ? 'recoveryCode' : 'code'}
title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
description={
@ -44,38 +41,35 @@ const LoginCheckpointContainer = () => {
: 'Enter the two-factor token generated by your device.'
}
type={isMissingDevice ? 'text' : 'number'}
autoFocus={true}
autoFocus
/>
</div>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
size={'xlarge'}
type={'submit'}
className={'btn btn-primary btn-jumbo'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
{isSubmitting ?
<Spinner size={'tiny'} className={'mx-auto'}/>
:
'Continue'
}
</button>
Continue
</Button>
</div>
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<span
onClick={() => {
setFieldValue('code', '');
setFieldValue('recoveryCode', '');
setIsMissingDevice(s => !s);
}}
className={'cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
{!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'}
</span>
</div>
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/login'}
className={'text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
Return to Login
</Link>

View file

@ -10,7 +10,8 @@ import Field from '@/components/elements/Field';
import { httpErrorToHuman } from '@/api/http';
import { FlashMessage } from '@/state/flashes';
import ReCAPTCHA from 'react-google-recaptcha';
import Spinner from '@/components/elements/Spinner';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
type OwnProps = RouteComponentProps & {
clearFlashes: ActionCreator<void>;
@ -34,38 +35,27 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
return (
<React.Fragment>
{ref.current && ref.current.render()}
<LoginFormContainer
title={'Login to Continue'}
className={'w-full flex'}
onSubmit={submit}
>
<label htmlFor={'username'}>Username or Email</label>
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`} onSubmit={submit}>
<Field
type={'text'}
label={'Username or Email'}
id={'username'}
name={'username'}
className={'input'}
light
/>
<div className={'mt-6'}>
<label htmlFor={'password'}>Password</label>
<div css={tw`mt-6`}>
<Field
type={'password'}
label={'Password'}
id={'password'}
name={'password'}
className={'input'}
light
/>
</div>
<div className={'mt-6'}>
<button
type={'submit'}
className={'btn btn-primary btn-jumbo'}
>
{isSubmitting ?
<Spinner size={'tiny'} className={'mx-auto'}/>
:
'Login'
}
</button>
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting}>
Login
</Button>
</div>
{recaptchaEnabled &&
<ReCAPTCHA
@ -80,10 +70,10 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
onExpired={() => setFieldValue('recaptchaData', null)}
/>
}
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/password'}
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
Forgot password?
</Link>
@ -96,7 +86,7 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
const EnhancedForm = withFormik<OwnProps, LoginData>({
displayName: 'LoginContainerForm',
mapPropsToValues: (props) => ({
mapPropsToValues: () => ({
username: '',
password: '',
recaptchaData: null,

View file

@ -1,8 +1,9 @@
import React, { forwardRef } from 'react';
import { Form } from 'formik';
import styled from 'styled-components';
import { breakpoint } from 'styled-components-breakpoint';
import styled from 'styled-components/macro';
import { breakpoint } from '@/theme';
import FlashMessageRender from '@/components/FlashMessageRender';
import tw from 'twin.macro';
type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
title?: string;
@ -29,27 +30,29 @@ const Container = styled.div`
export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) => (
<Container>
{title && <h2 className={'text-center text-neutral-100 font-medium py-4'}>
{title &&
<h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}>
{title}
</h2>}
<FlashMessageRender className={'mb-2 px-1'}/>
</h2>
}
<FlashMessageRender css={tw`mb-2 px-1`}/>
<Form {...props} ref={ref}>
<div className={'md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1'}>
<div className={'flex-none select-none mb-6 md:mb-0 self-center'}>
<img src={'/assets/svgs/pterodactyl.svg'} className={'block w-48 md:w-64 mx-auto'}/>
<div css={tw`md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1`}>
<div css={tw`flex-none select-none mb-6 md:mb-0 self-center`}>
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`}/>
</div>
<div className={'flex-1'}>
<div css={tw`flex-1`}>
{props.children}
</div>
</div>
</Form>
<p className={'text-center text-neutral-500 text-xs mt-4'}>
<p css={tw`text-center text-neutral-500 text-xs mt-4`}>
&copy; 2015 - 2020&nbsp;
<a
rel={'noopener nofollow'}
rel={'noopener nofollow noreferrer'}
href={'https://pterodactyl.io'}
target={'_blank'}
className={'no-underline text-neutral-500 hover:text-neutral-300'}
css={tw`no-underline text-neutral-500 hover:text-neutral-300`}
>
Pterodactyl Software
</a>

View file

@ -7,19 +7,19 @@ import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import Spinner from '@/components/elements/Spinner';
import { Formik, FormikHelpers } from 'formik';
import { object, ref, string } from 'yup';
import Field from '@/components/elements/Field';
type Props = Readonly<RouteComponentProps<{ token: string }> & {}>;
import Input from '@/components/elements/Input';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
password: string;
passwordConfirmation: string;
}
export default ({ match, history, location }: Props) => {
export default ({ match, location }: RouteComponentProps<{ token: string }>) => {
const [ email, setEmail ] = useState('');
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -56,52 +56,50 @@ export default ({ match, history, location }: Props) => {
.min(8, 'Your new password should be at least 8 characters in length.'),
passwordConfirmation: string()
.required('Your new password does not match.')
.oneOf([ref('password'), null], 'Your new password does not match.'),
// @ts-ignore
.oneOf([ ref('password'), null ], 'Your new password does not match.'),
})}
>
{({ isSubmitting }) => (
<LoginFormContainer
title={'Reset Password'}
className={'w-full flex'}
css={tw`w-full flex`}
>
<div>
<label>Email</label>
<input className={'input'} value={email} disabled={true}/>
<Input value={email} isLight disabled/>
</div>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
light={true}
light
label={'New Password'}
name={'password'}
type={'password'}
description={'Passwords must be at least 8 characters in length.'}
/>
</div>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
light={true}
light
label={'Confirm New Password'}
name={'passwordConfirmation'}
type={'password'}
/>
</div>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
size={'xlarge'}
type={'submit'}
className={'btn btn-primary btn-jumbo'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
{isSubmitting ?
<Spinner size={'tiny'} className={'mx-auto'}/>
:
'Reset Password'
}
</button>
Reset Password
</Button>
</div>
<div className={'mt-6 text-center'}>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/login'}
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
Return to Login
</Link>

View file

@ -4,16 +4,17 @@ import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey } from '@fortawesome/free-solid-svg-icons/faKey';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteApiKey from '@/api/account/deleteApiKey';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import FlashMessageRender from '@/components/FlashMessageRender';
import { httpErrorToHuman } from '@/api/http';
import format from 'date-fns/format';
import { format } from 'date-fns';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
export default () => {
const [ deleteIdentifier, setDeleteIdentifier ] = useState('');
@ -48,18 +49,18 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender byKey={'account'} className={'mb-4'}/>
<div className={'flex'}>
<ContentBox title={'Create API Key'} className={'flex-1'}>
<FlashMessageRender byKey={'account'} css={tw`mb-4`}/>
<div css={tw`flex`}>
<ContentBox title={'Create API Key'} css={tw`flex-1`}>
<CreateApiKeyForm onKeyCreated={key => setKeys(s => ([ ...s!, key ]))}/>
</ContentBox>
<ContentBox title={'API Keys'} className={'ml-10 flex-1'}>
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
<SpinnerOverlay visible={loading}/>
{deleteIdentifier &&
<ConfirmationModal
visible
title={'Confirm key deletion'}
buttonText={'Yes, delete key'}
visible={true}
onConfirmed={() => {
doDeletion(deleteIdentifier);
setDeleteIdentifier('');
@ -72,38 +73,38 @@ export default () => {
}
{
keys.length === 0 ?
<p className={'text-center text-sm'}>
<p css={tw`text-center text-sm`}>
{loading ? 'Loading...' : 'No API keys exist for this account.'}
</p>
:
keys.map(key => (
<div
keys.map((key, index) => (
<GreyRowBox
key={key.identifier}
className={'grey-row-box bg-neutral-600 mb-2 flex items-center'}
css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}
>
<FontAwesomeIcon icon={faKey} className={'text-neutral-300'}/>
<div className={'ml-4 flex-1'}>
<p className={'text-sm'}>{key.description}</p>
<p className={'text-2xs text-neutral-300 uppercase'}>
Last
used: {key.lastUsedAt ? format(key.lastUsedAt, 'MMM Do, YYYY HH:mm') : 'Never'}
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
<div css={tw`ml-4 flex-1`}>
<p css={tw`text-sm`}>{key.description}</p>
<p css={tw`text-2xs text-neutral-300 uppercase`}>
Last used:&nbsp;
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'}
</p>
</div>
<p className={'text-sm ml-4'}>
<code className={'font-mono py-1 px-2 bg-neutral-900 rounded'}>
<p css={tw`text-sm ml-4`}>
<code css={tw`font-mono py-1 px-2 bg-neutral-900 rounded`}>
{key.identifier}
</code>
</p>
<button
className={'ml-4 p-2 text-sm'}
css={tw`ml-4 p-2 text-sm`}
onClick={() => setDeleteIdentifier(key.identifier)}
>
<FontAwesomeIcon
icon={faTrashAlt}
className={'text-neutral-400 hover:text-red-400 transition-colors duration-150'}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
/>
</button>
</div>
</GreyRowBox>
))
}
</ContentBox>

View file

@ -3,9 +3,10 @@ import ContentBox from '@/components/elements/ContentBox';
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
import ConfigureTwoFactorForm from '@/components/dashboard/forms/ConfigureTwoFactorForm';
import styled from 'styled-components';
import { breakpoint } from 'styled-components-breakpoint';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import { breakpoint } from '@/theme';
import styled from 'styled-components/macro';
const Container = styled.div`
${tw`flex flex-wrap my-10`};
@ -31,13 +32,13 @@ export default () => {
<UpdatePasswordForm/>
</ContentBox>
<ContentBox
className={'mt-8 md:mt-0 md:ml-8'}
css={tw`mt-8 md:mt-0 md:ml-8`}
title={'Update Email Address'}
showFlashes={'account:email'}
>
<UpdateEmailAddressForm/>
</ContentBox>
<ContentBox className={'xl:ml-8 mt-8 xl:mt-0'} title={'Configure Two Factor'}>
<ContentBox css={tw`xl:ml-8 mt-8 xl:mt-0`} title={'Configure Two Factor'}>
<ConfigureTwoFactorForm/>
</ContentBox>
</Container>

View file

@ -10,6 +10,7 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import { useStoreState } from 'easy-peasy';
import { usePersistedState } from '@/plugins/usePersistedState';
import Switch from '@/components/elements/Switch';
import tw from 'twin.macro';
export default () => {
const { addError, clearFlashes } = useFlash();
@ -37,10 +38,10 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender className={'mb-4'}/>
<FlashMessageRender css={tw`mb-4`}/>
{rootAdmin &&
<div className={'mb-2 flex justify-end items-center'}>
<p className={'uppercase text-xs text-neutral-400 mr-2'}>
<div css={tw`mb-2 flex justify-end items-center`}>
<p css={tw`uppercase text-xs text-neutral-400 mr-2`}>
{showAdmin ? 'Showing all servers' : 'Showing your servers'}
</p>
<Switch
@ -51,14 +52,16 @@ export default () => {
</div>
}
{loading ?
<Spinner centered={true} size={'large'}/>
<Spinner centered size={'large'}/>
:
servers.length > 0 ?
servers.map(server => (
<ServerRow key={server.uuid} server={server} className={'mt-2'}/>
servers.map((server, index) => (
<div key={server.uuid} css={index > 0 ? tw`mt-2` : undefined}>
<ServerRow server={server}/>
</div>
))
:
<p className={'text-center text-sm text-neutral-400'}>
<p css={tw`text-center text-sm text-neutral-400`}>
There are no servers associated with your account.
</p>
}

View file

@ -1,82 +0,0 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import ContentBox from '@/components/elements/ContentBox';
export default class DesignElementsContainer extends React.PureComponent {
render () {
return (
<React.Fragment>
<div className={'my-10'}>
<div className={'flex'}>
<ContentBox
className={'flex-1 mr-4'}
title={'A Special Announcement'}
borderColor={'border-primary-400'}
>
<p className={'text-neutral-200 text-sm'}>
Your demands have been received: Dark Mode will be default in Pterodactyl 0.8!
</p>
<p><Link to={'/'}>Back</Link></p>
</ContentBox>
<div className={'ml-4 flex-1'}>
<h2 className={'text-neutral-300 mb-2 px-4'}>Form Elements</h2>
<div className={'bg-neutral-700 p-4 rounded shadow-lg border-t-4 border-primary-400'}>
<label className={'uppercase text-neutral-200'}>Email</label>
<input type={'text'} className={'input-dark'}/>
<p className={'input-help'}>
This is some descriptive helper text to explain how things work.
</p>
<div className={'mt-6'}/>
<label className={'uppercase text-neutral-200'}>Username</label>
<input type={'text'} className={'input-dark error'}/>
<p className={'input-help'}>
This field has an error.
</p>
<div className={'mt-6'}/>
<label className={'uppercase text-neutral-200'}>Disabled Field</label>
<input type={'text'} className={'input-dark'} disabled={true}/>
<div className={'mt-6'}/>
<label className={'uppercase text-neutral-200'}>Select</label>
<select className={'input-dark'}>
<option>Option 1</option>
<option>Option 2</option>
<option>Option 3</option>
</select>
<div className={'mt-6'}/>
<label className={'uppercase text-neutral-200'}>Textarea</label>
<textarea className={'input-dark h-32'}></textarea>
<div className={'mt-6'}/>
<button className={'btn btn-primary btn-sm'}>
Blue
</button>
<button className={'btn btn-grey btn-sm ml-2'}>
Grey
</button>
<button className={'btn btn-green btn-sm ml-2'}>
Green
</button>
<button className={'btn btn-red btn-sm ml-2'}>
Red
</button>
<div className={'mt-6'}/>
<button className={'btn btn-secondary btn-sm'}>
Secondary
</button>
<button className={'btn btn-secondary btn-red btn-sm ml-2'}>
Secondary Danger
</button>
<div className={'mt-6'}/>
<button className={'btn btn-primary btn-lg'}>
Large
</button>
<button className={'btn btn-primary btn-xs ml-2'}>
Tiny
</button>
</div>
</div>
</div>
</div>
</React.Fragment>
);
}
}

View file

@ -1,16 +1,13 @@
import React, { useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faServer } from '@fortawesome/free-solid-svg-icons/faServer';
import { faEthernet } from '@fortawesome/free-solid-svg-icons/faEthernet';
import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory';
import { faHdd } from '@fortawesome/free-solid-svg-icons/faHdd';
import { faServer, faEthernet, faMicrochip, faMemory, faHdd } from '@fortawesome/free-solid-svg-icons';
import { Link } from 'react-router-dom';
import { Server } from '@/api/server/getServer';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage';
import { bytesToHuman } from '@/helpers';
import classNames from 'classnames';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
// Determines if the current value is in an alarm threshold so we can show it in red rather
// than the more faded default style.
@ -20,7 +17,7 @@ const isAlarmState = (current: number, limit: number): boolean => {
return current / limitInBytes >= 0.90;
};
export default ({ server, className }: { server: Server; className: string | undefined }) => {
export default ({ server }: { server: Server }) => {
const interval = useRef<number>(null);
const [ stats, setStats ] = useState<ServerStats | null>(null);
const [ statsError, setStatsError ] = useState(false);
@ -52,108 +49,111 @@ export default ({ server, className }: { server: Server; className: string | und
alarms.memory = isAlarmState(stats.memoryUsageInBytes, server.limits.memory);
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
}
const disklimit = server.limits.disk != 0 ? bytesToHuman(server.limits.disk * 1000 * 1000) : "Unlimited";
const memorylimit = server.limits.memory != 0 ? bytesToHuman(server.limits.memory * 1000 * 1000) : "Unlimited";
const disklimit = server.limits.disk !== 0 ? bytesToHuman(server.limits.disk * 1000 * 1000) : 'Unlimited';
const memorylimit = server.limits.memory !== 0 ? bytesToHuman(server.limits.memory * 1000 * 1000) : 'Unlimited';
return (
<Link to={`/server/${server.id}`} className={`grey-row-box cursor-pointer ${className}`}>
<GreyRowBox as={Link} to={`/server/${server.id}`}>
<div className={'icon'}>
<FontAwesomeIcon icon={faServer}/>
</div>
<div className={'flex-1 ml-4'}>
<p className={'text-lg'}>{server.name}</p>
<div css={tw`flex-1 ml-4`}>
<p css={tw`text-lg`}>{server.name}</p>
</div>
<div className={'w-1/4 overflow-hidden'}>
<div className={'flex ml-4'}>
<FontAwesomeIcon icon={faEthernet} className={'text-neutral-500'}/>
<p className={'text-sm text-neutral-400 ml-2'}>
<div css={tw`w-1/4 overflow-hidden`}>
<div css={tw`flex ml-4`}>
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
<p css={tw`text-sm text-neutral-400 ml-2`}>
{
server.allocations.filter(alloc => alloc.default).map(allocation => (
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
</div>
</div>
<div className={'w-1/3 flex items-baseline relative'}>
<div css={tw`w-1/3 flex items-baseline relative`}>
{!stats ?
!statsError ?
<SpinnerOverlay size={'tiny'} visible={true} backgroundOpacity={0.25}/>
<SpinnerOverlay size={'small'} visible backgroundOpacity={0.25}/>
:
server.isInstalling ?
<div className={'flex-1 text-center'}>
<span className={'bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs'}>
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
Installing
</span>
</div>
:
<div className={'flex-1 text-center'}>
<span className={'bg-red-500 rounded px-2 py-1 text-red-100 text-xs'}>
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}>
{server.isSuspended ? 'Suspended' : 'Connection Error'}
</span>
</div>
:
<React.Fragment>
<div className={'flex-1 flex ml-4 justify-center'}>
<div css={tw`flex-1 flex ml-4 justify-center`}>
<FontAwesomeIcon
icon={faMicrochip}
className={classNames({
'text-neutral-500': !alarms.cpu,
'text-red-400': alarms.cpu,
})}
css={[
!alarms.cpu && tw`text-neutral-500`,
alarms.cpu && tw`text-red-400`,
]}
/>
<p
className={classNames('text-sm ml-2', {
'text-neutral-400': !alarms.cpu,
'text-white': alarms.cpu,
})}
css={[
tw`text-sm ml-2`,
!alarms.cpu && tw`text-neutral-400`,
alarms.cpu && tw`text-white`,
]}
>
{stats.cpuUsagePercent} %
</p>
</div>
<div className={'flex-1 ml-4'}>
<div className={'flex justify-center'}>
<div css={tw`flex-1 ml-4`}>
<div css={tw`flex justify-center`}>
<FontAwesomeIcon
icon={faMemory}
className={classNames({
'text-neutral-500': !alarms.memory,
'text-red-400': alarms.memory,
})}
css={[
!alarms.memory && tw`text-neutral-500`,
alarms.memory && tw`text-red-400`,
]}
/>
<p
className={classNames('text-sm ml-2', {
'text-neutral-400': !alarms.memory,
'text-white': alarms.memory,
})}
css={[
tw`text-sm ml-2`,
!alarms.memory && tw`text-neutral-400`,
alarms.memory && tw`text-white`,
]}
>
{bytesToHuman(stats.memoryUsageInBytes)}
</p>
</div>
<p className={'text-xs text-neutral-600 text-center mt-1'}>of {memorylimit}</p>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memorylimit}</p>
</div>
<div className={'flex-1 ml-4'}>
<div className={'flex justify-center'}>
<div css={tw`flex-1 ml-4`}>
<div css={tw`flex justify-center`}>
<FontAwesomeIcon
icon={faHdd}
className={classNames({
'text-neutral-500': !alarms.disk,
'text-red-400': alarms.disk,
})}
css={[
!alarms.disk && tw`text-neutral-500`,
alarms.disk && tw`text-red-400`,
]}
/>
<p
className={classNames('text-sm ml-2', {
'text-neutral-400': !alarms.disk,
'text-white': alarms.disk,
})}
css={[
tw`text-sm ml-2`,
!alarms.disk && tw`text-neutral-400`,
alarms.disk && tw`text-white`,
]}
>
{bytesToHuman(stats.diskUsageInBytes)}
</p>
</div>
<p className={'text-xs text-neutral-600 text-center mt-1'}>of {disklimit}</p>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {disklimit}</p>
</div>
</React.Fragment>
}
</div>
</Link>
</GreyRowBox>
);
};

View file

@ -3,6 +3,8 @@ import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import SetupTwoFactorModal from '@/components/dashboard/forms/SetupTwoFactorModal';
import DisableTwoFactorModal from '@/components/dashboard/forms/DisableTwoFactorModal';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!);
@ -12,43 +14,45 @@ export default () => {
<div>
{visible &&
<DisableTwoFactorModal
appear={true}
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
}
<p className={'text-sm'}>
<p css={tw`text-sm`}>
Two-factor authentication is currently enabled on your account.
</p>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
color={'red'}
isSecondary
onClick={() => setVisible(true)}
className={'btn btn-red btn-secondary btn-sm'}
>
Disable
</button>
</Button>
</div>
</div>
:
<div>
{visible &&
<SetupTwoFactorModal
appear={true}
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
}
<p className={'text-sm'}>
<p css={tw`text-sm`}>
You do not currently have two-factor authentication enabled on your account. Click
the button below to begin configuring it.
</p>
<div className={'mt-6'}>
<button
<div css={tw`mt-6`}>
<Button
color={'green'}
isSecondary
onClick={() => setVisible(true)}
className={'btn btn-green btn-secondary btn-sm'}
>
Begin Setup
</button>
</Button>
</div>
</div>
;

View file

@ -9,6 +9,9 @@ import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ApiKey } from '@/api/account/getApiKeys';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Input, { Textarea } from '@/components/elements/Input';
interface Values {
description: string;
@ -44,22 +47,21 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
closeOnEscape={false}
closeOnBackground={false}
>
<h3 className={'mb-6'}>Your API Key</h3>
<p className={'text-sm mb-6'}>
<h3 css={tw`mb-6`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<pre className={'text-sm bg-neutral-900 rounded py-2 px-4 font-mono'}>
<code className={'font-mono'}>{apiKey}</code>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
</pre>
<div className={'flex justify-end mt-6'}>
<button
<div css={tw`flex justify-end mt-6`}>
<Button
type={'button'}
className={'btn btn-secondary btn-sm'}
onClick={() => setApiKey('')}
>
Close
</button>
</Button>
</div>
</Modal>
<Formik
@ -80,25 +82,19 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
label={'Description'}
name={'description'}
description={'A description of this API key.'}
className={'mb-6'}
css={tw`mb-6`}
>
<Field name={'description'} className={'input-dark'}/>
<Field name={'description'} as={Input}/>
</FormikFieldWrapper>
<FormikFieldWrapper
label={'Allowed IPs'}
name={'allowedIps'}
description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'}
>
<Field
as={'textarea'}
name={'allowedIps'}
className={'input-dark h-32'}
/>
<Field as={Textarea} name={'allowedIps'} css={tw`h-32`}/>
</FormikFieldWrapper>
<div className={'flex justify-end mt-6'}>
<button className={'btn btn-primary btn-sm'}>
Create
</button>
<div css={tw`flex justify-end mt-6`}>
<Button>Create</Button>
</div>
</Form>
)}

View file

@ -8,6 +8,8 @@ import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor';
import { httpErrorToHuman } from '@/api/http';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
password: string;
@ -45,19 +47,19 @@ export default ({ ...props }: RequiredModalProps) => {
{({ isSubmitting, isValid }) => (
<Modal {...props} dismissable={!isSubmitting} showSpinnerOverlay={isSubmitting}>
<Form className={'mb-0'}>
<FlashMessageRender className={'mb-6'} byKey={'account:two-factor'}/>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
<Field
id={'password'}
name={'password'}
type={'password'}
label={'Current Password'}
description={'In order to disable two-factor authentication you will need to provide your account password.'}
autoFocus={true}
autoFocus
/>
<div className={'mt-6 text-right'}>
<button className={'btn btn-red btn-sm'} disabled={!isValid}>
<div css={tw`mt-6 text-right`}>
<Button disabled={!isValid}>
Disable Two-Factor
</button>
</Button>
</div>
</Form>
</Modal>

View file

@ -9,6 +9,8 @@ import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
code: string;
@ -64,7 +66,7 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
.matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'),
})}
>
{({ isSubmitting, isValid }) => (
{({ isSubmitting }) => (
<Modal
{...props}
onDismissed={dismiss}
@ -75,47 +77,47 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
>
{recoveryTokens.length > 0 ?
<>
<h2 className={'mb-4'}>Two-factor authentication enabled</h2>
<p className={'text-neutral-300'}>
<h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2>
<p css={tw`text-neutral-300`}>
Two-factor authentication has been enabled on your account. Should you loose access to
this device you'll need to use on of the codes displayed below in order to access your
this device you&apos;ll need to use on of the codes displayed below in order to access your
account.
</p>
<p className={'text-neutral-300 mt-4'}>
<p css={tw`text-neutral-300 mt-4`}>
<strong>These codes will not be displayed again.</strong> Please take note of them now
by storing them in a secure repository such as a password manager.
</p>
<pre className={'mt-4 rounded font-mono bg-neutral-900 p-4'}>
{recoveryTokens.map(token => <code key={token} className={'block mb-1'}>{token}</code>)}
<pre css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}>
{recoveryTokens.map(token => <code key={token} css={tw`block mb-1`}>{token}</code>)}
</pre>
<div className={'text-right'}>
<button className={'mt-6 btn btn-lg btn-primary'} onClick={dismiss}>
<div css={tw`text-right`}>
<Button css={tw`mt-6`} size={'large'} onClick={dismiss}>
Close
</button>
</Button>
</div>
</>
:
<Form className={'mb-0'}>
<FlashMessageRender className={'mb-6'} byKey={'account:two-factor'}/>
<div className={'flex flex-wrap'}>
<div className={'w-full md:flex-1'}>
<div className={'w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto'}>
<Form css={tw`mb-0`}>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
<div css={tw`flex flex-wrap`}>
<div css={tw`w-full md:flex-1`}>
<div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}>
{!token || !token.length ?
<img
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='}
className={'w-64 h-64 rounded'}
css={tw`w-64 h-64 rounded`}
/>
:
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${token}`}
onLoad={() => setLoading(false)}
className={'w-full h-full shadow-none rounded-0'}
css={tw`w-full h-full shadow-none rounded-none`}
/>
}
</div>
</div>
<div className={'w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col'}>
<div className={'flex-1'}>
<div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}>
<div css={tw`flex-1`}>
<Field
id={'code'}
name={'code'}
@ -125,10 +127,10 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
autoFocus={!loading}
/>
</div>
<div className={'mt-6 md:mt-0 text-right'}>
<button className={'btn btn-primary btn-sm'} disabled={!isValid}>
<div css={tw`mt-6 md:mt-0 text-right`}>
<Button>
Setup
</button>
</Button>
</div>
</div>
</div>

View file

@ -6,6 +6,8 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Field from '@/components/elements/Field';
import { httpErrorToHuman } from '@/api/http';
import { ApplicationStore } from '@/state';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
email: string;
@ -54,14 +56,14 @@ export default () => {
({ isSubmitting, isValid }) => (
<React.Fragment>
<SpinnerOverlay size={'large'} visible={isSubmitting}/>
<Form className={'m-0'}>
<Form css={tw`m-0`}>
<Field
id={'current_email'}
type={'email'}
name={'email'}
label={'Email'}
/>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
id={'confirm_password'}
type={'password'}
@ -69,10 +71,10 @@ export default () => {
label={'Confirm Password'}
/>
</div>
<div className={'mt-6'}>
<button className={'btn btn-sm btn-primary'} disabled={isSubmitting || !isValid}>
<div css={tw`mt-6`}>
<Button size={'small'} disabled={isSubmitting || !isValid}>
Update Email
</button>
</Button>
</div>
</Form>
</React.Fragment>

View file

@ -7,6 +7,8 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import updateAccountPassword from '@/api/account/updateAccountPassword';
import { httpErrorToHuman } from '@/api/http';
import { ApplicationStore } from '@/state';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
current: string;
@ -30,7 +32,7 @@ export default () => {
return null;
}
const submit = (values: Values, { resetForm, setSubmitting }: FormikHelpers<Values>) => {
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('account:password');
updateAccountPassword({ ...values })
.then(() => {
@ -57,14 +59,14 @@ export default () => {
({ isSubmitting, isValid }) => (
<React.Fragment>
<SpinnerOverlay size={'large'} visible={isSubmitting}/>
<Form className={'m-0'}>
<Form css={tw`m-0`}>
<Field
id={'current_password'}
type={'password'}
name={'current'}
label={'Current Password'}
/>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
id={'new_password'}
type={'password'}
@ -73,7 +75,7 @@ export default () => {
description={'Your new password should be at least 8 characters in length and unique to this website.'}
/>
</div>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
id={'confirm_password'}
type={'password'}
@ -81,10 +83,10 @@ export default () => {
label={'Confirm New Password'}
/>
</div>
<div className={'mt-6'}>
<button className={'btn btn-primary btn-sm'} disabled={isSubmitting || !isValid}>
<div css={tw`mt-6`}>
<Button size={'small'} disabled={isSubmitting || !isValid}>
Update Password
</button>
</Button>
</div>
</Form>
</React.Fragment>

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import { faSearch } from '@fortawesome/free-solid-svg-icons';
import useEventListener from '@/plugins/useEventListener';
import SearchModal from '@/components/dashboard/search/SearchModal';
@ -19,7 +19,7 @@ export default () => {
<>
{visible &&
<SearchModal
appear={true}
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>

View file

@ -3,7 +3,7 @@ import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { object, string } from 'yup';
import { debounce } from 'lodash-es';
import debounce from 'debounce';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import InputSpinner from '@/components/elements/InputSpinner';
import getServers from '@/api/getServers';
@ -11,7 +11,9 @@ import { Server } from '@/api/server/getServer';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Input from '@/components/elements/Input';
type Props = RequiredModalProps;
@ -20,8 +22,7 @@ interface Values {
}
const ServerResult = styled(Link)`
${tw`flex items-center bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline`};
transition: all 250ms linear;
${tw`flex items-center bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline transition-all duration-150`};
&:hover {
${tw`shadow border-cyan-500`};
@ -55,6 +56,7 @@ export default ({ ...props }: Props) => {
setLoading(true);
setSubmitting(false);
clearFlashes('search');
getServers(term)
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
.catch(error => {
@ -93,16 +95,12 @@ export default ({ ...props }: Props) => {
>
<SearchWatcher/>
<InputSpinner visible={loading}>
<Field
innerRef={ref}
name={'term'}
className={'input-dark'}
/>
<Field as={Input} innerRef={ref} name={'term'}/>
</InputSpinner>
</FormikFieldWrapper>
</Form>
{servers.length > 0 &&
<div className={'mt-6'}>
<div css={tw`mt-6`}>
{
servers.map(server => (
<ServerResult
@ -111,17 +109,17 @@ export default ({ ...props }: Props) => {
onClick={() => props.onDismissed()}
>
<div>
<p className={'text-sm'}>{server.name}</p>
<p className={'mt-1 text-xs text-neutral-400'}>
<p css={tw`text-sm`}>{server.name}</p>
<p css={tw`mt-1 text-xs text-neutral-400`}>
{
server.allocations.filter(alloc => alloc.default).map(allocation => (
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
</div>
<div className={'flex-1 text-right'}>
<span className={'text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded'}>
<div css={tw`flex-1 text-right`}>
<span css={tw`text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded`}>
{server.node}
</span>
</div>

View file

@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState, lazy } from 'react';
import useRouter from 'use-react-router';
import { ServerContext } from '@/state/server';
import React, { useCallback, useEffect, useState } from 'react';
import ace, { Editor } from 'brace';
import getFileContents from '@/api/server/files/getFileContents';
import styled from 'styled-components';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Select from '@/components/elements/Select';
// @ts-ignore
import modes from '@/modes';
// @ts-ignore
require('brace/ext/modelist');
@ -11,7 +12,7 @@ require('ayu-ace/mirage');
const EditorContainer = styled.div`
min-height: 16rem;
height: calc(100vh - 16rem);
height: calc(100vh - 20rem);
${tw`relative`};
#editor {
@ -19,35 +20,6 @@ const EditorContainer = styled.div`
}
`;
const modes: { [k: string]: string } = {
// eslint-disable-next-line @typescript-eslint/camelcase
assembly_x86: 'Assembly (x86)',
// eslint-disable-next-line @typescript-eslint/camelcase
c_cpp: 'C++',
coffee: 'Coffeescript',
css: 'CSS',
dockerfile: 'Dockerfile',
golang: 'Go',
html: 'HTML',
ini: 'Ini',
java: 'Java',
javascript: 'Javascript',
json: 'JSON',
kotlin: 'Kotlin',
lua: 'Luascript',
perl: 'Perl',
php: 'PHP',
properties: 'Properties',
python: 'Python',
ruby: 'Ruby',
// eslint-disable-next-line @typescript-eslint/camelcase
plain_text: 'Plaintext',
toml: 'TOML',
typescript: 'Typescript',
xml: 'XML',
yaml: 'YAML',
};
Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`));
export interface Props {
@ -70,7 +42,7 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
useEffect(() => {
editor && editor.session.setMode(mode);
}, [editor, mode]);
}, [ editor, mode ]);
useEffect(() => {
editor && editor.session.setValue(initialContent || '');
@ -113,19 +85,18 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
return (
<EditorContainer style={style}>
<div id={'editor'} ref={ref}/>
<div className={'absolute pin-r pin-b z-50'}>
<div className={'m-3 rounded bg-neutral-900 border border-black'}>
<select
className={'input-dark'}
<div css={tw`absolute right-0 bottom-0 z-50`}>
<div css={tw`m-3 rounded bg-neutral-900 border border-black`}>
<Select
value={mode.split('/').pop()}
onChange={e => setMode(`ace/mode/${e.currentTarget.value}`)}
>
{
Object.keys(modes).map(key => (
<option key={key} value={key}>{modes[key]}</option>
<option key={key} value={key}>{(modes as { [k: string]: string })[key]}</option>
))
}
</select>
</Select>
</div>
</div>
</EditorContainer>

View file

@ -1,20 +1,99 @@
import React from 'react';
import classNames from 'classnames';
import styled, { css } from 'styled-components/macro';
import tw from 'twin.macro';
import Spinner from '@/components/elements/Spinner';
type Props = { isLoading?: boolean } & React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
interface Props {
isLoading?: boolean;
size?: 'xsmall' | 'small' | 'large' | 'xlarge';
color?: 'green' | 'red' | 'primary' | 'grey';
isSecondary?: boolean;
}
export default ({ isLoading, children, className, ...props }: Props) => (
<button
{...props}
className={classNames('btn btn-sm relative', className)}
>
const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
${tw`relative inline-block rounded p-2 uppercase tracking-wide text-sm transition-all duration-150 border`};
${props => ((!props.isSecondary && !props.color) || props.color === 'primary') && css<Props>`
${props => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`};
&:hover:not(:disabled) {
${tw`bg-primary-600 border-primary-700`};
}
`};
${props => props.color === 'grey' && css`
${tw`border-neutral-600 bg-neutral-500 text-neutral-50`};
&:hover:not(:disabled) {
${tw`bg-neutral-600 border-neutral-700`};
}
`};
${props => props.color === 'green' && css<Props>`
${tw`border-green-600 bg-green-500 text-green-50`};
&:hover:not(:disabled) {
${tw`bg-green-600 border-green-700`};
}
${props => props.isSecondary && css`
&:active:not(:disabled) {
${tw`bg-green-600 border-green-700`};
}
`};
`};
${props => props.color === 'red' && css<Props>`
${tw`border-red-600 bg-red-500 text-red-50`};
&:hover:not(:disabled) {
${tw`bg-red-600 border-red-700`};
}
${props => props.isSecondary && css`
&:active:not(:disabled) {
${tw`bg-red-600 border-red-700`};
}
`};
`};
${props => props.size === 'xsmall' && tw`p-2 text-xs`};
${props => (!props.size || props.size === 'small') && tw`p-3`};
${props => props.size === 'large' && tw`p-4 text-sm`};
${props => props.size === 'xlarge' && tw`p-4 w-full`};
${props => props.isSecondary && css<Props>`
${tw`border-neutral-600 bg-transparent text-neutral-200`};
&:hover:not(:disabled) {
${tw`border-neutral-500 text-neutral-100`};
${props => props.color === 'red' && tw`bg-red-500 border-red-600 text-red-50`};
${props => props.color === 'primary' && tw`bg-primary-500 border-primary-600 text-primary-50`};
${props => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`};
}
`};
&:disabled { opacity: 0.55; cursor: default }
`;
type ComponentProps = Omit<JSX.IntrinsicElements['button'], 'ref' | keyof Props> & Props;
const Button: React.FC<ComponentProps> = ({ children, isLoading, ...props }) => (
<ButtonStyle {...props}>
{isLoading &&
<div className={'w-full flex absolute justify-center'} style={{ marginLeft: '-0.75rem' }}>
<div className={'spinner-circle spinner-white spinner-sm'}/>
<div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
<Spinner size={'small'}/>
</div>
}
<span className={isLoading ? 'text-transparent' : undefined}>
<span css={isLoading ? tw`text-transparent` : undefined}>
{children}
</span>
</button>
</ButtonStyle>
);
type LinkProps = Omit<JSX.IntrinsicElements['a'], 'ref' | keyof Props> & Props;
const LinkButton: React.FC<LinkProps> = props => <ButtonStyle as={'a'} {...props}/>;
export { LinkButton, ButtonStyle };
export default Button;

View file

@ -1,14 +1,15 @@
import React from 'react';
import { Field, FieldProps } from 'formik';
import Input from '@/components/elements/Input';
interface Props {
name: string;
value: string;
}
type OmitFields = 'name' | 'value' | 'type' | 'checked' | 'onChange';
type OmitFields = 'ref' | 'name' | 'value' | 'type' | 'checked' | 'onClick' | 'onChange';
type InputProps = Omit<React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, OmitFields>;
type InputProps = Omit<JSX.IntrinsicElements['input'], OmitFields>;
const Checkbox = ({ name, value, ...props }: Props & InputProps) => (
<Field name={name}>
@ -20,7 +21,7 @@ const Checkbox = ({ name, value, ...props }: Props & InputProps) => (
}
return (
<input
<Input
{...field}
{...props}
type={'checkbox'}

View file

@ -1,5 +1,7 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
type Props = {
title: string;
@ -16,15 +18,15 @@ const ConfirmationModal = ({ title, appear, children, visible, buttonText, onCon
showSpinnerOverlay={showSpinnerOverlay}
onDismissed={() => onDismissed()}
>
<h3 className={'mb-6'}>{title}</h3>
<p className={'text-sm'}>{children}</p>
<div className={'flex items-center justify-end mt-8'}>
<button className={'btn btn-secondary btn-sm'} onClick={() => onDismissed()}>
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p>
<div css={tw`flex items-center justify-end mt-8`}>
<Button isSecondary onClick={() => onDismissed()}>
Cancel
</button>
<button className={'btn btn-red btn-sm ml-4'} onClick={() => onConfirmed()}>
</Button>
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
{buttonText}
</button>
</Button>
</div>
</Modal>
);

View file

@ -1,7 +1,7 @@
import * as React from 'react';
import classNames from 'classnames';
import React from 'react';
import FlashMessageRender from '@/components/FlashMessageRender';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import tw from 'twin.macro';
type Props = Readonly<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
title?: string;
@ -12,16 +12,19 @@ type Props = Readonly<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElemen
const ContentBox = ({ title, borderColor, showFlashes, showLoadingOverlay, children, ...props }: Props) => (
<div {...props}>
{title && <h2 className={'text-neutral-300 mb-4 px-4'}>{title}</h2>}
{title && <h2 css={tw`text-neutral-300 mb-4 px-4 text-2xl`}>{title}</h2>}
{showFlashes &&
<FlashMessageRender
byKey={typeof showFlashes === 'string' ? showFlashes : undefined}
className={'mb-4'}
css={tw`mb-4`}
/>
}
<div className={classNames('bg-neutral-700 p-4 rounded shadow-lg relative', borderColor, {
'border-t-4': !!borderColor,
})}>
<div
css={[
tw`bg-neutral-700 p-4 rounded shadow-lg relative`,
!!borderColor && tw`border-t-4`,
]}
>
<SpinnerOverlay visible={showLoadingOverlay || false}/>
{children}
</div>

View file

@ -1,5 +1,6 @@
import styled from 'styled-components';
import { breakpoint } from 'styled-components-breakpoint';
import styled from 'styled-components/macro';
import { breakpoint } from '@/theme';
import tw from 'twin.macro';
const ContentContainer = styled.div`
max-width: 1200px;
@ -9,5 +10,6 @@ const ContentContainer = styled.div`
${tw`mx-auto`};
`};
`;
ContentContainer.displayName = 'ContentContainer';
export default ContentContainer;

View file

@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { CSSTransition } from 'react-transition-group';
import styled from 'styled-components';
import React, { createRef } from 'react';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Fade from '@/components/elements/Fade';
interface Props {
children: React.ReactNode;
@ -12,76 +13,95 @@ export const DropdownButtonRow = styled.button<{ danger?: boolean }>`
transition: 150ms all ease;
&:hover {
${props => props.danger
? tw`text-red-700 bg-red-100`
: tw`text-neutral-700 bg-neutral-100`
};
${props => props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`};
}
`;
const DropdownMenu = ({ renderToggle, children }: Props) => {
const menu = useRef<HTMLDivElement>(null);
const [ posX, setPosX ] = useState(0);
const [ visible, setVisible ] = useState(false);
interface State {
posX: number;
visible: boolean;
}
const onClickHandler = (e: React.MouseEvent<any, MouseEvent>) => {
class DropdownMenu extends React.PureComponent<Props, State> {
menu = createRef<HTMLDivElement>();
state: State = {
posX: 0,
visible: false,
};
componentWillUnmount () {
this.removeListeners();
}
componentDidUpdate (prevProps: Readonly<Props>, prevState: Readonly<State>) {
const menu = this.menu.current;
if (this.state.visible && !prevState.visible && menu) {
document.addEventListener('click', this.windowListener);
document.addEventListener('contextmenu', this.contextMenuListener);
menu.setAttribute(
'style', `left: ${Math.round(this.state.posX - menu.clientWidth)}px`,
);
}
if (!this.state.visible && prevState.visible) {
this.removeListeners();
}
}
removeListeners = () => {
document.removeEventListener('click', this.windowListener);
document.removeEventListener('contextmenu', this.contextMenuListener);
};
onClickHandler = (e: React.MouseEvent<any, MouseEvent>) => {
e.preventDefault();
!visible && setPosX(e.clientX);
setVisible(s => !s);
this.triggerMenu(e.clientX);
};
const windowListener = (e: MouseEvent) => {
if (e.button === 2 || !visible || !menu.current) {
contextMenuListener = () => this.setState({ visible: false });
windowListener = (e: MouseEvent) => {
const menu = this.menu.current;
if (e.button === 2 || !this.state.visible || !menu) {
return;
}
if (e.target === menu.current || menu.current.contains(e.target as Node)) {
if (e.target === menu || menu.contains(e.target as Node)) {
return;
}
if (e.target !== menu.current && !menu.current.contains(e.target as Node)) {
setVisible(false);
if (e.target !== menu && !menu.contains(e.target as Node)) {
this.setState({ visible: false });
}
};
useEffect(() => {
if (!visible || !menu.current) {
return;
}
triggerMenu = (posX: number) => this.setState(s => ({
posX: !s.visible ? posX : s.posX,
visible: !s.visible,
}));
document.addEventListener('click', windowListener);
menu.current.setAttribute(
'style', `left: ${Math.round(posX - menu.current.clientWidth)}px`,
render () {
return (
<div>
{this.props.renderToggle(this.onClickHandler)}
<Fade timeout={150} in={this.state.visible} unmountOnExit>
<div
ref={this.menu}
onClick={e => {
e.stopPropagation();
this.setState({ visible: false });
}}
css={tw`absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48`}
>
{this.props.children}
</div>
</Fade>
</div>
);
return () => {
document.removeEventListener('click', windowListener);
}
}, [ visible ]);
return (
<div>
{renderToggle(onClickHandler)}
<CSSTransition
timeout={250}
in={visible}
unmountOnExit={true}
classNames={'fade'}
>
<div
ref={menu}
onClick={e => {
e.stopPropagation();
setVisible(false);
}}
className={'absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48'}
>
{children}
</div>
</CSSTransition>
</div>
);
};
}
}
export default DropdownMenu;

View file

@ -0,0 +1,43 @@
import React from 'react';
import tw from 'twin.macro';
import styled from 'styled-components/macro';
import CSSTransition, { CSSTransitionProps } from 'react-transition-group/CSSTransition';
interface Props extends Omit<CSSTransitionProps, 'timeout' | 'classNames'> {
timeout: number;
}
const Container = styled.div<{ timeout: number }>`
.fade-enter, .fade-exit {
will-change: opacity;
}
.fade-enter {
${tw`opacity-0`};
&.fade-enter-active {
${tw`opacity-100 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms;
}
}
.fade-exit {
${tw`opacity-100`};
&.fade-exit-active {
${tw`opacity-0 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms;
}
}
`;
const Fade: React.FC<Props> = ({ timeout, children, ...props }) => (
<Container timeout={timeout}>
<CSSTransition timeout={timeout} classNames={'fade'} {...props}>
{children}
</CSSTransition>
</Container>
);
Fade.displayName = 'Fade';
export default Fade;

View file

@ -1,6 +1,7 @@
import React from 'react';
import React, { forwardRef } from 'react';
import { Field as FormikField, FieldProps } from 'formik';
import classNames from 'classnames';
import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
interface OwnProps {
name: string;
@ -12,21 +13,20 @@ interface OwnProps {
type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>;
const Field = ({ id, name, light = false, label, description, validate, className, ...props }: Props) => (
<FormikField name={name} validate={validate}>
const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, label, description, validate, ...props }, ref) => (
<FormikField innerRef={ref} name={name} validate={validate}>
{
({ field, form: { errors, touched } }: FieldProps) => (
<React.Fragment>
<>
{label &&
<label htmlFor={id} className={light ? undefined : 'input-dark-label'}>{label}</label>
<Label htmlFor={id} isLight={light}>{label}</Label>
}
<input
<Input
id={id}
{...field}
{...props}
className={classNames((className || (light ? 'input' : 'input-dark')), {
error: touched[field.name] && errors[field.name],
})}
isLight={light}
hasError={!!(touched[field.name] && errors[field.name])}
/>
{touched[field.name] && errors[field.name] ?
<p className={'input-help error'}>
@ -35,10 +35,11 @@ const Field = ({ id, name, light = false, label, description, validate, classNam
:
description ? <p className={'input-help'}>{description}</p> : null
}
</React.Fragment>
</>
)
}
</FormikField>
);
));
Field.displayName = 'Field';
export default Field;

View file

@ -1,7 +1,7 @@
import React from 'react';
import { Field, FieldProps } from 'formik';
import classNames from 'classnames';
import InputError from '@/components/elements/InputError';
import Label from '@/components/elements/Label';
interface Props {
id?: string;
@ -17,11 +17,11 @@ const FormikFieldWrapper = ({ id, name, label, className, description, validate,
<Field name={name} validate={validate}>
{
({ field, form: { errors, touched } }: FieldProps) => (
<div className={classNames(className, { 'has-error': touched[field.name] && errors[field.name] })}>
{label && <label htmlFor={id} className={'input-dark-label'}>{label}</label>}
<div className={`${className} ${(touched[field.name] && errors[field.name]) ? 'has-error' : undefined}`}>
{label && <Label htmlFor={id}>{label}</Label>}
{children}
<InputError errors={errors} touched={touched} name={field.name}>
{description ? <p className={'input-help'}>{description}</p> : null}
{description || null}
</InputError>
</div>
)

View file

@ -0,0 +1,12 @@
import styled from 'styled-components/macro';
import tw from 'twin.macro';
export default styled.div<{ $hoverable?: boolean }>`
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150`};
${props => props.$hoverable !== false && tw`hover:border-neutral-500`};
& > div.icon {
${tw`rounded-full bg-neutral-500 p-3`};
}
`;

View file

@ -0,0 +1,81 @@
import styled, { css } from 'styled-components/macro';
import tw from 'twin.macro';
export interface Props {
isLight?: boolean;
hasError?: boolean;
}
const light = css<Props>`
${tw`bg-white border-neutral-200 text-neutral-800`};
&:focus { ${tw`border-primary-400`} }
&:disabled {
${tw`bg-neutral-100 border-neutral-200`};
}
`;
const checkboxStyle = css<Props>`
${tw`cursor-pointer appearance-none inline-block align-middle select-none flex-shrink-0 w-4 h-4 text-primary-400 border border-neutral-300 rounded-sm`};
color-adjust: exact;
background-origin: border-box;
transition: all 75ms linear, box-shadow 25ms linear;
&:checked {
${tw`border-transparent bg-no-repeat bg-center`};
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.707 7.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L7 8.586 5.707 7.293z'/%3e%3c/svg%3e");
background-color: currentColor;
background-size: 100% 100%;
}
&:focus {
${tw`outline-none border-primary-300`};
box-shadow: 0 0 0 1px rgba(9, 103, 210, 0.25);
}
`;
const inputStyle = css<Props>`
// Reset to normal styling.
resize: none;
${tw`appearance-none outline-none w-full min-w-0`};
${tw`p-3 border rounded text-sm transition-all duration-150`};
${tw`bg-neutral-600 border-neutral-500 hover:border-neutral-400 text-neutral-200 shadow-none`};
& + .input-help {
${tw`mt-1 text-xs`};
${props => props.hasError ? tw`text-red-400` : tw`text-neutral-400`};
}
&:required, &:invalid {
${tw`shadow-none`};
}
&:not(:disabled):not(:read-only):focus {
${tw`shadow-md border-primary-400`};
}
&:disabled {
${tw`opacity-75`};
}
${props => props.isLight && light};
${props => props.hasError && tw`text-red-600 border-red-500 hover:border-red-600`};
`;
const Input = styled.input<Props>`
&:not([type="checkbox"]):not([type="radio"]) {
${inputStyle};
}
&[type="checkbox"], &[type="radio"] {
${checkboxStyle};
&[type="radio"] {
${tw`rounded-full`};
}
}
`;
const Textarea = styled.textarea<Props>`${inputStyle}`;
export { Textarea };
export default Input;

View file

@ -1,17 +1,18 @@
import React from 'react';
import capitalize from 'lodash-es/capitalize';
import { FormikErrors, FormikTouched } from 'formik';
import tw from 'twin.macro';
import { capitalize } from '@/helpers';
interface Props {
errors: FormikErrors<any>;
touched: FormikTouched<any>;
name: string;
children?: React.ReactNode;
children?: string | number | null | undefined;
}
const InputError = ({ errors, touched, name, children }: Props) => (
touched[name] && errors[name] ?
<p className={'input-help error'}>
<p css={tw`text-xs text-red-400 pt-2`}>
{typeof errors[name] === 'string' ?
capitalize(errors[name] as string)
:
@ -19,9 +20,9 @@ const InputError = ({ errors, touched, name, children }: Props) => (
}
</p>
:
<React.Fragment>
{children}
</React.Fragment>
<>
{children ? <p css={tw`text-xs text-neutral-400 pt-2`}>{children}</p> : null}
</>
);
export default InputError;

View file

@ -1,20 +1,20 @@
import React from 'react';
import Spinner from '@/components/elements/Spinner';
import { CSSTransition } from 'react-transition-group';
import Fade from '@/components/elements/Fade';
import tw from 'twin.macro';
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
<div className={'relative'}>
<CSSTransition
timeout={250}
<div css={tw`relative`}>
<Fade
appear
unmountOnExit
in={visible}
unmountOnExit={true}
appear={true}
classNames={'fade'}
timeout={150}
>
<div className={'absolute pin-r h-full flex items-center justify-end pr-3'}>
<Spinner size={'tiny'}/>
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
<Spinner size={'small'}/>
</div>
</CSSTransition>
</Fade>
{children}
</div>
);

View file

@ -0,0 +1,9 @@
import styled from 'styled-components/macro';
import tw from 'twin.macro';
const Label = styled.label<{ isLight?: boolean }>`
${tw`block text-xs uppercase text-neutral-200 mb-2`};
${props => props.isLight && tw`text-neutral-700`};
`;
export default Label;

View file

@ -1,9 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes';
import { CSSTransition } from 'react-transition-group';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import Spinner from '@/components/elements/Spinner';
import classNames from 'classnames';
import tw from 'twin.macro';
import styled from 'styled-components/macro';
import Fade from '@/components/elements/Fade';
export interface RequiredModalProps {
visible: boolean;
@ -12,20 +13,39 @@ export interface RequiredModalProps {
top?: boolean;
}
type Props = RequiredModalProps & {
interface Props extends RequiredModalProps {
dismissable?: boolean;
closeOnEscape?: boolean;
closeOnBackground?: boolean;
showSpinnerOverlay?: boolean;
children: React.ReactNode;
}
export default ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }: Props) => {
const [render, setRender] = useState(visible);
const ModalMask = styled.div`
${tw`fixed z-50 overflow-auto flex w-full inset-0`};
background: rgba(0, 0, 0, 0.70);
`;
const ModalContainer = styled.div<{ alignTop?: boolean }>`
${tw`relative flex flex-col w-full m-auto`};
max-height: calc(100vh - 8rem);
max-width: 50%;
// @todo max-w-screen-lg perhaps?
${props => props.alignTop && 'margin-top: 10%'};
& > .close-icon {
${tw`absolute right-0 p-2 text-white cursor-pointer opacity-50 transition-all duration-150 ease-linear hover:opacity-100`};
top: -2rem;
&:hover {${tw`transform rotate-90`}}
}
`;
const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => {
const [ render, setRender ] = useState(visible);
const isDismissable = useMemo(() => {
return (dismissable || true) && !(showSpinnerOverlay || false);
}, [dismissable, showSpinnerOverlay]);
}, [ dismissable, showSpinnerOverlay ]);
const handleEscapeEvent = (e: KeyboardEvent) => {
if (isDismissable && closeOnEscape && e.key === 'Escape') {
@ -33,52 +53,47 @@ export default ({ visible, appear, dismissable, showSpinnerOverlay, top = true,
}
};
useEffect(() => {
setRender(visible);
}, [visible]);
useEffect(() => setRender(visible), [ visible ]);
useEffect(() => {
window.addEventListener('keydown', handleEscapeEvent);
return () => window.removeEventListener('keydown', handleEscapeEvent);
}, [render]);
}, [ render ]);
return (
<CSSTransition
timeout={250}
classNames={'fade'}
appear={appear}
in={render}
unmountOnExit={true}
onExited={() => onDismissed()}
>
<div className={'modal-mask'} onClick={e => {
if (isDismissable && closeOnBackground) {
e.stopPropagation();
if (e.target === e.currentTarget) {
setRender(false);
<Fade timeout={150} appear={appear} in={render} unmountOnExit onExited={onDismissed}>
<ModalMask
onClick={e => {
if (isDismissable && closeOnBackground) {
e.stopPropagation();
if (e.target === e.currentTarget) {
setRender(false);
}
}
}
}}>
<div className={classNames('modal-container', { top })}>
}}
>
<ModalContainer alignTop={top}>
{isDismissable &&
<div className={'modal-close-icon'} onClick={() => setRender(false)}>
<div className={'close-icon'} onClick={() => setRender(false)}>
<FontAwesomeIcon icon={faTimes}/>
</div>
}
{showSpinnerOverlay &&
<div
className={'absolute w-full h-full rounded flex items-center justify-center'}
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
>
<Spinner/>
</div>
}
<div className={'modal-content p-6'}>
<div css={tw`bg-neutral-800 p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}>
{children}
</div>
</div>
</div>
</CSSTransition>
</ModalContainer>
</ModalMask>
</Fade>
);
};
export default Modal;

View file

@ -1,26 +1,26 @@
import React from 'react';
import ContentContainer from '@/components/elements/ContentContainer';
import { CSSTransition } from 'react-transition-group';
import tw from 'twin.macro';
import FlashMessageRender from '@/components/FlashMessageRender';
interface Props {
children: React.ReactNode;
className?: string;
}
export default ({ className, children }: Props) => (
<CSSTransition timeout={250} classNames={'fade'} appear={true} in={true}>
const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => (
<CSSTransition timeout={150} classNames={'fade'} appear in>
<>
<ContentContainer className={`my-10 ${className}`}>
<ContentContainer css={tw`my-10`} className={className}>
{showFlashKey &&
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
}
{children}
</ContentContainer>
<ContentContainer className={'mb-4'}>
<p className={'text-center text-neutral-500 text-xs'}>
<ContentContainer css={tw`mb-4`}>
<p css={tw`text-center text-neutral-500 text-xs`}>
&copy; 2015 - 2020&nbsp;
<a
rel={'noopener nofollow'}
rel={'noopener nofollow noreferrer'}
href={'https://pterodactyl.io'}
target={'_blank'}
className={'no-underline text-neutral-500 hover:text-neutral-300'}
css={tw`no-underline text-neutral-500 hover:text-neutral-300`}
>
Pterodactyl Software
</a>
@ -29,3 +29,5 @@ export default ({ className, children }: Props) => (
</>
</CSSTransition>
);
export default PageContentBlock;

View file

@ -1,8 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import styled from 'styled-components/macro';
import { useStoreActions, useStoreState } from 'easy-peasy';
import { randomInt } from '@/helpers';
import { CSSTransition } from 'react-transition-group';
import tw from 'twin.macro';
const BarFill = styled.div`
${tw`h-full bg-cyan-400`};
@ -60,10 +61,10 @@ export default () => {
return (
<div className={'w-full fixed'} style={{ height: '2px' }}>
<CSSTransition
timeout={250}
appear={true}
timeout={150}
appear
in={visible}
unmountOnExit={true}
unmountOnExit
classNames={'fade'}
>
<BarFill style={{ width: progress === undefined ? '100%' : `${progress}%` }}/>

View file

@ -0,0 +1,36 @@
import styled, { css } from 'styled-components/macro';
import tw from 'twin.macro';
interface Props {
hideDropdownArrow?: boolean;
}
const Select = styled.select<Props>`
${tw`shadow-none block p-3 pr-8 rounded border w-full text-sm transition-colors duration-150 ease-linear`};
&, &:hover:not(:disabled), &:focus {
${tw`outline-none`};
}
-webkit-appearance: none;
-moz-appearance: none;
background-size: 1rem;
background-repeat: no-repeat;
background-position-x: calc(100% - 0.75rem);
background-position-y: center;
&::-ms-expand {
display: none;
}
${props => !props.hideDropdownArrow && css`
${tw`bg-neutral-600 border-neutral-500 text-neutral-200`};
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='%23C3D1DF' d='M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z'/%3e%3c/svg%3e ");
&:hover:not(:disabled), &:focus {
${tw`border-neutral-400`};
}
`};
`;
export default Select;

View file

@ -1,31 +1,48 @@
import React from 'react';
import classNames from 'classnames';
import styled, { css, keyframes } from 'styled-components/macro';
import tw from 'twin.macro';
export type SpinnerSize = 'large' | 'normal' | 'tiny';
export type SpinnerSize = 'small' | 'base' | 'large';
interface Props {
size?: SpinnerSize;
centered?: boolean;
className?: string;
isBlue?: boolean;
}
const Spinner = ({ size, centered, className }: Props) => (
const spin = keyframes`
to { transform: rotate(360deg); }
`;
// noinspection CssOverwrittenProperties
const SpinnerComponent = styled.div<Props>`
${tw`w-8 h-8`};
border-width: 3px;
border-radius: 50%;
animation: ${spin} 1s cubic-bezier(0.55, 0.25, 0.25, 0.70) infinite;
${props => props.size === 'small' ? tw`w-4 h-4 border-2` : (props.size === 'large' ? css`
${tw`w-16 h-16`};
border-width: 6px;
` : null)};
border-color: ${props => !props.isBlue ? 'rgba(255, 255, 255, 0.2)' : 'hsla(212, 92%, 43%, 0.2)'};
border-top-color: ${props => !props.isBlue ? 'rgb(255, 255, 255)' : 'hsl(212, 92%, 43%)'};
`;
const Spinner = ({ centered, ...props }: Props) => (
centered ?
<div className={classNames(`flex justify-center ${className}`, { 'm-20': size === 'large', 'm-6': size !== 'large' })}>
<div
className={classNames('spinner-circle spinner-white', {
'spinner-lg': size === 'large',
'spinner-sm': size === 'tiny',
})}
/>
<div
css={[
tw`flex justify-center items-center`,
props.size === 'large' ? tw`m-20` : tw`m-6`,
]}
>
<SpinnerComponent {...props}/>
</div>
:
<div
className={classNames(`spinner-circle spinner-white ${className}`, {
'spinner-lg': size === 'large',
'spinner-sm': size === 'tiny',
})}
/>
<SpinnerComponent {...props}/>
);
Spinner.DisplayName = 'Spinner';
export default Spinner;

View file

@ -1,7 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import { CSSTransition } from 'react-transition-group';
import Spinner, { SpinnerSize } from '@/components/elements/Spinner';
import Fade from '@/components/elements/Fade';
import tw from 'twin.macro';
interface Props {
visible: boolean;
@ -11,17 +11,17 @@ interface Props {
}
const SpinnerOverlay = ({ size, fixed, visible, backgroundOpacity }: Props) => (
<CSSTransition timeout={150} classNames={'fade'} in={visible} unmountOnExit={true}>
<Fade timeout={150} in={visible} unmountOnExit>
<div
className={classNames('pin-t pin-l flex items-center justify-center w-full h-full rounded', {
absolute: !fixed,
fixed: fixed,
})}
css={[
tw`top-0 left-0 flex items-center justify-center w-full h-full rounded`,
!fixed ? tw`absolute` : tw`fixed`,
]}
style={{ zIndex: 9999, background: `rgba(0, 0, 0, ${backgroundOpacity || 0.45})` }}
>
<Spinner size={size}/>
</div>
</CSSTransition>
</Fade>
);
export default SpinnerOverlay;

View file

@ -0,0 +1,31 @@
import styled from 'styled-components/macro';
import tw from 'twin.macro';
// @ts-ignore
import config from '../../../../tailwind.config';
const SubNavigation = styled.div`
${tw`w-full bg-neutral-700 shadow`};
& > div {
${tw`flex items-center text-sm mx-auto px-2`};
max-width: 1200px;
& > a, & > div {
${tw`inline-block py-3 px-4 text-neutral-300 no-underline transition-all duration-150`};
&:not(:first-of-type) {
${tw`ml-2`};
}
&:active, &:hover {
${tw`text-neutral-100`};
}
&:active, &:hover, &.active {
box-shadow: inset 0 -2px ${config.theme.colors.cyan['500']};
}
}
}
`;
export default SubNavigation;

View file

@ -1,14 +1,8 @@
import React, { Suspense } from 'react';
import Spinner from '@/components/elements/Spinner';
const SuspenseSpinner = ({ children }: { children?: React.ReactNode }) => (
<Suspense
fallback={
<div className={'mx-4 w-3/4 mr-4 flex items-center justify-center'}>
<Spinner centered={true} size={'normal'}/>
</div>
}
>
const SuspenseSpinner: React.FC = ({ children }) => (
<Suspense fallback={<Spinner size={'large'} centered/>}>
{children}
</Suspense>
);

View file

@ -1,7 +1,9 @@
import React, { useMemo } from 'react';
import styled from 'styled-components';
import styled from 'styled-components/macro';
import v4 from 'uuid/v4';
import classNames from 'classnames';
import tw from 'twin.macro';
import Label from '@/components/elements/Label';
import Input from '@/components/elements/Input';
const ToggleContainer = styled.div`
${tw`relative select-none w-12 leading-normal`};
@ -47,10 +49,10 @@ const Switch = ({ name, label, description, defaultChecked, onChange, children }
const uuid = useMemo(() => v4(), []);
return (
<div className={'flex items-center'}>
<ToggleContainer className={'flex-none'}>
<div css={tw`flex items-center`}>
<ToggleContainer css={tw`flex-none`}>
{children
|| <input
|| <Input
id={uuid}
name={name}
type={'checkbox'}
@ -58,18 +60,20 @@ const Switch = ({ name, label, description, defaultChecked, onChange, children }
defaultChecked={defaultChecked}
/>
}
<label htmlFor={uuid}/>
<Label htmlFor={uuid}/>
</ToggleContainer>
{(label || description) &&
<div className={'ml-4 w-full'}>
<div css={tw`ml-4 w-full`}>
{label &&
<label
className={classNames('input-dark-label cursor-pointer', { 'mb-0': !!description })}
<Label
css={[ tw`cursor-pointer`, !!description && tw`mb-0` ]}
htmlFor={uuid}
>{label}</label>
>
{label}
</Label>
}
{description &&
<p className={'input-help'}>
<p css={tw`text-neutral-400 text-sm mt-2`}>
{description}
</p>
}

View file

@ -1,6 +1,7 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import tw from 'twin.macro';
interface Props {
icon?: IconProp;
@ -10,17 +11,17 @@ interface Props {
}
const TitledGreyBox = ({ icon, title, children, className }: Props) => (
<div className={`rounded shadow-md bg-neutral-700 ${className}`}>
<div className={'bg-neutral-900 rounded-t p-3 border-b border-black'}>
<div css={tw`rounded shadow-md bg-neutral-700`} className={className}>
<div css={tw`bg-neutral-900 rounded-t p-3 border-b border-black`}>
{typeof title === 'string' ?
<p className={'text-sm uppercase'}>
{icon && <FontAwesomeIcon icon={icon} className={'mr-2 text-neutral-300'}/>}{title}
<p css={tw`text-sm uppercase`}>
{icon && <FontAwesomeIcon icon={icon} css={tw`mr-2 text-neutral-300`}/>}{title}
</p>
:
title
}
</div>
<div className={'p-3'}>
<div css={tw`p-3`}>
{children}
</div>
</div>

View file

@ -1,10 +1,10 @@
import React from 'react';
import PageContentBlock from '@/components/elements/PageContentBlock';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons/faArrowLeft';
import { faSyncAlt } from '@fortawesome/free-solid-svg-icons/faSyncAlt';
import classNames from 'classnames';
import styled from 'styled-components';
import { faArrowLeft, faSyncAlt } from '@fortawesome/free-solid-svg-icons';
import styled, { keyframes } from 'styled-components/macro';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface BaseProps {
title: string;
@ -26,37 +26,35 @@ interface PropsWithBack extends BaseProps {
type Props = PropsWithBack | PropsWithRetry;
const ActionButton = styled.button`
const spin = keyframes`
to { transform: rotate(360deg) }
`;
const ActionButton = styled(Button)`
${tw`rounded-full w-8 h-8 flex items-center justify-center`};
&.hover\\:spin:hover {
animation: spin 2s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
animation: ${spin} 2s linear infinite;
}
`;
export default ({ title, image, message, onBack, onRetry }: Props) => (
<PageContentBlock>
<div className={'flex justify-center'}>
<div className={'w-full sm:w-3/4 md:w-1/2 p-12 md:p-20 bg-neutral-100 rounded-lg shadow-lg text-center relative'}>
<div css={tw`flex justify-center`}>
<div css={tw`w-full sm:w-3/4 md:w-1/2 p-12 md:p-20 bg-neutral-100 rounded-lg shadow-lg text-center relative`}>
{(typeof onBack === 'function' || typeof onRetry === 'function') &&
<div className={'absolute pin-l pin-t ml-4 mt-4'}>
<div css={tw`absolute left-0 top-0 ml-4 mt-4`}>
<ActionButton
onClick={() => onRetry ? onRetry() : (onBack ? onBack() : null)}
className={classNames('btn btn-primary', { 'hover:spin': !!onRetry })}
className={onRetry ? 'hover:spin' : undefined}
>
<FontAwesomeIcon icon={onRetry ? faSyncAlt : faArrowLeft}/>
</ActionButton>
</div>
}
<img src={image} className={'w-2/3 h-auto select-none'}/>
<h2 className={'mt-6 text-neutral-900 font-bold'}>{title}</h2>
<p className={'text-sm text-neutral-700 mt-2'}>
<img src={image} css={tw`w-2/3 h-auto select-none`}/>
<h2 css={tw`mt-6 text-neutral-900 font-bold`}>{title}</h2>
<p css={tw`text-sm text-neutral-700 mt-2`}>
{message}
</p>
</div>

View file

@ -1,5 +1,4 @@
import React from 'react';
import styled from 'styled-components';
import ScreenBlock from '@/components/screens/ScreenBlock';
interface Props {

View file

@ -3,10 +3,10 @@ import { ITerminalOptions, Terminal } from 'xterm';
import * as TerminalFit from 'xterm/lib/addons/fit/fit';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ServerContext } from '@/state/server';
import styled from 'styled-components';
import Can from '@/components/elements/Can';
import styled from 'styled-components/macro';
import { usePermissions } from '@/plugins/usePermissions';
import classNames from 'classnames';
import tw from 'twin.macro';
import 'xterm/dist/xterm.css';
const theme = {
background: 'transparent',
@ -55,7 +55,7 @@ export default () => {
const useRef = useCallback(node => setTerminalElement(node), []);
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ canSendCommands ] = usePermissions([ 'control.console']);
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
@ -122,12 +122,13 @@ export default () => {
}, [ connected, instance ]);
return (
<div className={'text-xs font-mono relative'}>
<div css={tw`text-xs font-mono relative`}>
<SpinnerOverlay visible={!connected} size={'large'}/>
<div
className={classNames('rounded-t p-2 bg-black w-full', {
'rounded-b': !canSendCommands,
})}
css={[
tw`rounded-t p-2 bg-black w-full`,
!canSendCommands && tw`rounded-b`,
]}
style={{
minHeight: '16rem',
maxHeight: '32rem',
@ -136,13 +137,13 @@ export default () => {
<TerminalDiv id={'terminal'} ref={useRef}/>
</div>
{canSendCommands &&
<div className={'rounded-b bg-neutral-900 text-neutral-100 flex'}>
<div className={'flex-no-shrink p-2 font-bold'}>$</div>
<div className={'w-full'}>
<div css={tw`rounded-b bg-neutral-900 text-neutral-100 flex`}>
<div css={tw`flex-shrink-0 p-2 font-bold`}>$</div>
<div css={tw`w-full`}>
<input
type={'text'}
disabled={!instance || !connected}
className={'bg-transparent text-neutral-100 p-2 pl-0 w-full'}
css={tw`bg-transparent text-neutral-100 p-2 pl-0 w-full`}
onKeyDown={e => handleCommandKeydown(e)}
/>
</div>

View file

@ -1,47 +1,22 @@
import React, { lazy, useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faServer } from '@fortawesome/free-solid-svg-icons/faServer';
import { faCircle } from '@fortawesome/free-solid-svg-icons/faCircle';
import classNames from 'classnames';
import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory';
import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
import { faHdd } from '@fortawesome/free-solid-svg-icons/faHdd';
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman } from '@/helpers';
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import Can from '@/components/elements/Can';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ContentContainer from '@/components/elements/ContentContainer';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import StopOrKillButton from '@/components/server/StopOrKillButton';
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
const ChunkedConsole = lazy(() => import(/* webpackChunkName: "console" */'@/components/server/Console'));
const ChunkedStatGraphs = lazy(() => import(/* webpackChunkName: "graphs" */'@/components/server/StatGraphs'));
const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => {
const [ clicked, setClicked ] = useState(false);
const status = ServerContext.useStoreState(state => state.status.value);
useEffect(() => {
setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state);
}, [ status ]);
return (
<button
className={'btn btn-red btn-xs'}
disabled={status === 'offline'}
onClick={e => {
e.preventDefault();
onPress(clicked ? 'kill' : 'stop');
setClicked(true);
}}
>
{clicked ? 'Kill' : 'Stop'}
</button>
);
};
export default () => {
const [ memory, setMemory ] = useState(0);
const [ cpu, setCpu ] = useState(0);
@ -81,58 +56,45 @@ export default () => {
};
}, [ instance, connected ]);
const disklimit = server.limits.disk != 0 ? bytesToHuman(server.limits.disk * 1000 * 1000) : "Unlimited";
const memorylimit = server.limits.memory != 0 ? bytesToHuman(server.limits.memory * 1000 * 1000) : "Unlimited";
const disklimit = server.limits.disk ? bytesToHuman(server.limits.disk * 1000 * 1000) : 'Unlimited';
const memorylimit = server.limits.memory ? bytesToHuman(server.limits.memory * 1000 * 1000) : 'Unlimited';
return (
<PageContentBlock className={'flex'}>
<div className={'w-1/4'}>
<PageContentBlock css={tw`flex`}>
<div css={tw`w-1/4`}>
<TitledGreyBox title={server.name} icon={faServer}>
<p className={'text-xs uppercase'}>
<p css={tw`text-xs uppercase`}>
<FontAwesomeIcon
icon={faCircle}
fixedWidth={true}
className={classNames('mr-1', {
'text-red-500': status === 'offline',
'text-yellow-500': [ 'running', 'offline' ].indexOf(status) < 0,
'text-green-500': status === 'running',
})}
fixedWidth
css={[
tw`mr-1`,
status === 'offline' ? tw`text-red-500` : (status === 'running' ? tw`text-green-500` : tw`text-yellow-500`),
]}
/>
&nbsp;{status}
</p>
<p className={'text-xs mt-2'}>
<FontAwesomeIcon
icon={faMicrochip}
fixedWidth={true}
className={'mr-1'}
/>
&nbsp;{cpu.toFixed(2)} %
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMicrochip} fixedWidth css={tw`mr-1`}/> {cpu.toFixed(2)}%
</p>
<p className={'text-xs mt-2'}>
<FontAwesomeIcon
icon={faMemory}
fixedWidth={true}
className={'mr-1'}
/>
&nbsp;{bytesToHuman(memory)}
<span className={'text-neutral-500'}> / {memorylimit}</span>
</p>
<p className={'text-xs mt-2'}>
<FontAwesomeIcon
icon={faHdd}
fixedWidth={true}
className={'mr-1'}
/>
&nbsp;{bytesToHuman(disk)}
<span className={'text-neutral-500'}> / {disklimit}</span>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMemory} fixedWidth css={tw`mr-1`}/> {bytesToHuman(memory)}
<span css={tw`text-neutral-500`}> / {memorylimit}</span>
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/>&nbsp;{bytesToHuman(disk)}
<span css={tw`text-neutral-500`}> / {disklimit}</span>
</p>
</TitledGreyBox>
{!server.isInstalling ?
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny={true}>
<div className={'grey-box justify-center'}>
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
<div css={tw`shadow-md bg-neutral-700 rounded p-3 flex text-xs mt-4 justify-center`}>
<Can action={'control.start'}>
<button
className={'btn btn-secondary btn-green btn-xs mr-2'}
<Button
size={'xsmall'}
color={'green'}
isSecondary
css={tw`mr-2`}
disabled={status !== 'offline'}
onClick={e => {
e.preventDefault();
@ -140,18 +102,20 @@ export default () => {
}}
>
Start
</button>
</Button>
</Can>
<Can action={'control.restart'}>
<button
className={'btn btn-secondary btn-primary btn-xs mr-2'}
<Button
size={'xsmall'}
isSecondary
css={tw`mr-2`}
onClick={e => {
e.preventDefault();
sendPowerCommand('restart');
}}
>
Restart
</button>
</Button>
</Can>
<Can action={'control.stop'}>
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
@ -159,9 +123,9 @@ export default () => {
</div>
</Can>
:
<div className={'mt-4 rounded bg-yellow-500 p-3'}>
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
<ContentContainer>
<p className={'text-sm text-yellow-900'}>
<p css={tw`text-sm text-yellow-900`}>
This server is currently running its installation process and most actions are
unavailable.
</p>
@ -169,7 +133,7 @@ export default () => {
</div>
}
</div>
<div className={'flex-1 ml-4'}>
<div css={tw`flex-1 ml-4`}>
<SuspenseSpinner>
<ChunkedConsole/>
<ChunkedStatGraphs/>

View file

@ -2,12 +2,12 @@ import React, { useCallback, useEffect, useState } from 'react';
import Chart, { ChartConfiguration } from 'chart.js';
import { ServerContext } from '@/state/server';
import { bytesToMegabytes } from '@/helpers';
import merge from 'lodash-es/merge';
import merge from 'deepmerge';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory';
import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
import { faMemory, faMicrochip } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro';
const chartDefaults: ChartConfiguration = {
const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguration => ({
type: 'line',
options: {
legend: {
@ -45,21 +45,17 @@ const chartDefaults: ChartConfiguration = {
zeroLineColor: 'rgba(15, 178, 184, 0.45)',
zeroLineWidth: 3,
},
ticks: {
ticks: merge(ticks || {}, {
fontSize: 10,
fontFamily: '"IBM Plex Mono", monospace',
fontColor: 'rgb(229, 232, 235)',
min: 0,
beginAtZero: true,
maxTicksLimit: 5,
},
}),
} ],
},
},
};
const createDefaultChart = (ctx: CanvasRenderingContext2D, options?: ChartConfiguration): Chart => new Chart(ctx, {
...merge({}, chartDefaults, options),
data: {
labels: Array(20).fill(''),
datasets: [
@ -84,18 +80,12 @@ export default () => {
return;
}
setMemory(createDefaultChart(node.getContext('2d')!, {
options: {
scales: {
yAxes: [ {
ticks: {
callback: (value) => `${value}Mb `,
suggestedMax: limits.memory,
},
} ],
},
},
}));
setMemory(
new Chart(node.getContext('2d')!, chartDefaults({
callback: (value) => `${value}Mb `,
suggestedMax: limits.memory,
}))
);
}, []);
const cpuRef = useCallback<(node: HTMLCanvasElement | null) => void>(node => {
@ -103,17 +93,11 @@ export default () => {
return;
}
setCpu(createDefaultChart(node.getContext('2d')!, {
options: {
scales: {
yAxes: [ {
ticks: {
callback: (value) => `${value}% `,
},
} ],
},
},
}));
setCpu(
new Chart(node.getContext('2d')!, chartDefaults({
callback: (value) => `${value}%`,
})),
);
}, []);
const statsListener = (data: string) => {
@ -157,21 +141,21 @@ export default () => {
}, [ instance, connected, memory, cpu ]);
return (
<div className={'flex mt-4'}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} className={'flex-1 mr-2'}>
<div css={tw`flex mt-4`}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`flex-1 mr-2`}>
{status !== 'offline' ?
<canvas id={'memory_chart'} ref={memoryRef} aria-label={'Server Memory Usage Graph'} role={'img'}/>
:
<p className={'text-xs text-neutral-400 text-center p-3'}>
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip} className={'flex-1 ml-2'}>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip} css={tw`flex-1 ml-2`}>
{status !== 'offline' ?
<canvas id={'cpu_chart'} ref={cpuRef} aria-label={'Server CPU Usage Graph'} role={'img'}/>
:
<p className={'text-xs text-neutral-400 text-center p-3'}>
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}

View file

@ -0,0 +1,30 @@
import React, { useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import { PowerAction } from '@/components/server/ServerConsole';
import Button from '@/components/elements/Button';
const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => {
const [ clicked, setClicked ] = useState(false);
const status = ServerContext.useStoreState(state => state.status.value);
useEffect(() => {
setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state);
}, [ status ]);
return (
<Button
color={'red'}
size={'xsmall'}
disabled={status === 'offline'}
onClick={e => {
e.preventDefault();
onPress(clicked ? 'kill' : 'stop');
setClicked(true);
}}
>
{clicked ? 'Kill' : 'Stop'}
</Button>
);
};
export default StopOrKillButton;

View file

@ -5,6 +5,7 @@ import getWebsocketToken from '@/api/server/getWebsocketToken';
import ContentContainer from '@/components/elements/ContentContainer';
import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner';
import tw from 'twin.macro';
export default () => {
const server = ServerContext.useStoreState(state => state.server.data);
@ -66,12 +67,12 @@ export default () => {
return (
error ?
<CSSTransition timeout={250} in={true} appear={true} classNames={'fade'}>
<div className={'bg-red-500 py-2'}>
<ContentContainer className={'flex items-center justify-center'}>
<Spinner size={'tiny'}/>
<p className={'ml-2 text-sm text-red-100'}>
We're having some trouble connecting to your server, please wait...
<CSSTransition timeout={150} in appear classNames={'fade'}>
<div css={tw`bg-red-500 py-2`}>
<ContentContainer css={tw`flex items-center justify-center`}>
<Spinner size={'small'}/>
<p css={tw`ml-2 text-sm text-red-100`}>
We&apos;re having some trouble connecting to your server, please wait...
</p>
</ContentContainer>
</div>

View file

@ -10,6 +10,7 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import BackupRow from '@/components/server/backups/BackupRow';
import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
export default () => {
const { uuid, featureLimits } = useServer();
@ -31,14 +32,14 @@ export default () => {
}, []);
if (backups.length === 0 && loading) {
return <Spinner size={'large'} centered={true}/>;
return <Spinner size={'large'} centered/>;
}
return (
<PageContentBlock>
<FlashMessageRender byKey={'backups'} className={'mb-4'}/>
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
{!backups.length ?
<p className="text-center text-sm text-neutral-400">
<p css={tw`text-center text-sm text-neutral-400`}>
There are no backups stored for this server.
</p>
:
@ -46,7 +47,7 @@ export default () => {
{backups.map((backup, index) => <BackupRow
key={backup.uuid}
backup={backup}
className={index !== (backups.length - 1) ? 'mb-2' : undefined}
css={index > 0 ? tw`mt-2` : undefined}
/>)}
</div>
}
@ -57,12 +58,12 @@ export default () => {
}
<Can action={'backup.create'}>
{(featureLimits.backups > 0 && backups.length > 0) &&
<p className="text-center text-xs text-neutral-400 mt-2">
<p css={tw`text-center text-xs text-neutral-400 mt-2`}>
{backups.length} of {featureLimits.backups} backups have been created for this server.
</p>
}
{featureLimits.backups > 0 && featureLimits.backups !== backups.length &&
<div className={'mt-6 flex justify-end'}>
<div css={tw`mt-6 flex justify-end`}>
<CreateBackupButton/>
</div>
}

View file

@ -1,11 +1,8 @@
import React, { useState } from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH';
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDownloadAlt';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faLock } from '@fortawesome/free-solid-svg-icons/faLock';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import { httpErrorToHuman } from '@/api/http';
import useFlash from '@/plugins/useFlash';
@ -16,6 +13,7 @@ import deleteBackup from '@/api/server/backups/deleteBackup';
import { ServerContext } from '@/state/server';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Can from '@/components/elements/Can';
import tw from 'twin.macro';
interface Props {
backup: ServerBackup;
@ -61,8 +59,8 @@ export default ({ backup }: Props) => {
<>
{visible &&
<ChecksumModal
appear
visible={visible}
appear={true}
onDismissed={() => setVisible(false)}
checksum={backup.sha256Hash}
/>
@ -79,32 +77,32 @@ export default ({ backup }: Props) => {
be recovered once deleted.
</ConfirmationModal>
}
<SpinnerOverlay visible={loading} fixed={true}/>
<SpinnerOverlay visible={loading} fixed/>
<DropdownMenu
renderToggle={onClick => (
<button
onClick={onClick}
className={'text-neutral-200 transition-color duration-150 hover:text-neutral-100 p-2'}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
>
<FontAwesomeIcon icon={faEllipsisH}/>
</button>
)}
>
<div className={'text-sm'}>
<div css={tw`text-sm`}>
<Can action={'backup.download'}>
<DropdownButtonRow onClick={() => doDownload()}>
<FontAwesomeIcon fixedWidth={true} icon={faCloudDownloadAlt} className={'text-xs'}/>
<span className={'ml-2'}>Download</span>
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Download</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setVisible(true)}>
<FontAwesomeIcon fixedWidth={true} icon={faLock} className={'text-xs'}/>
<span className={'ml-2'}>Checksum</span>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow>
<Can action={'backup.delete'}>
<DropdownButtonRow danger={true} onClick={() => setDeleteVisible(true)}>
<FontAwesomeIcon fixedWidth={true} icon={faTrashAlt} className={'text-xs'}/>
<span className={'ml-2'}>Delete</span>
<DropdownButtonRow danger onClick={() => setDeleteVisible(true)}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
</Can>
</div>

View file

@ -1,22 +1,16 @@
import React, { useState } from 'react';
import React from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArchive } from '@fortawesome/free-solid-svg-icons/faArchive';
import format from 'date-fns/format';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
import { format, formatDistanceToNow } from 'date-fns';
import Spinner from '@/components/elements/Spinner';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { bytesToHuman } from '@/helpers';
import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
interface Props {
backup: ServerBackup;
@ -41,38 +35,38 @@ export default ({ backup, className }: Props) => {
});
return (
<div className={`grey-row-box flex items-center ${className}`}>
<div className={'mr-4'}>
<GreyRowBox css={tw`flex items-center`} className={className}>
<div css={tw`mr-4`}>
{backup.completedAt ?
<FontAwesomeIcon icon={faArchive} className={'text-neutral-300'}/>
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
:
<Spinner size={'tiny'}/>
<Spinner size={'small'}/>
}
</div>
<div className={'flex-1'}>
<p className={'text-sm mb-1'}>
<div css={tw`flex-1`}>
<p css={tw`text-sm mb-1`}>
{backup.name}
{backup.completedAt &&
<span className={'ml-3 text-neutral-300 text-xs font-thin'}>{bytesToHuman(backup.bytes)}</span>
<span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span>
}
</p>
<p className={'text-xs text-neutral-400 font-mono'}>
<p css={tw`text-xs text-neutral-400 font-mono`}>
{backup.uuid}
</p>
</div>
<div className={'ml-8 text-center'}>
<div css={tw`ml-8 text-center`}>
<p
title={format(backup.createdAt, 'ddd, MMMM Do, YYYY HH:mm:ss Z')}
className={'text-sm'}
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
css={tw`text-sm`}
>
{distanceInWordsToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
</p>
<p className={'text-2xs text-neutral-500 uppercase mt-1'}>Created</p>
<p css={tw`text-2xs text-neutral-500 uppercase mt-1`}>Created</p>
</div>
<Can action={'backup.download'}>
<div className={'ml-6'} style={{ marginRight: '-0.5rem' }}>
<div css={tw`ml-6`} style={{ marginRight: '-0.5rem' }}>
{!backup.completedAt ?
<div className={'p-2 invisible'}>
<div css={tw`p-2 invisible`}>
<FontAwesomeIcon icon={faEllipsisH}/>
</div>
:
@ -80,6 +74,6 @@ export default ({ backup, className }: Props) => {
}
</div>
</Can>
</div>
</GreyRowBox>
);
};

View file

@ -1,14 +1,15 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import tw from 'twin.macro';
const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => (
<Modal {...props}>
<h3 className={'mb-6'}>Verify file checksum</h3>
<p className={'text-sm'}>
<h3 css={tw`mb-6`}>Verify file checksum</h3>
<p css={tw`text-sm`}>
The SHA256 checksum of this file is:
</p>
<pre className={'mt-2 text-sm p-2 bg-neutral-900 rounded'}>
<code className={'block font-mono'}>{checksum}</code>
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
<code css={tw`block font-mono`}>{checksum}</code>
</pre>
</Modal>
);

View file

@ -10,6 +10,9 @@ import createServerBackup from '@/api/server/backups/createServerBackup';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ServerContext } from '@/state/server';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
import { Textarea } from '@/components/elements/Input';
interface Values {
name: string;
@ -21,17 +24,17 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
return (
<Modal {...props} showSpinnerOverlay={isSubmitting}>
<Form className={'pb-6'}>
<FlashMessageRender byKey={'backups:create'} className={'mb-4'}/>
<h3 className={'mb-6'}>Create server backup</h3>
<div className={'mb-6'}>
<Form>
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
<div css={tw`mb-6`}>
<Field
name={'name'}
label={'Backup name'}
description={'If provided, the name that should be used to reference this backup.'}
/>
</div>
<div className={'mb-6'}>
<div css={tw`mb-6`}>
<FormikFieldWrapper
name={'ignored'}
label={'Ignored Files & Directories'}
@ -42,20 +45,13 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
prefixing the path with an exclamation point.
`}
>
<FormikField
name={'ignored'}
component={'textarea'}
className={'input-dark h-32'}
/>
<FormikField as={Textarea} name={'ignored'} css={tw`h-32`}/>
</FormikFieldWrapper>
</div>
<div className={'flex justify-end'}>
<button
type={'submit'}
className={'btn btn-primary btn-sm'}
>
<div css={tw`flex justify-end`}>
<Button type={'submit'}>
Start backup
</button>
</Button>
</div>
</Form>
</Modal>
@ -99,18 +95,15 @@ export default () => {
})}
>
<ModalContent
appear={true}
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
</Formik>
}
<button
className={'btn btn-primary btn-sm'}
onClick={() => setVisible(true)}
>
<Button onClick={() => setVisible(true)}>
Create backup
</button>
</Button>
</>
);
};

View file

@ -9,6 +9,8 @@ import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
interface Values {
databaseName: string;
@ -48,7 +50,7 @@ export default () => {
};
return (
<React.Fragment>
<>
<Formik
onSubmit={submit}
initialValues={{ databaseName: '', connectionsFrom: '%' }}
@ -65,9 +67,9 @@ export default () => {
setVisible(false);
}}
>
<FlashMessageRender byKey={'database:create'} className={'mb-6'}/>
<h3 className={'mb-6'}>Create new database</h3>
<Form className={'m-0'}>
<FlashMessageRender byKey={'database:create'} css={tw`mb-6`}/>
<h2 css={tw`text-2xl mb-6`}>Create new database</h2>
<Form css={tw`m-0`}>
<Field
type={'string'}
id={'database_name'}
@ -75,7 +77,7 @@ export default () => {
label={'Database Name'}
description={'A descriptive name for your database instance.'}
/>
<div className={'mt-6'}>
<div css={tw`mt-6`}>
<Field
type={'string'}
id={'connections_from'}
@ -84,26 +86,27 @@ export default () => {
description={'Where connections should be allowed from. Use % for wildcards.'}
/>
</div>
<div className={'mt-6 text-right'}>
<button
<div css={tw`mt-6 text-right`}>
<Button
type={'button'}
className={'btn btn-sm btn-secondary mr-2'}
isSecondary
css={tw`mr-2`}
onClick={() => setVisible(false)}
>
Cancel
</button>
<button className={'btn btn-sm btn-primary'} type={'submit'}>
</Button>
<Button type={'submit'}>
Create Database
</button>
</Button>
</div>
</Form>
</Modal>
)
}
</Formik>
<button className={'btn btn-primary btn-sm'} onClick={() => setVisible(true)}>
<Button onClick={() => setVisible(true)}>
New Database
</button>
</React.Fragment>
</Button>
</>
);
};

View file

@ -1,9 +1,6 @@
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDatabase } from '@fortawesome/free-solid-svg-icons/faDatabase';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faEye } from '@fortawesome/free-solid-svg-icons/faEye';
import classNames from 'classnames';
import { faDatabase, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
import Modal from '@/components/elements/Modal';
import { Form, Formik, FormikHelpers } from 'formik';
import Field from '@/components/elements/Field';
@ -17,6 +14,11 @@ import Can from '@/components/elements/Can';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Label from '@/components/elements/Label';
import Input from '@/components/elements/Input';
import GreyRowBox from '@/components/elements/GreyRowBox';
interface Props {
database: ServerDatabase;
@ -51,13 +53,14 @@ export default ({ database, className }: Props) => {
addError({ key: 'database:delete', message: httpErrorToHuman(error) });
});
};
return (
<React.Fragment>
<>
<Formik
onSubmit={submit}
initialValues={{ confirm: '' }}
validationSchema={schema}
isInitialValid={false}
>
{
({ isSubmitting, isValid, resetForm }) => (
@ -70,13 +73,13 @@ export default ({ database, className }: Props) => {
resetForm();
}}
>
<FlashMessageRender byKey={'database:delete'} className={'mb-6'}/>
<h3 className={'mb-6'}>Confirm database deletion</h3>
<p className={'text-sm'}>
<FlashMessageRender byKey={'database:delete'} css={tw`mb-6`}/>
<h2 css={tw`text-2xl mb-6`}>Confirm database deletion</h2>
<p css={tw`text-sm`}>
Deleting a database is a permanent action, it cannot be undone. This will permanetly
delete the <strong>{database.name}</strong> database and remove all associated data.
</p>
<Form className={'m-0 mt-6'}>
<Form css={tw`m-0 mt-6`}>
<Field
type={'text'}
id={'confirm_name'}
@ -84,21 +87,22 @@ export default ({ database, className }: Props) => {
label={'Confirm Database Name'}
description={'Enter the database name to confirm deletion.'}
/>
<div className={'mt-6 text-right'}>
<button
<div css={tw`mt-6 text-right`}>
<Button
type={'button'}
className={'btn btn-sm btn-secondary mr-2'}
isSecondary
css={tw`mr-2`}
onClick={() => setVisible(false)}
>
Cancel
</button>
<button
</Button>
<Button
type={'submit'}
className={'btn btn-sm btn-red'}
color={'red'}
disabled={!isValid}
>
Delete Database
</button>
</Button>
</div>
</Form>
</Modal>
@ -106,62 +110,61 @@ export default ({ database, className }: Props) => {
}
</Formik>
<Modal visible={connectionVisible} onDismissed={() => setConnectionVisible(false)}>
<FlashMessageRender byKey={'database-connection-modal'} className={'mb-6'}/>
<h3 className={'mb-6'}>Database connection details</h3>
<FlashMessageRender byKey={'database-connection-modal'} css={tw`mb-6`}/>
<h3 css={tw`mb-6`}>Database connection details</h3>
<Can action={'database.view_password'}>
<div>
<label className={'input-dark-label'}>Password</label>
<input type={'text'} className={'input-dark'} readOnly={true} value={database.password}/>
<Label>Password</Label>
<Input type={'text'} readOnly value={database.password}/>
</div>
</Can>
<div className={'mt-6'}>
<label className={'input-dark-label'}>JBDC Connection String</label>
<input
<div css={tw`mt-6`}>
<Label>JBDC Connection String</Label>
<Input
type={'text'}
className={'input-dark'}
readOnly={true}
readOnly
value={`jdbc:mysql://${database.username}:${database.password}@${database.connectionString}/${database.name}`}
/>
</div>
<div className={'mt-6 text-right'}>
<div css={tw`mt-6 text-right`}>
<Can action={'database.update'}>
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase}/>
</Can>
<button className={'btn btn-sm btn-secondary'} onClick={() => setConnectionVisible(false)}>
<Button isSecondary onClick={() => setConnectionVisible(false)}>
Close
</button>
</Button>
</div>
</Modal>
<div className={classNames('grey-row-box no-hover', className)}>
<div className={'icon'}>
<FontAwesomeIcon icon={faDatabase} fixedWidth={true}/>
<GreyRowBox $hoverable={false} className={className}>
<div>
<FontAwesomeIcon icon={faDatabase} fixedWidth/>
</div>
<div className={'flex-1 ml-4'}>
<p className={'text-lg'}>{database.name}</p>
<div css={tw`flex-1 ml-4`}>
<p css={tw`text-lg`}>{database.name}</p>
</div>
<div className={'ml-8 text-center'}>
<p className={'text-sm'}>{database.connectionString}</p>
<p className={'mt-1 text-2xs text-neutral-500 uppercase select-none'}>Endpoint</p>
<div css={tw`ml-8 text-center`}>
<p css={tw`text-sm`}>{database.connectionString}</p>
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Endpoint</p>
</div>
<div className={'ml-8 text-center'}>
<p className={'text-sm'}>{database.allowConnectionsFrom}</p>
<p className={'mt-1 text-2xs text-neutral-500 uppercase select-none'}>Connections from</p>
<div css={tw`ml-8 text-center`}>
<p css={tw`text-sm`}>{database.allowConnectionsFrom}</p>
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Connections from</p>
</div>
<div className={'ml-8 text-center'}>
<p className={'text-sm'}>{database.username}</p>
<p className={'mt-1 text-2xs text-neutral-500 uppercase select-none'}>Username</p>
<div css={tw`ml-8 text-center`}>
<p css={tw`text-sm`}>{database.username}</p>
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Username</p>
</div>
<div className={'ml-8'}>
<button className={'btn btn-sm btn-secondary mr-2'} onClick={() => setConnectionVisible(true)}>
<FontAwesomeIcon icon={faEye} fixedWidth={true}/>
</button>
<div css={tw`ml-8`}>
<Button isSecondary css={tw`mr-2`} onClick={() => setConnectionVisible(true)}>
<FontAwesomeIcon icon={faEye} fixedWidth/>
</Button>
<Can action={'database.delete'}>
<button className={'btn btn-sm btn-secondary btn-red'} onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faTrashAlt} fixedWidth={true}/>
</button>
<Button color={'red'} isSecondary onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faTrashAlt} fixedWidth/>
</Button>
</Can>
</div>
</div>
</React.Fragment>
</GreyRowBox>
</>
);
};

View file

@ -5,12 +5,13 @@ import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import DatabaseRow from '@/components/server/databases/DatabaseRow';
import Spinner from '@/components/elements/Spinner';
import { CSSTransition } from 'react-transition-group';
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
import Can from '@/components/elements/Can';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import Fade from '@/components/elements/Fade';
export default () => {
const { uuid, featureLimits } = useServer();
@ -35,11 +36,11 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender byKey={'databases'} className={'mb-4'}/>
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
{(!databases.length && loading) ?
<Spinner size={'large'} centered={true}/>
<Spinner size={'large'} centered/>
:
<CSSTransition classNames={'fade'} timeout={250}>
<Fade timeout={150}>
<>
{databases.length > 0 ?
databases.map((database, index) => (
@ -50,28 +51,29 @@ export default () => {
/>
))
:
<p className={'text-center text-sm text-neutral-400'}>
<p css={tw`text-center text-sm text-neutral-400`}>
{featureLimits.databases > 0 ?
`It looks like you have no databases.`
'It looks like you have no databases.'
:
`Databases cannot be created for this server.`
'Databases cannot be created for this server.'
}
</p>
}
<Can action={'database.create'}>
{(featureLimits.databases > 0 && databases.length > 0) &&
<p className="text-center text-xs text-neutral-400 mt-2">
{databases.length} of {featureLimits.databases} databases have been allocated to this server.
<p css={tw`text-center text-xs text-neutral-400 mt-2`}>
{databases.length} of {featureLimits.databases} databases have been allocated to this
server.
</p>
}
{featureLimits.databases > 0 && featureLimits.databases !== databases.length &&
<div className={'mt-6 flex justify-end'}>
<div css={tw`mt-6 flex justify-end`}>
<CreateDatabaseButton/>
</div>
}
</Can>
</>
</CSSTransition>
</Fade>
}
</PageContentBlock>
);

View file

@ -6,6 +6,7 @@ import { ServerContext } from '@/state/server';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import { httpErrorToHuman } from '@/api/http';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
export default ({ databaseId, onUpdate }: {
databaseId: string;
@ -38,7 +39,7 @@ export default ({ databaseId, onUpdate }: {
};
return (
<Button className={'btn-secondary mr-2'} onClick={rotate} isLoading={loading}>
<Button isSecondary color={'primary'} css={tw`mr-2`} onClick={rotate} isLoading={loading}>
Rotate Password
</Button>
);

View file

@ -1,192 +1,134 @@
import React, { createRef, useEffect, useState } from 'react';
import React, { useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH';
import { CSSTransition } from 'react-transition-group';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faFileDownload } from '@fortawesome/free-solid-svg-icons/faFileDownload';
import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy';
import { faLevelUpAlt } from '@fortawesome/free-solid-svg-icons/faLevelUpAlt';
import {
faCopy,
faEllipsisH,
faFileDownload,
faLevelUpAlt,
faPencilAlt,
faTrashAlt,
IconDefinition,
} from '@fortawesome/free-solid-svg-icons';
import RenameFileModal from '@/components/server/files/RenameFileModal';
import { ServerContext } from '@/state/server';
import { join } from 'path';
import deleteFile from '@/api/server/files/deleteFile';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import copyFile from '@/api/server/files/copyFile';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can';
import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro';
import { FileObject } from '@/api/server/files/loadDirectory';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import DropdownMenu from '@/components/elements/DropdownMenu';
import styled from 'styled-components/macro';
import useEventListener from '@/plugins/useEventListener';
type ModalType = 'rename' | 'move';
export default ({ uuid }: { uuid: string }) => {
const menu = createRef<HTMLDivElement>();
const menuButton = createRef<HTMLDivElement>();
const [ menuVisible, setMenuVisible ] = useState(false);
const StyledRow = styled.div<{ $danger?: boolean }>`
${tw`p-2 flex items-center rounded`};
${props => props.$danger ? tw`hover:bg-red-100 hover:text-red-700` : tw`hover:bg-neutral-100 hover:text-neutral-700`};
`;
interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
icon: IconDefinition;
title: string;
$danger?: boolean;
}
const Row = ({ icon, title, ...props }: RowProps) => (
<StyledRow {...props}>
<FontAwesomeIcon icon={icon} css={tw`text-xs`}/>
<span css={tw`ml-2`}>{title}</span>
</StyledRow>
);
export default ({ file }: { file: FileObject }) => {
const onClickRef = useRef<DropdownMenu>(null);
const [ showSpinner, setShowSpinner ] = useState(false);
const [ modal, setModal ] = useState<ModalType | null>(null);
const [ posX, setPosX ] = useState(0);
const server = useServer();
const { addError, clearFlashes } = useFlash();
const file = ServerContext.useStoreState(state => state.files.contents.find(file => file.uuid === uuid));
const { uuid } = useServer();
const { mutate } = useFileManagerSwr();
const { clearAndAddHttpError, clearFlashes } = useFlash();
const directory = ServerContext.useStoreState(state => state.files.directory);
const { removeFile, getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files);
if (!file) {
return null;
}
const windowListener = (e: MouseEvent) => {
if (e.button === 2 || !menuVisible || !menu.current) {
return;
useEventListener(`pterodactyl:files:ctx:${file.uuid}`, (e: CustomEvent) => {
if (onClickRef.current) {
onClickRef.current.triggerMenu(e.detail);
}
if (e.target === menu.current || menu.current.contains(e.target as Node)) {
return;
}
if (e.target !== menu.current && !menu.current.contains(e.target as Node)) {
setMenuVisible(false);
}
};
});
const doDeletion = () => {
setShowSpinner(true);
clearFlashes('files');
deleteFile(server.uuid, join(directory, file.name))
.then(() => removeFile(uuid))
.catch(error => {
console.error('Error while attempting to delete a file.', error);
addError({ key: 'files', message: httpErrorToHuman(error) });
setShowSpinner(false);
});
// For UI speed, immediately remove the file from the listing before calling the deletion function.
// If the delete actually fails, we'll fetch the current directory contents again automatically.
mutate(files => files.filter(f => f.uuid !== file.uuid), false);
deleteFile(uuid, join(directory, file.name)).catch(error => {
mutate();
clearAndAddHttpError({ key: 'files', error });
});
};
const doCopy = () => {
setShowSpinner(true);
clearFlashes('files');
copyFile(server.uuid, join(directory, file.name))
.then(() => getDirectoryContents(directory))
copyFile(uuid, join(directory, file.name))
.then(() => mutate())
.catch(error => {
console.error('Error while attempting to copy file.', error);
addError({ key: 'files', message: httpErrorToHuman(error) });
setShowSpinner(false);
clearAndAddHttpError({ key: 'files', error });
});
};
const doDownload = () => {
setShowSpinner(true);
clearFlashes('files');
getFileDownloadUrl(server.uuid, join(directory, file.name))
getFileDownloadUrl(uuid, join(directory, file.name))
.then(url => {
// @ts-ignore
window.location = url;
})
.catch(error => {
console.error(error);
addError({ key: 'files', message: httpErrorToHuman(error) });
})
.catch(error => clearAndAddHttpError({ key: 'files', error }))
.then(() => setShowSpinner(false));
};
useEffect(() => {
menuVisible
? document.addEventListener('click', windowListener)
: document.removeEventListener('click', windowListener);
if (menuVisible && menu.current) {
menu.current.setAttribute(
'style', `margin-top: -0.35rem; left: ${Math.round(posX - menu.current.clientWidth)}px`,
);
}
}, [ menuVisible ]);
useEffect(() => () => {
document.removeEventListener('click', windowListener);
}, []);
return (
<div key={`dropdown:${file.uuid}`}>
<div
ref={menuButton}
className={'p-3 hover:text-white'}
onClick={e => {
e.preventDefault();
if (!menuVisible) {
setPosX(e.clientX);
}
setModal(null);
setMenuVisible(!menuVisible);
}}
>
<FontAwesomeIcon icon={faEllipsisH}/>
<RenameFileModal
file={file}
visible={modal === 'rename' || modal === 'move'}
useMoveTerminology={modal === 'move'}
onDismissed={() => {
setModal(null);
setMenuVisible(false);
}}
/>
<SpinnerOverlay visible={showSpinner} fixed={true} size={'large'}/>
</div>
<CSSTransition timeout={250} in={menuVisible} unmountOnExit={true} classNames={'fade'}>
<div
ref={menu}
onClick={e => {
e.stopPropagation();
setMenuVisible(false);
}}
className={'absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48'}
>
<Can action={'file.update'}>
<div
onClick={() => setModal('rename')}
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
>
<FontAwesomeIcon icon={faPencilAlt} className={'text-xs'}/>
<span className={'ml-2'}>Rename</span>
</div>
<div
onClick={() => setModal('move')}
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
>
<FontAwesomeIcon icon={faLevelUpAlt} className={'text-xs'}/>
<span className={'ml-2'}>Move</span>
</div>
</Can>
<Can action={'file.create'}>
<div
onClick={() => doCopy()}
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
>
<FontAwesomeIcon icon={faCopy} className={'text-xs'}/>
<span className={'ml-2'}>Copy</span>
</div>
</Can>
<div
className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'}
onClick={() => doDownload()}
>
<FontAwesomeIcon icon={faFileDownload} className={'text-xs'}/>
<span className={'ml-2'}>Download</span>
</div>
<Can action={'file.delete'}>
<div
onClick={() => doDeletion()}
className={'hover:text-red-700 p-2 flex items-center hover:bg-red-100 rounded'}
>
<FontAwesomeIcon icon={faTrashAlt} className={'text-xs'}/>
<span className={'ml-2'}>Delete</span>
</div>
</Can>
<DropdownMenu
ref={onClickRef}
renderToggle={onClick => (
<div css={tw`p-3 hover:text-white`} onClick={onClick}>
<FontAwesomeIcon icon={faEllipsisH}/>
<RenameFileModal
file={file}
visible={!!modal}
useMoveTerminology={modal === 'move'}
onDismissed={() => setModal(null)}
/>
<SpinnerOverlay visible={showSpinner} fixed size={'large'}/>
</div>
</CSSTransition>
</div>
)}
>
<Can action={'file.update'}>
<Row onClick={() => setModal('rename')} icon={faPencilAlt} title={'Rename'}/>
<Row onClick={() => setModal('move')} icon={faLevelUpAlt} title={'Move'}/>
</Can>
{file.isFile &&
<Can action={'file.create'}>
<Row onClick={doCopy} icon={faCopy} title={'Copy'}/>
</Can>
}
<Row onClick={doDownload} icon={faFileDownload} title={'Download'}/>
<Can action={'file.delete'}>
<Row onClick={doDeletion} icon={faTrashAlt} title={'Delete'} $danger/>
</Can>
</DropdownMenu>
);
};

View file

@ -1,30 +1,33 @@
import React, { lazy, useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import getFileContents from '@/api/server/files/getFileContents';
import useRouter from 'use-react-router';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import saveFileContents from '@/api/server/files/saveFileContents';
import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs';
import { useParams } from 'react-router';
import { useHistory, useLocation, useParams } from 'react-router';
import FileNameModal from '@/components/server/files/FileNameModal';
import Can from '@/components/elements/Can';
import FlashMessageRender from '@/components/FlashMessageRender';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor'));
export default () => {
const [ error, setError ] = useState('');
const { action } = useParams();
const { history, location: { hash } } = useRouter();
const [ loading, setLoading ] = useState(action === 'edit');
const [ content, setContent ] = useState('');
const [ modalVisible, setModalVisible ] = useState(false);
const history = useHistory();
const { hash } = useLocation();
const { id, uuid } = ServerContext.useStoreState(state => state.server.data!);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -81,16 +84,17 @@ export default () => {
return (
<PageContentBlock>
<FlashMessageRender byKey={'files:view'} className={'mb-4'}/>
<FileManagerBreadcrumbs withinFileEditor={true} isNewFile={action !== 'edit'}/>
{(name || hash.replace(/^#/, '')).endsWith('.pteroignore') &&
<div className={'mb-4 p-4 border-l-4 bg-neutral-900 rounded border-cyan-400'}>
<p className={'text-neutral-300 text-sm'}>
You're editing a <code className={'font-mono bg-black rounded py-px px-1'}>.pteroignore</code> file.
<FlashMessageRender byKey={'files:view'} css={tw`mb-4`}/>
<FileManagerBreadcrumbs withinFileEditor isNewFile={action !== 'edit'}/>
{hash.replace(/^#/, '').endsWith('.pteroignore') &&
<div css={tw`mb-4 p-4 border-l-4 bg-neutral-900 rounded border-cyan-400`}>
<p css={tw`text-neutral-300 text-sm`}>
You&apos;re editing
a <code css={tw`font-mono bg-black rounded py-px px-1`}>.pteroignore</code> file.
Any files or directories listed in here will be excluded from backups. Wildcards are supported by
using an asterisk (<code className={'font-mono bg-black rounded py-px px-1'}>*</code>). You can
using an asterisk (<code css={tw`font-mono bg-black rounded py-px px-1`}>*</code>). You can
negate a prior rule by prepending an exclamation point
(<code className={'font-mono bg-black rounded py-px px-1'}>!</code>).
(<code css={tw`font-mono bg-black rounded py-px px-1`}>!</code>).
</p>
</div>
}
@ -102,7 +106,7 @@ export default () => {
save(name);
}}
/>
<div className={'relative'}>
<div css={tw`relative`}>
<SpinnerOverlay visible={loading}/>
<LazyAceEditor
initialModePath={hash.replace(/^#/, '') || 'plain_text'}
@ -113,18 +117,18 @@ export default () => {
onContentSaved={() => save()}
/>
</div>
<div className={'flex justify-end mt-4'}>
<div css={tw`flex justify-end mt-4`}>
{action === 'edit' ?
<Can action={'file.update'}>
<button className={'btn btn-primary btn-sm'} onClick={() => save()}>
<Button onClick={() => save()}>
Save Content
</button>
</Button>
</Can>
:
<Can action={'file.create'}>
<button className={'btn btn-primary btn-sm'} onClick={() => setModalVisible(true)}>
<Button onClick={() => setModalVisible(true)}>
Create File
</button>
</Button>
</Can>
}
</div>

View file

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import { NavLink } from 'react-router-dom';
import { cleanDirectoryPath } from '@/helpers';
import tw from 'twin.macro';
interface Props {
withinFileEditor?: boolean;
@ -32,11 +33,11 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
});
return (
<div className={'flex items-center text-sm mb-4 text-neutral-500'}>
/<span className={'px-1 text-neutral-300'}>home</span>/
<div css={tw`flex items-center text-sm mb-4 text-neutral-500`}>
/<span css={tw`px-1 text-neutral-300`}>home</span>/
<NavLink
to={`/server/${id}/files`}
className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'}
css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}
>
container
</NavLink>/
@ -46,18 +47,18 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
<React.Fragment key={index}>
<NavLink
to={`/server/${id}/files#${crumb.path}`}
className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'}
css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`}
>
{crumb.name}
</NavLink>/
</React.Fragment>
:
<span key={index} className={'px-1 text-neutral-300'}>{crumb.name}</span>
<span key={index} css={tw`px-1 text-neutral-300`}>{crumb.name}</span>
))
}
{file &&
<React.Fragment>
<span className={'px-1 text-neutral-300'}>{decodeURIComponent(file)}</span>
<span css={tw`px-1 text-neutral-300`}>{decodeURIComponent(file)}</span>
</React.Fragment>
}
</div>

View file

@ -1,8 +1,4 @@
import React, { useEffect, useState } from 'react';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import React, { useEffect } from 'react';
import { httpErrorToHuman } from '@/api/http';
import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner';
@ -10,11 +6,15 @@ import FileObjectRow from '@/components/server/files/FileObjectRow';
import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs';
import { FileObject } from '@/api/server/files/loadDirectory';
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import Can from '@/components/elements/Can';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError';
import useRouter from 'use-react-router';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import useServer from '@/plugins/useServer';
import { ServerContext } from '@/state/server';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
const sortFiles = (files: FileObject[]): FileObject[] => {
return files.sort((a, b) => a.name.localeCompare(b.name))
@ -22,93 +22,72 @@ const sortFiles = (files: FileObject[]): FileObject[] => {
};
export default () => {
const [ error, setError ] = useState('');
const [ loading, setLoading ] = useState(true);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { id } = ServerContext.useStoreState(state => state.server.data!);
const { contents: files } = ServerContext.useStoreState(state => state.files);
const { getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files);
const loadContents = () => {
setError('');
clearFlashes();
setLoading(true);
getDirectoryContents(window.location.hash)
.then(() => setLoading(false))
.catch(error => {
console.error(error.message, { error });
setError(httpErrorToHuman(error));
});
};
const { id } = useServer();
const { hash } = useLocation();
const { data: files, error, mutate } = useFileManagerSwr();
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
useEffect(() => {
loadContents();
}, []);
// We won't automatically mutate the store when the component re-mounts, otherwise because of
// my (horrible) programming this fires off way more than we intend it to.
mutate();
setDirectory(hash.length > 0 ? hash : '/');
}, [ hash ]);
if (error) {
return (
<ServerError
message={error}
onRetry={() => loadContents()}
/>
<ServerError message={httpErrorToHuman(error)} onRetry={() => mutate()}/>
);
}
return (
<PageContentBlock>
<FlashMessageRender byKey={'files'} className={'mb-4'}/>
<React.Fragment>
<FileManagerBreadcrumbs/>
{
loading ?
<Spinner size={'large'} centered={true}/>
:
<React.Fragment>
{!files.length ?
<p className={'text-sm text-neutral-400 text-center'}>
This directory seems to be empty.
</p>
:
<CSSTransition classNames={'fade'} timeout={250} appear={true} in={true}>
<React.Fragment>
<div>
{files.length > 250 ?
<React.Fragment>
<div className={'rounded bg-yellow-400 mb-px p-3'}>
<p className={'text-yellow-900 text-sm text-center'}>
This directory is too large to display in the browser,
limiting the output to the first 250 files.
</p>
</div>
{
sortFiles(files.slice(0, 250)).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
))
}
</React.Fragment>
:
sortFiles(files).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
))
}
<PageContentBlock showFlashKey={'files'}>
<FileManagerBreadcrumbs/>
{
!files ?
<Spinner size={'large'} centered/>
:
<>
{!files.length ?
<p css={tw`text-sm text-neutral-400 text-center`}>
This directory seems to be empty.
</p>
:
<CSSTransition classNames={'fade'} timeout={150} appear in>
<React.Fragment>
<div>
{files.length > 250 &&
<div css={tw`rounded bg-yellow-400 mb-px p-3`}>
<p css={tw`text-yellow-900 text-sm text-center`}>
This directory is too large to display in the browser,
limiting the output to the first 250 files.
</p>
</div>
</React.Fragment>
</CSSTransition>
}
<Can action={'file.create'}>
<div className={'flex justify-end mt-8'}>
<NewDirectoryButton/>
<Link
to={`/server/${id}/files/new${window.location.hash}`}
className={'btn btn-sm btn-primary'}
>
New File
</Link>
</div>
</Can>
</React.Fragment>
}
</React.Fragment>
}
{
sortFiles(files.slice(0, 250)).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
))
}
</div>
</React.Fragment>
</CSSTransition>
}
<Can action={'file.create'}>
<div css={tw`flex justify-end mt-8`}>
<NewDirectoryButton/>
<Button
// @ts-ignore
as={Link}
to={`/server/${id}/files/new${window.location.hash}`}
>
New File
</Button>
</div>
</Can>
</>
}
</PageContentBlock>
);
};

View file

@ -5,6 +5,8 @@ import { object, string } from 'yup';
import Field from '@/components/elements/Field';
import { ServerContext } from '@/state/server';
import { join } from 'path';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
type Props = RequiredModalProps & {
onFileNamed: (name: string) => void;
@ -44,12 +46,10 @@ export default ({ onFileNamed, onDismissed, ...props }: Props) => {
name={'fileName'}
label={'File Name'}
description={'Enter the name that this file should be saved as.'}
autoFocus={true}
autoFocus
/>
<div className={'mt-6 text-right'}>
<button className={'btn btn-primary btn-sm'}>
Create File
</button>
<div css={tw`mt-6 text-right`}>
<Button>Create File</Button>
</div>
</Form>
</Modal>

View file

@ -1,75 +1,83 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileImport } from '@fortawesome/free-solid-svg-icons/faFileImport';
import { faFileAlt } from '@fortawesome/free-solid-svg-icons/faFileAlt';
import { faFolder } from '@fortawesome/free-solid-svg-icons/faFolder';
import { faFileAlt, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, cleanDirectoryPath } from '@/helpers';
import differenceInHours from 'date-fns/difference_in_hours';
import format from 'date-fns/format';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
import React from 'react';
import { differenceInHours, format, formatDistanceToNow } from 'date-fns';
import React, { memo } from 'react';
import { FileObject } from '@/api/server/files/loadDirectory';
import FileDropdownMenu from '@/components/server/files/FileDropdownMenu';
import { ServerContext } from '@/state/server';
import { NavLink } from 'react-router-dom';
import useRouter from 'use-react-router';
import { NavLink, useHistory, useRouteMatch } from 'react-router-dom';
import tw from 'twin.macro';
import isEqual from 'react-fast-compare';
import styled from 'styled-components/macro';
export default ({ file }: { file: FileObject }) => {
const Row = styled.div`
${tw`flex bg-neutral-700 rounded-sm mb-px text-sm hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600`};
`;
const FileObjectRow = ({ file }: { file: FileObject }) => {
const directory = ServerContext.useStoreState(state => state.files.directory);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const { match, history } = useRouter();
const history = useHistory();
const match = useRouteMatch();
const onRowClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
// Don't rely on the onClick to work with the generated URL. Because of the way this
// component re-renders you'll get redirected into a nested directory structure since
// it'll cause the directory variable to update right away when you click.
//
// Just trust me future me, leave this be.
if (!file.isFile) {
e.preventDefault();
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
}
};
return (
<div
<Row
key={file.name}
className={`
flex bg-neutral-700 rounded-sm mb-px text-sm
hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600
`}
onContextMenu={e => {
e.preventDefault();
window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX }));
}}
>
<NavLink
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`}
className={'flex flex-1 text-neutral-300 no-underline p-3'}
onClick={e => {
// Don't rely on the onClick to work with the generated URL. Because of the way this
// component re-renders you'll get redirected into a nested directory structure since
// it'll cause the directory variable to update right away when you click.
//
// Just trust me future me, leave this be.
if (!file.isFile) {
e.preventDefault();
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
}
}}
css={tw`flex flex-1 text-neutral-300 no-underline p-3`}
onClick={onRowClick}
>
<div className={'flex-none text-neutral-400 mr-4 text-lg pl-3'}>
<div css={tw`flex-none text-neutral-400 mr-4 text-lg pl-3`}>
{file.isFile ?
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : faFileAlt}/>
:
<FontAwesomeIcon icon={faFolder}/>
}
</div>
<div className={'flex-1'}>
<div css={tw`flex-1`}>
{file.name}
</div>
{file.isFile &&
<div className={'w-1/6 text-right mr-4'}>
<div css={tw`w-1/6 text-right mr-4`}>
{bytesToHuman(file.size)}
</div>
}
<div
className={'w-1/5 text-right mr-4'}
css={tw`w-1/5 text-right mr-4`}
title={file.modifiedAt.toString()}
>
{Math.abs(differenceInHours(file.modifiedAt, new Date())) > 48 ?
format(file.modifiedAt, 'MMM Do, YYYY h:mma')
format(file.modifiedAt, 'MMM do, yyyy h:mma')
:
distanceInWordsToNow(file.modifiedAt, { addSuffix: true })
formatDistanceToNow(file.modifiedAt, { addSuffix: true })
}
</div>
</NavLink>
<FileDropdownMenu uuid={file.uuid}/>
</div>
<FileDropdownMenu file={file}/>
</Row>
);
};
export default memo(FileObjectRow, (prevProps, nextProps) => isEqual(prevProps.file, nextProps.file));

View file

@ -7,6 +7,13 @@ import { join } from 'path';
import { object, string } from 'yup';
import createDirectory from '@/api/server/files/createDirectory';
import v4 from 'uuid/v4';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import { mutate } from 'swr';
import useServer from '@/plugins/useServer';
import { FileObject } from '@/api/server/files/loadDirectory';
import { useLocation } from 'react-router';
import useFlash from '@/plugins/useFlash';
interface Values {
directoryName: string;
@ -16,37 +23,44 @@ const schema = object().shape({
directoryName: string().required('A valid directory name must be provided.'),
});
export default () => {
const [ visible, setVisible ] = useState(false);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const directory = ServerContext.useStoreState(state => state.files.directory);
const pushFile = ServerContext.useStoreActions(actions => actions.files.pushFile);
const generateDirectoryData = (name: string): FileObject => ({
uuid: v4(),
name: name,
mode: '0644',
size: 0,
isFile: false,
isEditable: false,
isSymlink: false,
mimetype: '',
createdAt: new Date(),
modifiedAt: new Date(),
});
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
createDirectory(uuid, directory, values.directoryName)
export default () => {
const { uuid } = useServer();
const { hash } = useLocation();
const { clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false);
const directory = ServerContext.useStoreState(state => state.files.directory);
const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers<Values>) => {
createDirectory(uuid, directory, directoryName)
.then(() => {
pushFile({
uuid: v4(),
name: values.directoryName,
mode: '0644',
size: 0,
isFile: false,
isEditable: false,
isSymlink: false,
mimetype: '',
createdAt: new Date(),
modifiedAt: new Date(),
});
mutate(
`${uuid}:files:${hash}`,
(data: FileObject[]) => [ ...data, generateDirectoryData(directoryName) ],
);
setVisible(false);
})
.catch(error => {
console.error(error);
setSubmitting(false);
clearAndAddHttpError({ key: 'files', error });
});
};
return (
<React.Fragment>
<>
<Formik
onSubmit={submit}
validationSchema={schema}
@ -62,33 +76,33 @@ export default () => {
resetForm();
}}
>
<Form className={'m-0'}>
<Form css={tw`m-0`}>
<Field
id={'directoryName'}
name={'directoryName'}
label={'Directory Name'}
/>
<p className={'text-xs mt-2 text-neutral-400'}>
<span className={'text-neutral-200'}>This directory will be created as</span>
<p css={tw`text-xs mt-2 text-neutral-400`}>
<span css={tw`text-neutral-200`}>This directory will be created as</span>
&nbsp;/home/container/
<span className={'text-cyan-200'}>
<span css={tw`text-cyan-200`}>
{decodeURIComponent(
join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, ''),
)}
</span>
</p>
<div className={'flex justify-end'}>
<button className={'btn btn-sm btn-primary mt-8'}>
<div css={tw`flex justify-end`}>
<Button css={tw`mt-8`}>
Create Directory
</button>
</Button>
</div>
</Form>
</Modal>
)}
</Formik>
<button className={'btn btn-sm btn-secondary mr-2'} onClick={() => setVisible(true)}>
<Button isSecondary css={tw`mr-2`} onClick={() => setVisible(true)}>
Create Directory
</button>
</React.Fragment>
</Button>
</>
);
};

View file

@ -6,7 +6,11 @@ import { join } from 'path';
import renameFile from '@/api/server/files/renameFile';
import { ServerContext } from '@/state/server';
import { FileObject } from '@/api/server/files/loadDirectory';
import classNames from 'classnames';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import useServer from '@/plugins/useServer';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import useFlash from '@/plugins/useFlash';
interface FormikValues {
name: string;
@ -15,47 +19,44 @@ interface FormikValues {
type Props = RequiredModalProps & { file: FileObject; useMoveTerminology?: boolean };
export default ({ file, useMoveTerminology, ...props }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { uuid } = useServer();
const { mutate } = useFileManagerSwr();
const { clearAndAddHttpError } = useFlash();
const directory = ServerContext.useStoreState(state => state.files.directory);
const { pushFile, removeFile } = ServerContext.useStoreActions(actions => actions.files);
const submit = (values: FormikValues, { setSubmitting }: FormikHelpers<FormikValues>) => {
const submit = ({ name }: FormikValues, { setSubmitting }: FormikHelpers<FormikValues>) => {
const len = name.split('/').length;
if (!useMoveTerminology && len === 1) {
// Rename the file within this directory.
mutate(files => files.map(f => f.uuid === file.uuid ? { ...f, name } : f), false);
} else if ((useMoveTerminology || len > 1) && file.uuid.length) {
// Remove the file from this directory since they moved it elsewhere.
mutate(files => files.filter(f => f.uuid !== file.uuid), false);
}
const renameFrom = join(directory, file.name);
const renameTo = join(directory, values.name);
const renameTo = join(directory, name);
renameFile(uuid, { renameFrom, renameTo })
.then(() => {
if (!useMoveTerminology && values.name.split('/').length === 1) {
pushFile({ ...file, name: values.name });
}
if ((useMoveTerminology || values.name.split('/').length > 1) && file.uuid.length > 0) {
removeFile(file.uuid);
}
props.onDismissed();
})
.then(() => props.onDismissed())
.catch(error => {
mutate();
setSubmitting(false);
console.error(error);
clearAndAddHttpError({ key: 'files', error });
});
};
return (
<Formik
onSubmit={submit}
initialValues={{ name: file.name }}
>
<Formik onSubmit={submit} initialValues={{ name: file.name }}>
{({ isSubmitting, values }) => (
<Modal {...props} dismissable={!isSubmitting} showSpinnerOverlay={isSubmitting}>
<Form className={'m-0'}>
<Form css={tw`m-0`}>
<div
className={classNames('flex', {
'items-center': useMoveTerminology,
'items-end': !useMoveTerminology,
})}
css={[
tw`flex`,
useMoveTerminology ? tw`items-center` : tw`items-end`,
]}
>
<div className={'flex-1 mr-6'}>
<div css={tw`flex-1 mr-6`}>
<Field
type={'string'}
id={'file_name'}
@ -65,18 +66,16 @@ export default ({ file, useMoveTerminology, ...props }: Props) => {
? 'Enter the new name and directory of this file or folder, relative to the current directory.'
: undefined
}
autoFocus={true}
autoFocus
/>
</div>
<div>
<button className={'btn btn-sm btn-primary'}>
{useMoveTerminology ? 'Move' : 'Rename'}
</button>
<Button>{useMoveTerminology ? 'Move' : 'Rename'}</Button>
</div>
</div>
{useMoveTerminology &&
<p className={'text-xs mt-2 text-neutral-400'}>
<strong className={'text-neutral-200'}>New location:</strong>
<p css={tw`text-xs mt-2 text-neutral-400`}>
<strong css={tw`text-neutral-200`}>New location:</strong>
&nbsp;/home/container/{join(directory, values.name).replace(/^(\.\.\/|\/)+/, '')}
</p>
}

View file

@ -0,0 +1,115 @@
import React, { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import styled from 'styled-components/macro';
import PageContentBlock from '@/components/elements/PageContentBlock';
import GreyRowBox from '@/components/elements/GreyRowBox';
import Button from '@/components/elements/Button';
import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useSWR from 'swr';
import getServerAllocations from '@/api/server/network/getServerAllocations';
import { Allocation } from '@/api/server/getServer';
import Spinner from '@/components/elements/Spinner';
import setPrimaryServerAllocation from '@/api/server/network/setPrimaryServerAllocation';
import useFlash from '@/plugins/useFlash';
import { Textarea } from '@/components/elements/Input';
import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes';
import { debounce } from 'debounce';
import InputSpinner from '@/components/elements/InputSpinner';
const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm block`}`;
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
const NetworkContainer = () => {
const { uuid, allocations } = useServer();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ loading, setLoading ] = useState<false | number>(false);
const { data, error, mutate } = useSWR<Allocation[]>(uuid, key => getServerAllocations(key), { initialData: allocations });
const setPrimaryAllocation = (id: number) => {
clearFlashes('server:network');
const initial = data;
mutate(data?.map(a => a.id === id ? { ...a, isDefault: true } : { ...a, isDefault: false }), false);
setPrimaryServerAllocation(uuid, id)
.catch(error => {
clearAndAddHttpError({ key: 'server:network', error });
mutate(initial, false);
});
};
const setAllocationNotes = debounce((id: number, notes: string) => {
setLoading(id);
clearFlashes('server:network');
setServerAllocationNotes(uuid, id, notes)
.then(() => mutate(data?.map(a => a.id === id ? { ...a, notes } : a), false))
.catch(error => {
clearAndAddHttpError({ key: 'server:network', error });
})
.then(() => setLoading(false));
}, 750);
useEffect(() => {
if (error) {
clearAndAddHttpError({ key: 'server:network', error });
}
}, [ error ]);
return (
<PageContentBlock showFlashKey={'server:network'}>
{!data ?
<Spinner size={'large'} centered/>
:
data.map(({ id, ip, port, alias, notes, isDefault }, index) => (
<GreyRowBox key={`${ip}:${port}`} css={index > 0 ? tw`mt-2` : undefined} $hoverable={false}>
<div css={tw`pl-4 pr-6 text-neutral-400`}>
<FontAwesomeIcon icon={faNetworkWired}/>
</div>
<div css={tw`mr-4`}>
<Code>{alias || ip}</Code>
<Label>IP Address</Label>
</div>
<div>
<Code>{port}</Code>
<Label>Port</Label>
</div>
<div css={tw`px-8 flex-1 self-start`}>
<InputSpinner visible={loading === id}>
<Textarea
css={tw`bg-neutral-800 hover:border-neutral-600 border-transparent`}
placeholder={'Notes'}
defaultValue={notes || undefined}
onChange={e => setAllocationNotes(id, e.currentTarget.value)}
/>
</InputSpinner>
</div>
<div css={tw`w-32 text-right`}>
{isDefault ?
<span css={tw`bg-green-500 py-1 px-2 rounded text-green-50 text-xs`}>
Primary
</span>
:
<Can action={'allocations.update'}>
<Button
isSecondary
size={'xsmall'}
color={'primary'}
onClick={() => setPrimaryAllocation(id)}
>
Make Primary
</Button>
</Can>
}
</div>
</GreyRowBox>
))
}
</PageContentBlock>
);
};
export default NetworkContainer;

View file

@ -1,26 +0,0 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
type Props = RequiredModalProps & {
onConfirmed: () => void;
}
export default ({ onConfirmed, ...props }: Props) => (
<Modal {...props}>
<h2>Confirm task deletion</h2>
<p className={'text-sm mt-4'}>
Are you sure you want to delete this task? This action cannot be undone.
</p>
<div className={'flex items-center justify-end mt-8'}>
<button className={'btn btn-secondary btn-sm'} onClick={() => props.onDismissed()}>
Cancel
</button>
<button className={'btn btn-red btn-sm ml-4'} onClick={() => {
props.onDismissed();
onConfirmed();
}}>
Delete Task
</button>
</div>
</Modal>
);

Some files were not shown because too many files have changed in this diff Show more