diff --git a/resources/scripts/api/server/createServerDatabase.ts b/resources/scripts/api/server/createServerDatabase.ts new file mode 100644 index 000000000..90103337c --- /dev/null +++ b/resources/scripts/api/server/createServerDatabase.ts @@ -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 => { + 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); + }); +}; diff --git a/resources/scripts/api/server/deleteServerDatabase.ts b/resources/scripts/api/server/deleteServerDatabase.ts new file mode 100644 index 000000000..23275bd36 --- /dev/null +++ b/resources/scripts/api/server/deleteServerDatabase.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (uuid: string, database: string): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/client/servers/${uuid}/databases/${database}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/server/getServerDatabases.ts b/resources/scripts/api/server/getServerDatabases.ts new file mode 100644 index 000000000..835964c27 --- /dev/null +++ b/resources/scripts/api/server/getServerDatabases.ts @@ -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 => { + 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); + }); +}; diff --git a/resources/scripts/components/FlashMessageRender.tsx b/resources/scripts/components/FlashMessageRender.tsx index 5b9c51d75..cb0b37026 100644 --- a/resources/scripts/components/FlashMessageRender.tsx +++ b/resources/scripts/components/FlashMessageRender.tsx @@ -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) => state.flashes.items); +export default ({ className, spacerClass, byKey }: Props) => { + const flashes = useStoreState((state: State) => state.flashes.items); let filtered = flashes; if (byKey) { @@ -21,9 +21,8 @@ export default ({ withBottomSpace, spacerClass, byKey }: Props) => { return null; } - // noinspection PointlessBooleanExpressionJS return ( -
+
{ filtered.map((flash, index) => ( diff --git a/resources/scripts/components/auth/ForgotPasswordContainer.tsx b/resources/scripts/components/auth/ForgotPasswordContainer.tsx index fbf0eb704..668875ca0 100644 --- a/resources/scripts/components/auth/ForgotPasswordContainer.tsx +++ b/resources/scripts/components/auth/ForgotPasswordContainer.tsx @@ -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) => actions.flashes); + const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); const handleFieldUpdate = (e: React.ChangeEvent) => setEmail(e.target.value); diff --git a/resources/scripts/components/auth/LoginCheckpointContainer.tsx b/resources/scripts/components/auth/LoginCheckpointContainer.tsx index ccc53e5bc..54ac3ee48 100644 --- a/resources/scripts/components/auth/LoginCheckpointContainer.tsx +++ b/resources/scripts/components/auth/LoginCheckpointContainer.tsx @@ -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) => actions.flashes); + const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); if (!state || !state.token) { history.replace('/auth/login'); diff --git a/resources/scripts/components/auth/LoginContainer.tsx b/resources/scripts/components/auth/LoginContainer.tsx index f29a8c599..95538d78c 100644 --- a/resources/scripts/components/auth/LoginContainer.tsx +++ b/resources/scripts/components/auth/LoginContainer.tsx @@ -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) => actions.flashes); + const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); const submit = (e: React.FormEvent) => { e.preventDefault(); diff --git a/resources/scripts/components/auth/ResetPasswordContainer.tsx b/resources/scripts/components/auth/ResetPasswordContainer.tsx index 9467bb92b..6954ebd2a 100644 --- a/resources/scripts/components/auth/ResetPasswordContainer.tsx +++ b/resources/scripts/components/auth/ResetPasswordContainer.tsx @@ -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 & {}>; @@ -17,7 +17,7 @@ export default (props: Props) => { const [ password, setPassword ] = useState(''); const [ passwordConfirm, setPasswordConfirm ] = useState(''); - const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); + const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); const parsed = parse(props.location.search); if (email.length === 0 && parsed.email) { diff --git a/resources/scripts/components/dashboard/DashboardContainer.tsx b/resources/scripts/components/dashboard/DashboardContainer.tsx index 052d00034..a9c59a679 100644 --- a/resources/scripts/components/dashboard/DashboardContainer.tsx +++ b/resources/scripts/components/dashboard/DashboardContainer.tsx @@ -9,8 +9,8 @@ import { Link } from 'react-router-dom'; export default () => (
- -
+ +
@@ -49,8 +49,8 @@ export default () => (
-
-
+
+
diff --git a/resources/scripts/components/dashboard/forms/UpdateEmailAddressForm.tsx b/resources/scripts/components/dashboard/forms/UpdateEmailAddressForm.tsx index 4593908a5..1ee88a9ca 100644 --- a/resources/scripts/components/dashboard/forms/UpdateEmailAddressForm.tsx +++ b/resources/scripts/components/dashboard/forms/UpdateEmailAddressForm.tsx @@ -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) => state.user.data); - const updateEmail = useStoreActions((state: Actions) => state.user.updateUserEmail); + const user = useStoreState((state: State) => state.user.data); + const updateEmail = useStoreActions((state: Actions) => state.user.updateUserEmail); - const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); + const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); const submit = (values: Values, { resetForm, setSubmitting }: FormikActions) => { clearFlashes('account:email'); diff --git a/resources/scripts/components/dashboard/forms/UpdatePasswordForm.tsx b/resources/scripts/components/dashboard/forms/UpdatePasswordForm.tsx index dce13e650..7c25c1287 100644 --- a/resources/scripts/components/dashboard/forms/UpdatePasswordForm.tsx +++ b/resources/scripts/components/dashboard/forms/UpdatePasswordForm.tsx @@ -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) => state.user.data); - const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); + const user = useStoreState((state: State) => state.user.data); + const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); if (!user) { return null; diff --git a/resources/scripts/components/elements/Modal.tsx b/resources/scripts/components/elements/Modal.tsx new file mode 100644 index 000000000..be1e62e20 --- /dev/null +++ b/resources/scripts/components/elements/Modal.tsx @@ -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 ( + props.onDismissed()} + > +
{ + if (props.dismissable !== false && props.closeOnBackground !== false) { + e.stopPropagation(); + if (e.target === e.currentTarget) { + setRender(false); + } + } + }}> +
+ {props.dismissable !== false && +
setRender(false)}> + +
+ } + {props.showSpinnerOverlay && +
+ +
+ } +
+ {props.children} +
+
+
+
+ ); +}; diff --git a/resources/scripts/components/elements/Spinner.tsx b/resources/scripts/components/elements/Spinner.tsx index 090980e3f..d1704916f 100644 --- a/resources/scripts/components/elements/Spinner.tsx +++ b/resources/scripts/components/elements/Spinner.tsx @@ -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 ? +
+
+
+ : +
); diff --git a/resources/scripts/components/server/Console.tsx b/resources/scripts/components/server/Console.tsx index e21d4d07a..66f0b76bd 100644 --- a/resources/scripts/components/server/Console.tsx +++ b/resources/scripts/components/server/Console.tsx @@ -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> { } 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); diff --git a/resources/scripts/components/server/ServerConsole.tsx b/resources/scripts/components/server/ServerConsole.tsx index 0264e0d64..19a8d9d1b 100644 --- a/resources/scripts/components/server/ServerConsole.tsx +++ b/resources/scripts/components/server/ServerConsole.tsx @@ -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) => state.server.status); + const status = ServerContext.useStoreState(state => state.status.value); return (
diff --git a/resources/scripts/components/server/WebsocketHandler.tsx b/resources/scripts/components/server/WebsocketHandler.tsx index 7f74a64e8..a8d2f3003 100644 --- a/resources/scripts/components/server/WebsocketHandler.tsx +++ b/resources/scripts/components/server/WebsocketHandler.tsx @@ -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) => state.server.data); - const instance = useStoreState((state: State) => state.server.socket.instance); - const setServerStatus = useStoreActions((actions: Actions) => actions.server.setServerStatus); - const { setInstance, setConnectionState } = useStoreActions((actions: Actions) => 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)); diff --git a/resources/scripts/components/server/databases/CreateDatabaseButton.tsx b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx new file mode 100644 index 000000000..54261605d --- /dev/null +++ b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx @@ -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) => actions.flashes); + const server = ServerContext.useStoreState(state => state.server.data!); + + const submit = (values: Values, { setSubmitting }: FormikActions) => { + 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 ( + + + { + ({ isSubmitting, resetForm }) => ( + { + resetForm(); + setVisible(false); + }} + > + +

Create new database

+
+ +
+ +
+
+ + +
+ +
+ ) + } +
+ +
+ ); +}; diff --git a/resources/scripts/components/server/databases/DatabaseRow.tsx b/resources/scripts/components/server/databases/DatabaseRow.tsx new file mode 100644 index 000000000..3cad11755 --- /dev/null +++ b/resources/scripts/components/server/databases/DatabaseRow.tsx @@ -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) => 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 ( + + + { + ({ isSubmitting, isValid, resetForm }) => ( + { setVisible(false); resetForm(); }} + > + +

Confirm database deletion

+

+ Deleting a database is a permanent action, it cannot be undone. This will permanetly + delete the {database.name} database and remove all associated data. +

+
+ +
+ + +
+ +
+ ) + } +
+ setConnectionVisible(false)}> +

