Merge branch 'develop' into fix/2071
This commit is contained in:
commit
a9bb692112
194 changed files with 5396 additions and 5416 deletions
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
}));
|
||||
};
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}`);
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -6,4 +6,4 @@ export default (uuid: string): Promise<void> => {
|
|||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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))))
|
||||
|
|
|
@ -15,4 +15,4 @@ export default (uuid: string, params: Params, subuser?: Subuser): Promise<Subuse
|
|||
.then(data => resolve(rawDataToServerSubuser(data.data)))
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
11
resources/scripts/api/transformers.ts
Normal file
11
resources/scripts/api/transformers.ts
Normal 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,
|
||||
});
|
35
resources/scripts/assets/css/GlobalStylesheet.ts
Normal file
35
resources/scripts/assets/css/GlobalStylesheet.ts
Normal 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;
|
||||
}
|
||||
`;
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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`}>
|
||||
© 2015 - 2020
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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:
|
||||
{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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,16 +1,14 @@
|
|||
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, megabytesToHuman } 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 +18,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,110 +50,114 @@ 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 ? megabytesToHuman(server.limits.disk) : "Unlimited";
|
||||
const memorylimit = server.limits.memory != 0 ? megabytesToHuman(server.limits.memory) : "Unlimited";
|
||||
|
||||
const disklimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : "Unlimited";
|
||||
const memorylimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : "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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
;
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
43
resources/scripts/components/elements/Fade.tsx
Normal file
43
resources/scripts/components/elements/Fade.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
12
resources/scripts/components/elements/GreyRowBox.tsx
Normal file
12
resources/scripts/components/elements/GreyRowBox.tsx
Normal 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`};
|
||||
}
|
||||
`;
|
81
resources/scripts/components/elements/Input.tsx
Normal file
81
resources/scripts/components/elements/Input.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
9
resources/scripts/components/elements/Label.tsx
Normal file
9
resources/scripts/components/elements/Label.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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`}>
|
||||
© 2015 - 2020
|
||||
<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;
|
||||
|
|
|
@ -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}%` }}/>
|
||||
|
|
36
resources/scripts/components/elements/Select.tsx
Normal file
36
resources/scripts/components/elements/Select.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
31
resources/scripts/components/elements/SubNavigation.tsx
Normal file
31
resources/scripts/components/elements/SubNavigation.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import ScreenBlock from '@/components/screens/ScreenBlock';
|
||||
|
||||
interface Props {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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, megabytesToHuman } 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,59 +56,45 @@ export default () => {
|
|||
};
|
||||
}, [ instance, connected ]);
|
||||
|
||||
const disklimit = server.limits.disk != 0 ? megabytesToHuman(server.limits.disk) : "Unlimited";
|
||||
const memorylimit = server.limits.memory != 0 ? megabytesToHuman(server.limits.memory) : "Unlimited";
|
||||
const disklimit = server.limits.disk ? megabytesToHuman(server.limits.disk) : 'Unlimited';
|
||||
const memorylimit = server.limits.memory ? megabytesToHuman(server.limits.memory) : '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`),
|
||||
]}
|
||||
/>
|
||||
{status}
|
||||
</p>
|
||||
<p className={'text-xs mt-2'}>
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrochip}
|
||||
fixedWidth={true}
|
||||
className={'mr-1'}
|
||||
/>
|
||||
{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'}
|
||||
/>
|
||||
{bytesToHuman(memory)}
|
||||
<span className={'text-neutral-500'}> / {memorylimit}</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 className={'text-xs mt-2'}>
|
||||
<FontAwesomeIcon
|
||||
icon={faHdd}
|
||||
fixedWidth={true}
|
||||
className={'mr-1'}
|
||||
/>
|
||||
{bytesToHuman(disk)}
|
||||
|
||||
<span className={'text-neutral-500'}> / {disklimit}</span>
|
||||
<p css={tw`text-xs mt-2`}>
|
||||
<FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/> {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();
|
||||
|
@ -141,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)}/>
|
||||
|
@ -160,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>
|
||||
|
@ -170,7 +133,7 @@ export default () => {
|
|||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={'flex-1 ml-4'}>
|
||||
<div css={tw`flex-1 ml-4`}>
|
||||
<SuspenseSpinner>
|
||||
<ChunkedConsole/>
|
||||
<ChunkedStatGraphs/>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
30
resources/scripts/components/server/StopOrKillButton.tsx
Normal file
30
resources/scripts/components/server/StopOrKillButton.tsx
Normal 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;
|
|
@ -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're having some trouble connecting to your server, please wait...
|
||||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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>
|
||||
/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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
/home/container/{join(directory, values.name).replace(/^(\.\.\/|\/)+/, '')}
|
||||
</p>
|
||||
}
|
||||
|
|
115
resources/scripts/components/server/network/NetworkContainer.tsx
Normal file
115
resources/scripts/components/server/network/NetworkContainer.tsx
Normal 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;
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue