Merge branch 'develop' into patch-1
This commit is contained in:
commit
574855a4ac
33 changed files with 654 additions and 270 deletions
15
resources/scripts/api/server/createServerDatabase.ts
Normal file
15
resources/scripts/api/server/createServerDatabase.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { rawDataToServerDatabase, ServerDatabase } from '@/api/server/getServerDatabases';
|
||||
import http from '@/api/http';
|
||||
|
||||
export default (uuid: string, data: { connectionsFrom: string; databaseName: string }): Promise<ServerDatabase> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.post(`/api/client/servers/${uuid}/databases`, {
|
||||
database: data.databaseName,
|
||||
remote: data.connectionsFrom,
|
||||
}, {
|
||||
params: { include: 'password' },
|
||||
})
|
||||
.then(response => resolve(rawDataToServerDatabase(response.data.attributes)))
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
9
resources/scripts/api/server/deleteServerDatabase.ts
Normal file
9
resources/scripts/api/server/deleteServerDatabase.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import http from '@/api/http';
|
||||
|
||||
export default (uuid: string, database: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.delete(`/api/client/servers/${uuid}/databases/${database}`)
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
31
resources/scripts/api/server/getServerDatabases.ts
Normal file
31
resources/scripts/api/server/getServerDatabases.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import http from '@/api/http';
|
||||
|
||||
export interface ServerDatabase {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
connectionString: string;
|
||||
allowConnectionsFrom: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export const rawDataToServerDatabase = (data: any): ServerDatabase => ({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
username: data.username,
|
||||
connectionString: `${data.host.address}:${data.host.port}`,
|
||||
allowConnectionsFrom: data.connections_from,
|
||||
password: data.relationships && data.relationships.password ? data.relationships.password.attributes.password : undefined,
|
||||
});
|
||||
|
||||
export default (uuid: string, includePassword: boolean = true): Promise<ServerDatabase[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`/api/client/servers/${uuid}/databases`, {
|
||||
params: includePassword ? { include: 'password' } : undefined,
|
||||
})
|
||||
.then(response => resolve(
|
||||
(response.data.data || []).map((item: any) => rawDataToServerDatabase(item.attributes))
|
||||
))
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
|
@ -1,16 +1,16 @@
|
|||
import React from 'react';
|
||||
import MessageBox from '@/components/MessageBox';
|
||||
import { State, useStoreState } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
||||
type Props = Readonly<{
|
||||
byKey?: string;
|
||||
spacerClass?: string;
|
||||
withBottomSpace?: boolean;
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
export default ({ withBottomSpace, spacerClass, byKey }: Props) => {
|
||||
const flashes = useStoreState((state: State<ApplicationState>) => state.flashes.items);
|
||||
export default ({ className, spacerClass, byKey }: Props) => {
|
||||
const flashes = useStoreState((state: State<ApplicationStore>) => state.flashes.items);
|
||||
|
||||
let filtered = flashes;
|
||||
if (byKey) {
|
||||
|
@ -21,9 +21,8 @@ export default ({ withBottomSpace, spacerClass, byKey }: Props) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
// noinspection PointlessBooleanExpressionJS
|
||||
return (
|
||||
<div className={withBottomSpace === false ? undefined : 'mb-2'}>
|
||||
<div className={className}>
|
||||
{
|
||||
filtered.map((flash, index) => (
|
||||
<React.Fragment key={flash.id || flash.type + flash.message}>
|
||||
|
|
|
@ -4,14 +4,14 @@ import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
|
|||
import { httpErrorToHuman } from '@/api/http';
|
||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
||||
export default () => {
|
||||
const [ isSubmitting, setSubmitting ] = React.useState(false);
|
||||
const [ email, setEmail ] = React.useState('');
|
||||
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
const handleFieldUpdate = (e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value);
|
||||
|
||||
|
|
|
@ -4,15 +4,15 @@ import loginCheckpoint from '@/api/auth/loginCheckpoint';
|
|||
import { httpErrorToHuman } from '@/api/http';
|
||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import { StaticContext } from 'react-router';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
||||
export default ({ history, location: { state } }: RouteComponentProps<{}, StaticContext, { token?: string }>) => {
|
||||
const [ code, setCode ] = useState('');
|
||||
const [ isLoading, setIsLoading ] = useState(false);
|
||||
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
if (!state || !state.token) {
|
||||
history.replace('/auth/login');
|
||||
|
|
|
@ -5,14 +5,14 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
||||
export default ({ history }: RouteComponentProps) => {
|
||||
const [ username, setUsername ] = useState('');
|
||||
const [ password, setPassword ] = useState('');
|
||||
const [ isLoading, setLoading ] = useState(false);
|
||||
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
const submit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
|
|
@ -7,7 +7,7 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
||||
type Props = Readonly<RouteComponentProps<{ token: string }> & {}>;
|
||||
|
||||
|
@ -17,7 +17,7 @@ export default (props: Props) => {
|
|||
const [ password, setPassword ] = useState('');
|
||||
const [ passwordConfirm, setPasswordConfirm ] = useState('');
|
||||
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
const parsed = parse(props.location.search);
|
||||
if (email.length === 0 && parsed.email) {
|
||||
|
|
|
@ -9,8 +9,8 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
export default () => (
|
||||
<div className={'my-10'}>
|
||||
<Link to={'/server/e9d6c836'} className={'flex no-underline text-neutral-200 cursor-pointer items-center bg-neutral-700 p-4 border border-transparent hover:border-neutral-500'}>
|
||||
<div className={'rounded-full bg-neutral-500 p-3'}>
|
||||
<Link to={'/server/e9d6c836'} className={'grey-row-box cursor-pointer'}>
|
||||
<div className={'icon'}>
|
||||
<FontAwesomeIcon icon={faServer}/>
|
||||
</div>
|
||||
<div className={'w-1/2 ml-4'}>
|
||||
|
@ -49,8 +49,8 @@ export default () => (
|
|||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className={'flex mt-px cursor-pointer items-center bg-neutral-700 p-4 border border-transparent hover:border-neutral-500'}>
|
||||
<div className={'rounded-full bg-neutral-500 p-3'}>
|
||||
<div className={'grey-row-box cursor-pointer mt-2'}>
|
||||
<div className={'icon'}>
|
||||
<FontAwesomeIcon icon={faServer}/>
|
||||
</div>
|
||||
<div className={'w-1/2 ml-4'}>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react';
|
||||
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import { Form, Formik, FormikActions } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import Field from '@/components/elements/Field';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
||||
interface Values {
|
||||
email: string;
|
||||
|
@ -18,10 +18,10 @@ const schema = Yup.object().shape({
|
|||
});
|
||||
|
||||
export default () => {
|
||||
const user = useStoreState((state: State<ApplicationState>) => state.user.data);
|
||||
const updateEmail = useStoreActions((state: Actions<ApplicationState>) => state.user.updateUserEmail);
|
||||
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
|
||||
const updateEmail = useStoreActions((state: Actions<ApplicationStore>) => state.user.updateUserEmail);
|
||||
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
const submit = (values: Values, { resetForm, setSubmitting }: FormikActions<Values>) => {
|
||||
clearFlashes('account:email');
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react';
|
||||
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import { Form, Formik, FormikActions } from 'formik';
|
||||
import Field from '@/components/elements/Field';
|
||||
import * as Yup from 'yup';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import updateAccountPassword from '@/api/account/updateAccountPassword';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
||||
interface Values {
|
||||
current: string;
|
||||
|
@ -23,8 +23,8 @@ const schema = Yup.object().shape({
|
|||
});
|
||||
|
||||
export default () => {
|
||||
const user = useStoreState((state: State<ApplicationState>) => state.user.data);
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationState>) => actions.flashes);
|
||||
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
|
|
71
resources/scripts/components/elements/Modal.tsx
Normal file
71
resources/scripts/components/elements/Modal.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import React, { useEffect, 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 Spinner from '@/components/elements/Spinner';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onDismissed: () => void;
|
||||
dismissable?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
closeOnBackground?: boolean;
|
||||
showSpinnerOverlay?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const [render, setRender] = useState(props.visible);
|
||||
|
||||
const handleEscapeEvent = (e: KeyboardEvent) => {
|
||||
if (props.dismissable !== false && props.closeOnEscape !== false && e.key === 'Escape') {
|
||||
setRender(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => setRender(props.visible), [props.visible]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscapeEvent);
|
||||
|
||||
return () => window.removeEventListener('keydown', handleEscapeEvent);
|
||||
}, [render]);
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
timeout={250}
|
||||
classNames={'fade'}
|
||||
in={render}
|
||||
unmountOnExit={true}
|
||||
onExited={() => props.onDismissed()}
|
||||
>
|
||||
<div className={'modal-mask'} onClick={e => {
|
||||
if (props.dismissable !== false && props.closeOnBackground !== false) {
|
||||
e.stopPropagation();
|
||||
if (e.target === e.currentTarget) {
|
||||
setRender(false);
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<div className={'modal-container top'}>
|
||||
{props.dismissable !== false &&
|
||||
<div className={'modal-close-icon'} onClick={() => setRender(false)}>
|
||||
<FontAwesomeIcon icon={faTimes}/>
|
||||
</div>
|
||||
}
|
||||
{props.showSpinnerOverlay &&
|
||||
<div
|
||||
className={'absolute w-full h-full rounded flex items-center justify-center'}
|
||||
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
|
||||
>
|
||||
<Spinner large={false}/>
|
||||
</div>
|
||||
}
|
||||
<div className={'modal-content p-6'}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
);
|
||||
};
|
|
@ -1,6 +1,11 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default ({ large }: { large?: boolean }) => (
|
||||
export default ({ large, centered }: { large?: boolean; centered?: boolean }) => (
|
||||
centered ?
|
||||
<div className={classNames('flex justify-center', { 'm-20': large, 'm-6': !large })}>
|
||||
<div className={classNames('spinner-circle spinner-white', { 'spinner-lg': large })}/>
|
||||
</div>
|
||||
:
|
||||
<div className={classNames('spinner-circle spinner-white', { 'spinner-lg': large })}/>
|
||||
);
|
||||
|
|
|
@ -2,9 +2,9 @@ import React, { createRef } from 'react';
|
|||
import { Terminal } from 'xterm';
|
||||
import * as TerminalFit from 'xterm/lib/addons/fit/fit';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Websocket } from '@/plugins/Websocket';
|
||||
import { ServerStore } from '@/state/server';
|
||||
|
||||
const theme = {
|
||||
background: 'transparent',
|
||||
|
@ -113,8 +113,8 @@ class Console extends React.PureComponent<Readonly<Props>> {
|
|||
}
|
||||
|
||||
export default connect(
|
||||
(state: ApplicationState) => ({
|
||||
connected: state.server.socket.connected,
|
||||
instance: state.server.socket.instance,
|
||||
(state: ServerStore) => ({
|
||||
connected: state.socket.connected,
|
||||
instance: state.socket.instance,
|
||||
}),
|
||||
)(Console);
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import React from 'react';
|
||||
import Console from '@/components/server/Console';
|
||||
import { State, useStoreState } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
export default () => {
|
||||
const status = useStoreState((state: State<ApplicationState>) => state.server.status);
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
|
||||
return (
|
||||
<div className={'my-10 flex'}>
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import { Websocket } from '@/plugins/Websocket';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
export default () => {
|
||||
const server = useStoreState((state: State<ApplicationState>) => state.server.data);
|
||||
const instance = useStoreState((state: State<ApplicationState>) => state.server.socket.instance);
|
||||
const setServerStatus = useStoreActions((actions: Actions<ApplicationState>) => actions.server.setServerStatus);
|
||||
const { setInstance, setConnectionState } = useStoreActions((actions: Actions<ApplicationState>) => actions.server.socket);
|
||||
const server = ServerContext.useStoreState(state => state.server.data);
|
||||
const instance = ServerContext.useStoreState(state => state.socket.instance);
|
||||
const setServerStatus = ServerContext.useStoreActions(actions => actions.status.setServerStatus);
|
||||
const { setInstance, setConnectionState } = ServerContext.useStoreActions(actions => actions.socket);
|
||||
|
||||
useEffect(() => {
|
||||
// If there is already an instance or there is no server, just exit out of this process
|
||||
|
@ -20,7 +19,7 @@ export default () => {
|
|||
|
||||
const socket = new Websocket(
|
||||
`wss://wings.pterodactyl.test:8080/api/servers/${server.uuid}/ws`,
|
||||
'CC8kHCuMkXPosgzGO6d37wvhNcksWxG6kTrA'
|
||||
'CC8kHCuMkXPosgzGO6d37wvhNcksWxG6kTrA',
|
||||
);
|
||||
|
||||
socket.on('SOCKET_OPEN', () => setConnectionState(true));
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ServerDatabase } from '@/api/server/getServerDatabases';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import { Form, Formik, FormikActions } from 'formik';
|
||||
import Field from '@/components/elements/Field';
|
||||
import { object, string } from 'yup';
|
||||
import createServerDatabase from '@/api/server/createServerDatabase';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
|
||||
interface Values {
|
||||
databaseName: string;
|
||||
connectionsFrom: string;
|
||||
}
|
||||
|
||||
const schema = object().shape({
|
||||
databaseName: string()
|
||||
.required('A database name must be provided.')
|
||||
.min(5, 'Database name must be at least 5 characters.')
|
||||
.max(64, 'Database name must not exceed 64 characters.')
|
||||
.matches(/^[A-Za-z0-9_\-.]{5,64}$/, 'Database name should only contain alphanumeric characters, underscores, dashes, and/or periods.'),
|
||||
connectionsFrom: string()
|
||||
.required('A connection value must be provided.')
|
||||
.matches(/^([1-9]{1,3}|%)(\.([0-9]{1,3}|%))?(\.([0-9]{1,3}|%))?(\.([0-9]{1,3}|%))?$/, 'A valid connection address must be provided.'),
|
||||
});
|
||||
|
||||
export default ({ onCreated }: { onCreated: (database: ServerDatabase) => void }) => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikActions<Values>) => {
|
||||
clearFlashes();
|
||||
createServerDatabase(server.uuid, { ...values })
|
||||
.then(database => {
|
||||
onCreated(database);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error);
|
||||
addFlash({
|
||||
key: 'create-database-modal',
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: httpErrorToHuman(error),
|
||||
});
|
||||
})
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ databaseName: '', connectionsFrom: '%' }}
|
||||
validationSchema={schema}
|
||||
>
|
||||
{
|
||||
({ isSubmitting, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
resetForm();
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<FlashMessageRender byKey={'create-database-modal'} className={'mb-6'}/>
|
||||
<h3 className={'mb-6'}>Create new database</h3>
|
||||
<Form className={'m-0'}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'database_name'}
|
||||
name={'databaseName'}
|
||||
label={'Database Name'}
|
||||
description={'A descriptive name for your database instance.'}
|
||||
/>
|
||||
<div className={'mt-6'}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'connections_from'}
|
||||
name={'connectionsFrom'}
|
||||
label={'Connections From'}
|
||||
description={'Where connections should be allowed from. Use % for wildcards.'}
|
||||
/>
|
||||
</div>
|
||||
<div className={'mt-6 text-right'}>
|
||||
<button
|
||||
type={'button'}
|
||||
className={'btn btn-sm btn-secondary mr-2'}
|
||||
onClick={() => setVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button className={'btn btn-sm btn-primary'} type={'submit'}>
|
||||
Create Database
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
</Formik>
|
||||
<button className={'btn btn-primary btn-lg'} onClick={() => setVisible(true)}>
|
||||
New Database
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
159
resources/scripts/components/server/databases/DatabaseRow.tsx
Normal file
159
resources/scripts/components/server/databases/DatabaseRow.tsx
Normal file
|
@ -0,0 +1,159 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ServerDatabase } from '@/api/server/getServerDatabases';
|
||||
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 Modal from '@/components/elements/Modal';
|
||||
import { Form, Formik, FormikActions } from 'formik';
|
||||
import Field from '@/components/elements/Field';
|
||||
import { object, string } from 'yup';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import deleteServerDatabase from '@/api/server/deleteServerDatabase';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
|
||||
interface Props {
|
||||
database: ServerDatabase;
|
||||
className?: string;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default ({ database, className, onDelete }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [connectionVisible, setConnectionVisible] = useState(false);
|
||||
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
||||
|
||||
const schema = object().shape({
|
||||
confirm: string()
|
||||
.required('The database name must be provided.')
|
||||
.oneOf([database.name.split('_', 2)[1], database.name], 'The database name must be provided.'),
|
||||
});
|
||||
|
||||
const submit = (values: { confirm: string }, { setSubmitting }: FormikActions<{ confirm: string }>) => {
|
||||
clearFlashes();
|
||||
deleteServerDatabase(server.uuid, database.id)
|
||||
.then(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => onDelete(), 150);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
addFlash({
|
||||
key: 'delete-database-modal',
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: httpErrorToHuman(error),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ confirm: '' }}
|
||||
validationSchema={schema}
|
||||
>
|
||||
{
|
||||
({ isSubmitting, isValid, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => { setVisible(false); resetForm(); }}
|
||||
>
|
||||
<FlashMessageRender byKey={'delete-database-modal'} className={'mb-6'}/>
|
||||
<h3 className={'mb-6'}>Confirm database deletion</h3>
|
||||
<p className={'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'}>
|
||||
<Field
|
||||
type={'text'}
|
||||
id={'confirm_name'}
|
||||
name={'confirm'}
|
||||
label={'Confirm Database Name'}
|
||||
description={'Enter the database name to confirm deletion.'}
|
||||
/>
|
||||
<div className={'mt-6 text-right'}>
|
||||
<button
|
||||
type={'button'}
|
||||
className={'btn btn-sm btn-secondary mr-2'}
|
||||
onClick={() => setVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type={'submit'}
|
||||
className={'btn btn-sm btn-red'}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Delete Database
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
</Formik>
|
||||
<Modal visible={connectionVisible} onDismissed={() => setConnectionVisible(false)}>
|
||||
<h3 className={'mb-6'}>Database connection details</h3>
|
||||
<div>
|
||||
<label className={'input-dark-label'}>Password</label>
|
||||
<input type={'text'} className={'input-dark'} readOnly={true} value={database.password}/>
|
||||
</div>
|
||||
<div className={'mt-6'}>
|
||||
<label className={'input-dark-label'}>JBDC Connection String</label>
|
||||
<input
|
||||
type={'text'}
|
||||
className={'input-dark'}
|
||||
readOnly={true}
|
||||
value={`jdbc:mysql://${database.username}:${database.password}@${database.connectionString}/${database.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={'mt-6 text-right'}>
|
||||
<button className={'btn btn-sm btn-secondary'} onClick={() => setConnectionVisible(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className={classNames('grey-row-box no-hover', className)}>
|
||||
<div className={'icon'}>
|
||||
<FontAwesomeIcon icon={faDatabase}/>
|
||||
</div>
|
||||
<div className={'flex-1 ml-4'}>
|
||||
<p className={'text-lg'}>{database.name}</p>
|
||||
</div>
|
||||
<div className={'ml-6'}>
|
||||
<p className={'text-center text-xs text-neutral-500 uppercase mb-1 select-none'}>Endpoint:</p>
|
||||
<p className={'text-center text-sm'}>{database.connectionString}</p>
|
||||
</div>
|
||||
<div className={'ml-6'}>
|
||||
<p className={'text-center text-xs text-neutral-500 uppercase mb-1 select-none'}>
|
||||
Connections From:
|
||||
</p>
|
||||
<p className={'text-center text-sm'}>{database.allowConnectionsFrom}</p>
|
||||
</div>
|
||||
<div className={'ml-6'}>
|
||||
<p className={'text-center text-xs text-neutral-500 uppercase mb-1 select-none'}>Username:</p>
|
||||
<p className={'text-center text-sm'}>{database.username}</p>
|
||||
</div>
|
||||
<div className={'ml-6'}>
|
||||
<button className={'btn btn-sm btn-secondary mr-2'} onClick={() => setConnectionVisible(true)}>
|
||||
<FontAwesomeIcon icon={faEye} fixedWidth={true}/>
|
||||
</button>
|
||||
<button className={'btn btn-sm btn-secondary btn-red'} onClick={() => setVisible(true)}>
|
||||
<FontAwesomeIcon icon={faTrashAlt} fixedWidth={true}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,64 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import getServerDatabases, { ServerDatabase } from '@/api/server/getServerDatabases';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
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';
|
||||
|
||||
export default () => {
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ databases, setDatabases ] = useState<ServerDatabase[]>([]);
|
||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
||||
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('databases');
|
||||
getServerDatabases(server.uuid)
|
||||
.then(databases => {
|
||||
setDatabases(databases);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => addFlash({
|
||||
key: 'databases',
|
||||
title: 'Error',
|
||||
message: httpErrorToHuman(error),
|
||||
type: 'error',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={'my-10 mb-6'}>
|
||||
<FlashMessageRender byKey={'databases'}/>
|
||||
{loading ?
|
||||
<Spinner large={true} centered={true}/>
|
||||
:
|
||||
<CSSTransition classNames={'fade'} timeout={250}>
|
||||
<React.Fragment>
|
||||
{databases.length > 0 ?
|
||||
databases.map((database, index) => (
|
||||
<DatabaseRow
|
||||
key={database.id}
|
||||
database={database}
|
||||
onDelete={() => setDatabases(s => [ ...s.filter(d => d.id !== database.id) ])}
|
||||
className={index > 0 ? 'mt-1' : undefined}
|
||||
/>
|
||||
))
|
||||
:
|
||||
<p className={'text-center text-sm text-neutral-200'}>
|
||||
It looks like you have no databases. Click the button below to create one now.
|
||||
</p>
|
||||
}
|
||||
<div className={'mt-6 flex justify-end'}>
|
||||
<CreateDatabaseButton onCreated={database => setDatabases(s => [ ...s, database ])}/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</CSSTransition>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -3,14 +3,16 @@ import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom';
|
|||
import NavigationBar from '@/components/NavigationBar';
|
||||
import ServerConsole from '@/components/server/ServerConsole';
|
||||
import TransitionRouter from '@/TransitionRouter';
|
||||
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import WebsocketHandler from '@/components/server/WebsocketHandler';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { Provider } from 'react-redux';
|
||||
import DatabasesContainer from '@/components/server/databases/DatabasesContainer';
|
||||
|
||||
export default ({ match, location }: RouteComponentProps<{ id: string }>) => {
|
||||
const server = useStoreState((state: State<ApplicationState>) => state.server.data);
|
||||
const { clearServerState, getServer } = useStoreActions((actions: Actions<ApplicationState>) => actions.server);
|
||||
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
|
||||
const server = ServerContext.useStoreState(state => state.server.data);
|
||||
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
|
||||
const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState);
|
||||
|
||||
if (!server) {
|
||||
getServer(match.params.id);
|
||||
|
@ -31,6 +33,7 @@ export default ({ match, location }: RouteComponentProps<{ id: string }>) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Provider store={ServerContext.useStore()}>
|
||||
<TransitionRouter>
|
||||
<div className={'w-full mx-auto'} style={{ maxWidth: '1200px' }}>
|
||||
{!server ?
|
||||
|
@ -42,11 +45,19 @@ export default ({ match, location }: RouteComponentProps<{ id: string }>) => {
|
|||
<WebsocketHandler/>
|
||||
<Switch location={location}>
|
||||
<Route path={`${match.path}`} component={ServerConsole} exact/>
|
||||
<Route path={`${match.path}/databases`} component={DatabasesContainer}/>
|
||||
</Switch>
|
||||
</React.Fragment>
|
||||
}
|
||||
</div>
|
||||
</TransitionRouter>
|
||||
</Provider>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default (props: RouteComponentProps<any>) => (
|
||||
<ServerContext.Provider>
|
||||
<ServerRouter {...props}/>
|
||||
</ServerContext.Provider>
|
||||
);
|
||||
|
|
28
resources/scripts/state/flashes.ts
Normal file
28
resources/scripts/state/flashes.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Action, action } from 'easy-peasy';
|
||||
import { FlashMessageType } from '@/components/MessageBox';
|
||||
|
||||
export interface FlashStore {
|
||||
items: FlashMessage[];
|
||||
addFlash: Action<FlashStore, FlashMessage>;
|
||||
clearFlashes: Action<FlashStore, string | void>;
|
||||
}
|
||||
|
||||
export interface FlashMessage {
|
||||
id?: string;
|
||||
key?: string;
|
||||
type: FlashMessageType;
|
||||
title?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const flashes: FlashStore = {
|
||||
items: [],
|
||||
addFlash: action((state, payload) => {
|
||||
state.items.push(payload);
|
||||
}),
|
||||
clearFlashes: action((state, payload) => {
|
||||
state.items = payload ? state.items.filter(flashes => flashes.key !== payload) : [];
|
||||
}),
|
||||
};
|
||||
|
||||
export default flashes;
|
|
@ -1,13 +1,15 @@
|
|||
import { createStore } from 'easy-peasy';
|
||||
import { ApplicationState } from '@/state/types';
|
||||
import flashes from '@/state/models/flashes';
|
||||
import user from '@/state/models/user';
|
||||
import server from '@/state/models/server';
|
||||
import flashes, { FlashStore } from '@/state/flashes';
|
||||
import user, { UserStore } from '@/state/user';
|
||||
|
||||
const state: ApplicationState = {
|
||||
export interface ApplicationStore {
|
||||
flashes: FlashStore;
|
||||
user: UserStore;
|
||||
}
|
||||
|
||||
const state: ApplicationStore = {
|
||||
flashes,
|
||||
user,
|
||||
server,
|
||||
};
|
||||
|
||||
export const store = createStore(state);
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import { action } from 'easy-peasy';
|
||||
import { FlashState } from '@/state/types';
|
||||
|
||||
const flashes: FlashState = {
|
||||
items: [],
|
||||
addFlash: action((state, payload) => {
|
||||
state.items.push(payload);
|
||||
}),
|
||||
clearFlashes: action((state, payload) => {
|
||||
state.items = payload ? state.items.filter(flashes => flashes.key !== payload) : [];
|
||||
}),
|
||||
};
|
||||
|
||||
export default flashes;
|
|
@ -1,43 +0,0 @@
|
|||
import getServer, { Server } from '@/api/server/getServer';
|
||||
import { action, Action, thunk, Thunk } from 'easy-peasy';
|
||||
import socket, { SocketState } from './socket';
|
||||
|
||||
export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running';
|
||||
|
||||
export interface ServerState {
|
||||
data?: Server;
|
||||
status: ServerStatus;
|
||||
socket: SocketState;
|
||||
getServer: Thunk<ServerState, string, {}, any, Promise<void>>;
|
||||
setServer: Action<ServerState, Server>;
|
||||
setServerStatus: Action<ServerState, ServerStatus>;
|
||||
clearServerState: Action<ServerState>;
|
||||
}
|
||||
|
||||
const server: ServerState = {
|
||||
socket,
|
||||
status: 'offline',
|
||||
getServer: thunk(async (actions, payload) => {
|
||||
const server = await getServer(payload);
|
||||
actions.setServer(server);
|
||||
}),
|
||||
setServer: action((state, payload) => {
|
||||
state.data = payload;
|
||||
}),
|
||||
setServerStatus: action((state, payload) => {
|
||||
state.status = payload;
|
||||
}),
|
||||
clearServerState: action(state => {
|
||||
state.data = undefined;
|
||||
|
||||
if (state.socket.instance) {
|
||||
state.socket.instance.removeAllListeners();
|
||||
state.socket.instance.close();
|
||||
}
|
||||
|
||||
state.socket.instance = null;
|
||||
state.socket.connected = false;
|
||||
}),
|
||||
};
|
||||
|
||||
export default server;
|
57
resources/scripts/state/server/index.ts
Normal file
57
resources/scripts/state/server/index.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import getServer, { Server } from '@/api/server/getServer';
|
||||
import { action, Action, createContextStore, thunk, Thunk } from 'easy-peasy';
|
||||
import socket, { SocketStore } from './socket';
|
||||
|
||||
export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running';
|
||||
|
||||
interface ServerDataStore {
|
||||
data?: Server;
|
||||
getServer: Thunk<ServerDataStore, string, {}, any, Promise<void>>;
|
||||
setServer: Action<ServerDataStore, Server>;
|
||||
}
|
||||
|
||||
const server: ServerDataStore = {
|
||||
getServer: thunk(async (actions, payload) => {
|
||||
const server = await getServer(payload);
|
||||
actions.setServer(server);
|
||||
}),
|
||||
setServer: action((state, payload) => {
|
||||
state.data = payload;
|
||||
}),
|
||||
};
|
||||
|
||||
interface ServerStatusStore {
|
||||
value: ServerStatus;
|
||||
setServerStatus: Action<ServerStatusStore, ServerStatus>;
|
||||
}
|
||||
|
||||
const status: ServerStatusStore = {
|
||||
value: 'offline',
|
||||
setServerStatus: action((state, payload) => {
|
||||
state.value = payload;
|
||||
}),
|
||||
};
|
||||
|
||||
export interface ServerStore {
|
||||
server: ServerDataStore;
|
||||
socket: SocketStore;
|
||||
status: ServerStatusStore;
|
||||
clearServerState: Action<ServerStore>;
|
||||
}
|
||||
|
||||
export const ServerContext = createContextStore<ServerStore>({
|
||||
server,
|
||||
socket,
|
||||
status,
|
||||
clearServerState: action(state => {
|
||||
state.server.data = undefined;
|
||||
|
||||
if (state.socket.instance) {
|
||||
state.socket.instance.removeAllListeners();
|
||||
state.socket.instance.close();
|
||||
}
|
||||
|
||||
state.socket.instance = null;
|
||||
state.socket.connected = false;
|
||||
}),
|
||||
}, { name: 'ServerStore' });
|
|
@ -1,14 +1,14 @@
|
|||
import { Action, action } from 'easy-peasy';
|
||||
import { Websocket } from '@/plugins/Websocket';
|
||||
|
||||
export interface SocketState {
|
||||
export interface SocketStore {
|
||||
instance: Websocket | null;
|
||||
connected: boolean;
|
||||
setInstance: Action<SocketState, Websocket | null>;
|
||||
setConnectionState: Action<SocketState, boolean>;
|
||||
setInstance: Action<SocketStore, Websocket | null>;
|
||||
setConnectionState: Action<SocketStore, boolean>;
|
||||
}
|
||||
|
||||
const socket: SocketState = {
|
||||
const socket: SocketStore = {
|
||||
instance: null,
|
||||
connected: false,
|
||||
setInstance: action((state, payload) => {
|
24
resources/scripts/state/types.d.ts
vendored
24
resources/scripts/state/types.d.ts
vendored
|
@ -1,24 +0,0 @@
|
|||
import { FlashMessageType } from '@/components/MessageBox';
|
||||
import { Action } from 'easy-peasy';
|
||||
import { UserState } from '@/state/models/user';
|
||||
import { ServerState } from '@/state/models/server';
|
||||
|
||||
export interface ApplicationState {
|
||||
flashes: FlashState;
|
||||
user: UserState;
|
||||
server: ServerState;
|
||||
}
|
||||
|
||||
export interface FlashState {
|
||||
items: FlashMessage[];
|
||||
addFlash: Action<FlashState, FlashMessage>;
|
||||
clearFlashes: Action<FlashState, string | void>;
|
||||
}
|
||||
|
||||
export interface FlashMessage {
|
||||
id?: string;
|
||||
key?: string;
|
||||
type: FlashMessageType;
|
||||
title?: string;
|
||||
message: string;
|
||||
}
|
|
@ -12,14 +12,14 @@ export interface UserData {
|
|||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserState {
|
||||
export interface UserStore {
|
||||
data?: UserData;
|
||||
setUserData: Action<UserState, UserData>;
|
||||
updateUserData: Action<UserState, Partial<UserData>>;
|
||||
updateUserEmail: Thunk<UserState, { email: string; password: string }, any, {}, Promise<void>>;
|
||||
setUserData: Action<UserStore, UserData>;
|
||||
updateUserData: Action<UserStore, Partial<UserData>>;
|
||||
updateUserEmail: Thunk<UserStore, { email: string; password: string }, any, {}, Promise<void>>;
|
||||
}
|
||||
|
||||
const user: UserState = {
|
||||
const user: UserStore = {
|
||||
data: undefined,
|
||||
setUserData: action((state, payload) => {
|
||||
state.data = payload;
|
|
@ -7,114 +7,15 @@ code.clean {
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicators for server online status.
|
||||
*/
|
||||
.indicator {
|
||||
@apply .bg-neutral-800 .border .border-primary-500;
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.grey-row-box {
|
||||
@apply .flex .rounded .no-underline .text-neutral-200 .items-center .bg-neutral-700 .p-4 .border .border-transparent;
|
||||
transition: border-color 150ms linear;
|
||||
|
||||
&.online {
|
||||
@apply .bg-green-600 .border-green-500;
|
||||
animation: onlineblink 2s infinite alternate;
|
||||
&:not(.no-hover):hover {
|
||||
@apply .border-neutral-500;
|
||||
}
|
||||
|
||||
&.offline {
|
||||
@apply .bg-green-600 .border-red-500;
|
||||
animation: offlineblink 2s infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage indicator labels for the server listing.
|
||||
*/
|
||||
.usage {
|
||||
@apply .flex-1 .text-center .relative;
|
||||
|
||||
& > .indicator-title {
|
||||
@apply .text-xs .uppercase .font-hairline .bg-white .absolute .text-primary-500;
|
||||
margin-top:-9px;
|
||||
padding: 0 8px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Styling for elements that contain the core page content.
|
||||
*/
|
||||
.content-box {
|
||||
@apply .bg-white .p-6 .rounded .shadow .border .border-neutral-100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex boxes for server listing on user dashboard.
|
||||
*/
|
||||
.server-card-container {
|
||||
@apply .mb-4 .w-full;
|
||||
|
||||
@screen md {
|
||||
@apply .w-1/2 .pr-4;
|
||||
|
||||
&:nth-of-type(2n) {
|
||||
@apply .pr-0;
|
||||
}
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
@apply .w-1/3 .pr-4;
|
||||
|
||||
&:nth-of-type(2n) {
|
||||
@apply .pr-4;
|
||||
}
|
||||
|
||||
&:nth-of-type(3n) {
|
||||
@apply .pr-0;
|
||||
}
|
||||
}
|
||||
|
||||
& > div {
|
||||
@apply .flex .flex-col;
|
||||
transition: box-shadow 150ms ease-in;
|
||||
|
||||
&:hover {
|
||||
@apply .shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
& > div > .server-card {
|
||||
@apply .flex .flex-col .p-4 .border .border-t-4 .border-neutral-100 .bg-white;
|
||||
transition: all 100ms ease-in;
|
||||
|
||||
& .identifier-icon {
|
||||
@apply .select-none .inline-block .rounded-full .text-white .text-center .leading-none .justify-center .w-8 .h-8 .mr-2 .flex .flex-row .items-center;
|
||||
}
|
||||
|
||||
& a, & a:visited {
|
||||
@apply .no-underline .text-neutral-800;
|
||||
}
|
||||
}
|
||||
|
||||
& > div > .footer {
|
||||
@apply .border .border-neutral-100 .border-t-0 .bg-neutral-50 .shadow-inner;
|
||||
}
|
||||
}
|
||||
|
||||
.pillbox {
|
||||
@apply .rounded-full .px-2 .py-1 .text-white .font-medium .leading-none .text-xs;
|
||||
}
|
||||
|
||||
.server-search {
|
||||
@apply .w-full .my-4;
|
||||
|
||||
& > input[type="text"] {
|
||||
@apply .w-full .p-4 .rounded .border .border-neutral-200 .text-neutral-500;
|
||||
transition: border 150ms ease-in;
|
||||
|
||||
&:focus {
|
||||
@apply .border-primary-500;
|
||||
}
|
||||
& > div.icon {
|
||||
@apply .rounded-full .bg-neutral-500 .p-3;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
.modal-mask {
|
||||
@apply .fixed .pin .z-50 .overflow-auto .flex;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: rgba(0, 0, 0, 0.70);
|
||||
transition: opacity 250ms ease;
|
||||
|
||||
& > .modal-container {
|
||||
@apply .relative .w-full .max-w-md .m-auto .flex-col .flex;
|
||||
|
||||
&.top {
|
||||
margin-top: 15%;
|
||||
margin-top: 10%;
|
||||
}
|
||||
|
||||
& > .modal-close-icon {
|
||||
@apply .absolute .pin-r .p-2 .text-white .cursor-pointer .opacity-50;
|
||||
transition: opacity 150ms linear, transform 150ms ease-in;
|
||||
top: -2.5rem;
|
||||
top: -2rem;
|
||||
|
||||
&:hover {
|
||||
@apply .opacity-100;
|
||||
|
@ -22,7 +22,7 @@
|
|||
}
|
||||
|
||||
& > .modal-content {
|
||||
@apply .bg-white .rounded .shadow-md;
|
||||
@apply .bg-neutral-800 .rounded .shadow-md;
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
|
||||
|
|
|
@ -13,5 +13,5 @@ h1, h2, h3, h4, h5, h6 {
|
|||
}
|
||||
|
||||
p {
|
||||
@apply .text-neutral-200;
|
||||
@apply .text-neutral-200 .leading-snug;
|
||||
}
|
||||
|
|
|
@ -290,7 +290,9 @@ module.exports = {
|
|||
leading: {
|
||||
'none': 1,
|
||||
'tight': 1.25,
|
||||
'snug': 1.375,
|
||||
'normal': 1.5,
|
||||
'relaxed': 1.625,
|
||||
'loose': 2,
|
||||
},
|
||||
|
||||
|
|
|
@ -135,7 +135,7 @@ module.exports = {
|
|||
},
|
||||
plugins: plugins,
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimize: isProduction,
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
cache: true,
|
||||
|
|
Loading…
Reference in a new issue