Database connection details

+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+

{database.name}

+
+
+

Endpoint:

+

{database.connectionString}

+
+
+

+ Connections From: +

+

{database.allowConnectionsFrom}

+
+
+

Username:

+

{database.username}

+
+
+ + +
+
+
+ ); +}; diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx new file mode 100644 index 000000000..a7c2c1d72 --- /dev/null +++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx @@ -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([]); + const server = ServerContext.useStoreState(state => state.server.data!); + const { addFlash, clearFlashes } = useStoreActions((actions: Actions) => 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 ( +
+ + {loading ? + + : + + + {databases.length > 0 ? + databases.map((database, index) => ( + setDatabases(s => [ ...s.filter(d => d.id !== database.id) ])} + className={index > 0 ? 'mt-1' : undefined} + /> + )) + : +

+ It looks like you have no databases. Click the button below to create one now. +

+ } +
+ setDatabases(s => [ ...s, database ])}/> +
+
+
+ } +
+ ); +}; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 62f5ff8e0..4b01f0b63 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -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) => state.server.data); - const { clearServerState, getServer } = useStoreActions((actions: Actions) => 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,22 +33,31 @@ export default ({ match, location }: RouteComponentProps<{ id: string }>) => {
- -
- {!server ? -
- -
- : - - - - - - - } -
-
+ + +
+ {!server ? +
+ +
+ : + + + + + + + + } +
+
+
); }; + +export default (props: RouteComponentProps) => ( + + + +); diff --git a/resources/scripts/state/flashes.ts b/resources/scripts/state/flashes.ts new file mode 100644 index 000000000..666778a11 --- /dev/null +++ b/resources/scripts/state/flashes.ts @@ -0,0 +1,28 @@ +import { Action, action } from 'easy-peasy'; +import { FlashMessageType } from '@/components/MessageBox'; + +export interface FlashStore { + items: FlashMessage[]; + addFlash: Action; + clearFlashes: Action; +} + +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; diff --git a/resources/scripts/state/index.ts b/resources/scripts/state/index.ts index 5e19365f8..db3181aa5 100644 --- a/resources/scripts/state/index.ts +++ b/resources/scripts/state/index.ts @@ -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); diff --git a/resources/scripts/state/models/flashes.ts b/resources/scripts/state/models/flashes.ts deleted file mode 100644 index 97c9f080b..000000000 --- a/resources/scripts/state/models/flashes.ts +++ /dev/null @@ -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; diff --git a/resources/scripts/state/models/server.ts b/resources/scripts/state/models/server.ts deleted file mode 100644 index c7b7942dd..000000000 --- a/resources/scripts/state/models/server.ts +++ /dev/null @@ -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>; - setServer: Action; - setServerStatus: Action; - clearServerState: Action; -} - -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; diff --git a/resources/scripts/state/server/index.ts b/resources/scripts/state/server/index.ts new file mode 100644 index 000000000..c29e61bf8 --- /dev/null +++ b/resources/scripts/state/server/index.ts @@ -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>; + setServer: Action; +} + +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; +} + +const status: ServerStatusStore = { + value: 'offline', + setServerStatus: action((state, payload) => { + state.value = payload; + }), +}; + +export interface ServerStore { + server: ServerDataStore; + socket: SocketStore; + status: ServerStatusStore; + clearServerState: Action; +} + +export const ServerContext = createContextStore({ + 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' }); diff --git a/resources/scripts/state/models/socket.ts b/resources/scripts/state/server/socket.ts similarity index 70% rename from resources/scripts/state/models/socket.ts rename to resources/scripts/state/server/socket.ts index 10922feda..e67910668 100644 --- a/resources/scripts/state/models/socket.ts +++ b/resources/scripts/state/server/socket.ts @@ -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; - setConnectionState: Action; + setInstance: Action; + setConnectionState: Action; } -const socket: SocketState = { +const socket: SocketStore = { instance: null, connected: false, setInstance: action((state, payload) => { diff --git a/resources/scripts/state/types.d.ts b/resources/scripts/state/types.d.ts deleted file mode 100644 index d54afc29d..000000000 --- a/resources/scripts/state/types.d.ts +++ /dev/null @@ -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; - clearFlashes: Action; -} - -export interface FlashMessage { - id?: string; - key?: string; - type: FlashMessageType; - title?: string; - message: string; -} diff --git a/resources/scripts/state/models/user.ts b/resources/scripts/state/user.ts similarity index 80% rename from resources/scripts/state/models/user.ts rename to resources/scripts/state/user.ts index c7da19fbc..087bfe003 100644 --- a/resources/scripts/state/models/user.ts +++ b/resources/scripts/state/user.ts @@ -12,14 +12,14 @@ export interface UserData { updatedAt: Date; } -export interface UserState { +export interface UserStore { data?: UserData; - setUserData: Action; - updateUserData: Action>; - updateUserEmail: Thunk>; + setUserData: Action; + updateUserData: Action>; + updateUserEmail: Thunk>; } -const user: UserState = { +const user: UserStore = { data: undefined, setUserData: action((state, payload) => { state.data = payload; diff --git a/resources/styles/components/miscellaneous.css b/resources/styles/components/miscellaneous.css index 158970f9b..f023a834b 100644 --- a/resources/styles/components/miscellaneous.css +++ b/resources/styles/components/miscellaneous.css @@ -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; } } diff --git a/resources/styles/components/modal.css b/resources/styles/components/modal.css index 4deb58cbf..7c3a6ae94 100644 --- a/resources/styles/components/modal.css +++ b/resources/styles/components/modal.css @@ -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; } diff --git a/resources/styles/components/typography.css b/resources/styles/components/typography.css index 301eabb2a..c6b3f10ff 100644 --- a/resources/styles/components/typography.css +++ b/resources/styles/components/typography.css @@ -13,5 +13,5 @@ h1, h2, h3, h4, h5, h6 { } p { - @apply .text-neutral-200; + @apply .text-neutral-200 .leading-snug; } diff --git a/tailwind.js b/tailwind.js index ec7f6c291..2f3314e52 100644 --- a/tailwind.js +++ b/tailwind.js @@ -290,7 +290,9 @@ module.exports = { leading: { 'none': 1, 'tight': 1.25, + 'snug': 1.375, 'normal': 1.5, + 'relaxed': 1.625, 'loose': 2, }, diff --git a/webpack.config.js b/webpack.config.js index 0054d91d3..10bdb1c22 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -135,7 +135,7 @@ module.exports = { }, plugins: plugins, optimization: { - minimize: true, + minimize: isProduction, minimizer: [ new TerserPlugin({ cache: true,