Some mobile improvements for the UI; make console fill space better

This commit is contained in:
DaneEveritt 2022-06-21 18:43:59 -04:00
parent faff263f17
commit 54c619e6ba
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
9 changed files with 127 additions and 93 deletions

View file

@ -0,0 +1,214 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ITerminalOptions, Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search';
import { SearchBarAddon } from 'xterm-addon-search-bar';
import { WebLinksAddon } from 'xterm-addon-web-links';
import { ScrollDownHelperAddon } from '@/plugins/XtermScrollDownHelperAddon';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ServerContext } from '@/state/server';
import { usePermissions } from '@/plugins/usePermissions';
import { theme as th } from 'twin.macro';
import 'xterm/css/xterm.css';
import useEventListener from '@/plugins/useEventListener';
import { debounce } from 'debounce';
import { usePersistedState } from '@/plugins/usePersistedState';
import { SocketEvent, SocketRequest } from '@/components/server/events';
import classNames from 'classnames';
import styles from './style.module.css';
import { ChevronDoubleRightIcon } from '@heroicons/react/solid';
const theme = {
background: th`colors.black`.toString(),
cursor: 'transparent',
black: th`colors.black`.toString(),
red: '#E54B4B',
green: '#9ECE58',
yellow: '#FAED70',
blue: '#396FE2',
magenta: '#BB80B3',
cyan: '#2DDAFD',
white: '#d0d0d0',
brightBlack: 'rgba(255, 255, 255, 0.2)',
brightRed: '#FF5370',
brightGreen: '#C3E88D',
brightYellow: '#FFCB6B',
brightBlue: '#82AAFF',
brightMagenta: '#C792EA',
brightCyan: '#89DDFF',
brightWhite: '#ffffff',
selection: '#FAF089',
};
const terminalProps: ITerminalOptions = {
disableStdin: true,
cursorStyle: 'underline',
allowTransparency: true,
fontSize: 12,
fontFamily: 'Menlo, Monaco, Consolas, monospace',
rows: 30,
theme: theme,
};
export default () => {
const TERMINAL_PRELUDE = '\u001b[1m\u001b[33mcontainer@pterodactyl~ \u001b[0m';
const ref = useRef<HTMLDivElement>(null);
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon();
const searchBar = new SearchBarAddon({ searchAddon });
const webLinksAddon = new WebLinksAddon();
const scrollDownHelperAddon = new ScrollDownHelperAddon();
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
const serverId = ServerContext.useStoreState(state => state.server.data!.id);
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
const [ history, setHistory ] = usePersistedState<string[]>(`${serverId}:command_history`, []);
const [ historyIndex, setHistoryIndex ] = useState(-1);
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
);
const handleTransferStatus = (status: string) => {
switch (status) {
// Sent by either the source or target node if a failure occurs.
case 'failure':
terminal.writeln(TERMINAL_PRELUDE + 'Transfer has failed.\u001b[0m');
return;
// Sent by the source node whenever the server was archived successfully.
case 'archive':
terminal.writeln(TERMINAL_PRELUDE + 'Server has been archived successfully, attempting connection to target node..\u001b[0m');
}
};
const handleDaemonErrorOutput = (line: string) => terminal.writeln(
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
);
const handlePowerChangeEvent = (state: string) => terminal.writeln(
TERMINAL_PRELUDE + 'Server marked as ' + state + '...\u001b[0m',
);
const handleCommandKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowUp') {
const newIndex = Math.min(historyIndex + 1, history!.length - 1);
setHistoryIndex(newIndex);
e.currentTarget.value = history![newIndex] || '';
// By default up arrow will also bring the cursor to the start of the line,
// so we'll preventDefault to keep it at the end.
e.preventDefault();
}
if (e.key === 'ArrowDown') {
const newIndex = Math.max(historyIndex - 1, -1);
setHistoryIndex(newIndex);
e.currentTarget.value = history![newIndex] || '';
}
const command = e.currentTarget.value;
if (e.key === 'Enter' && command.length > 0) {
setHistory(prevHistory => [ command, ...prevHistory! ].slice(0, 32));
setHistoryIndex(-1);
instance && instance.send('send command', command);
e.currentTarget.value = '';
}
};
useEffect(() => {
if (connected && ref.current && !terminal.element) {
terminal.loadAddon(fitAddon);
terminal.loadAddon(searchAddon);
terminal.loadAddon(searchBar);
terminal.loadAddon(webLinksAddon);
terminal.loadAddon(scrollDownHelperAddon);
terminal.open(ref.current);
fitAddon.fit();
// Add support for capturing keys
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
document.execCommand('copy');
return false;
} else if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
searchBar.show();
return false;
} else if (e.key === 'Escape') {
searchBar.hidden();
}
return true;
});
}
}, [ terminal, connected ]);
useEventListener('resize', debounce(() => {
if (terminal.element) {
fitAddon.fit();
}
}, 100));
useEffect(() => {
const listeners: Record<string, (s: string) => void> = {
[SocketEvent.STATUS]: handlePowerChangeEvent,
[SocketEvent.CONSOLE_OUTPUT]: handleConsoleOutput,
[SocketEvent.INSTALL_OUTPUT]: handleConsoleOutput,
[SocketEvent.TRANSFER_LOGS]: handleConsoleOutput,
[SocketEvent.TRANSFER_STATUS]: handleTransferStatus,
[SocketEvent.DAEMON_MESSAGE]: line => handleConsoleOutput(line, true),
[SocketEvent.DAEMON_ERROR]: handleDaemonErrorOutput,
};
if (connected && instance) {
// Do not clear the console if the server is being transferred.
if (!isTransferring) {
terminal.clear();
}
Object.keys(listeners).forEach((key: string) => {
instance.addListener(key, listeners[key]);
});
instance.send(SocketRequest.SEND_LOGS);
}
return () => {
if (instance) {
Object.keys(listeners).forEach((key: string) => {
instance.removeListener(key, listeners[key]);
});
}
};
}, [ connected, instance ]);
return (
<div className={styles.terminal}>
<SpinnerOverlay visible={!connected} size={'large'}/>
<div className={classNames(styles.container, styles.overflows_container, { 'rounded-b': !canSendCommands })}>
<div id={styles.terminal} ref={ref}/>
</div>
{canSendCommands &&
<div className={classNames('relative', styles.overflows_container)}>
<input
className={classNames('peer', styles.command_input)}
type={'text'}
placeholder={'Type a command...'}
aria-label={'Console command input.'}
disabled={!instance || !connected}
onKeyDown={handleCommandKeyDown}
autoCorrect={'off'}
autoCapitalize={'none'}
/>
<div className={classNames('text-gray-100 peer-focus:text-gray-50 peer-focus:animate-pulse', styles.command_icon)}>
<ChevronDoubleRightIcon className={'w-4 h-4'}/>
</div>
</div>
}
</div>
);
};

View file

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Button } from '@/components/elements/button/index';
import Can from '@/components/elements/Can';
import { ServerContext } from '@/state/server';
import { PowerAction } from '@/components/server/ServerConsole';
import { PowerAction } from '@/components/server/console/ServerConsoleContainer';
import { Dialog } from '@/components/elements/dialog';
interface PowerButtonProps {
@ -41,7 +41,7 @@ export default ({ className }: PowerButtonProps) => {
</Dialog.Confirm>
<Can action={'control.start'}>
<Button
className={'w-24'}
className={'w-full sm:w-24'}
disabled={status !== 'offline'}
onClick={onButtonClick.bind(this, 'start')}
>
@ -50,7 +50,7 @@ export default ({ className }: PowerButtonProps) => {
</Can>
<Can action={'control.restart'}>
<Button.Text
className={'w-24'}
className={'w-full sm:w-24'}
variant={Button.Variants.Secondary}
disabled={!status}
onClick={onButtonClick.bind(this, 'restart')}
@ -60,7 +60,7 @@ export default ({ className }: PowerButtonProps) => {
</Can>
<Can action={'control.stop'}>
<Button.Danger
className={'w-24'}
className={'w-full sm:w-24'}
variant={killable ? undefined : Button.Variants.Secondary}
disabled={status === 'offline'}
onClick={onButtonClick.bind(this, killable ? 'kill' : 'stop')}

View file

@ -0,0 +1,77 @@
import React, { memo } from 'react';
import { ServerContext } from '@/state/server';
import Can from '@/components/elements/Can';
import ContentContainer from '@/components/elements/ContentContainer';
import tw from 'twin.macro';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import isEqual from 'react-fast-compare';
import Spinner from '@/components/elements/Spinner';
import Features from '@feature/Features';
import Console from '@/components/server/console/Console';
import StatGraphs from '@/components/server/console/StatGraphs';
import PowerButtons from '@/components/server/console/PowerButtons';
import ServerDetailsBlock from '@/components/server/console/ServerDetailsBlock';
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
const ServerConsoleContainer = () => {
const name = ServerContext.useStoreState(state => state.server.data!.name);
const description = ServerContext.useStoreState(state => state.server.data!.description);
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
const eggFeatures = ServerContext.useStoreState(state => state.server.data!.eggFeatures, isEqual);
return (
<ServerContentBlock title={'Console'} className={'flex flex-col gap-2 sm:gap-4'}>
<div className={'flex gap-4 items-end'}>
<div className={'hidden sm:block flex-1'}>
<h1 className={'font-header text-2xl text-gray-50 leading-relaxed line-clamp-1'}>{name}</h1>
<p className={'text-sm line-clamp-2'}>{description}</p>
</div>
<div className={'flex-1'}>
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
<PowerButtons className={'flex sm:justify-end space-x-2'}/>
</Can>
</div>
</div>
<div className={'grid grid-cols-4 gap-2 sm:gap-4'}>
<ServerDetailsBlock className={'col-span-4 lg:col-span-1 order-last lg:order-none'}/>
<div className={'col-span-4 lg:col-span-3'}>
<Spinner.Suspense>
<Console/>
</Spinner.Suspense>
</div>
{isInstalling ?
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
<ContentContainer>
<p css={tw`text-sm text-yellow-900`}>
This server is currently running its installation process and most actions are
unavailable.
</p>
</ContentContainer>
</div>
:
isTransferring ?
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
<ContentContainer>
<p css={tw`text-sm text-yellow-900`}>
This server is currently being transferred to another node and all actions
are unavailable.
</p>
</ContentContainer>
</div>
:
null
}
</div>
<div className={'grid grid-cols-1 md:grid-cols-3 gap-2 sm:gap-4'}>
<Spinner.Suspense>
<StatGraphs/>
</Spinner.Suspense>
</div>
<Features enabled={eggFeatures}/>
</ServerContentBlock>
);
};
export default memo(ServerConsoleContainer, isEqual);

View file

@ -0,0 +1,160 @@
import React, { useEffect, useState } from 'react';
import {
faClock,
faCloudDownloadAlt,
faCloudUploadAlt,
faHdd,
faMemory,
faMicrochip,
faWifi,
} from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, formatIp, megabytesToHuman } from '@/helpers';
import { ServerContext } from '@/state/server';
import { SocketEvent, SocketRequest } from '@/components/server/events';
import UptimeDuration from '@/components/server/UptimeDuration';
import StatBlock from '@/components/server/console/StatBlock';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import classNames from 'classnames';
type Stats = Record<'memory' | 'cpu' | 'disk' | 'uptime' | 'rx' | 'tx', number>;
const getBackgroundColor = (value: number, max: number | null): string | undefined => {
const delta = !max ? 0 : (value / max);
if (delta > 0.8) {
if (delta > 0.9) {
return 'bg-red-500';
}
return 'bg-yellow-500';
}
return undefined;
};
const ServerDetailsBlock = ({ className }: { className?: string }) => {
const [ stats, setStats ] = useState<Stats>({ memory: 0, cpu: 0, disk: 0, uptime: 0, tx: 0, rx: 0 });
const status = ServerContext.useStoreState(state => state.status.value);
const connected = ServerContext.useStoreState(state => state.socket.connected);
const instance = ServerContext.useStoreState(state => state.socket.instance);
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const allocation = ServerContext.useStoreState(state => {
const match = state.server.data!.allocations.find(allocation => allocation.isDefault);
return !match ? 'n/a' : `${match.alias || formatIp(match.ip)}:${match.port}`;
});
useEffect(() => {
if (!connected || !instance) {
return;
}
instance.send(SocketRequest.SEND_STATS);
}, [ instance, connected ]);
useWebsocketEvent(SocketEvent.STATS, (data) => {
let stats: any = {};
try {
stats = JSON.parse(data);
} catch (e) {
return;
}
setStats({
memory: stats.memory_bytes,
cpu: stats.cpu_absolute,
disk: stats.disk_bytes,
tx: stats.network.tx_bytes,
rx: stats.network.rx_bytes,
uptime: stats.uptime || 0,
});
});
return (
<div className={classNames('grid grid-cols-6 gap-2 md:gap-4', className)}>
<StatBlock
icon={faClock}
title={'Uptime'}
color={getBackgroundColor(status === 'running' ? 0 : (status !== 'offline' ? 9 : 10), 10)}
>
{stats.uptime > 0 ?
<UptimeDuration uptime={stats.uptime / 1000}/>
:
'Offline'
}
</StatBlock>
<StatBlock
icon={faMicrochip}
title={'CPU'}
color={getBackgroundColor(stats.cpu, limits.cpu)}
description={limits.memory
? `This server is allowed to use up to ${limits.cpu}% of the host's available CPU resources.`
: 'No CPU limit has been configured for this server.'
}
>
{status === 'offline' ?
<span className={'text-gray-400'}>Offline</span>
:
`${stats.cpu.toFixed(2)}%`
}
</StatBlock>
<StatBlock
icon={faMemory}
title={'Memory'}
color={getBackgroundColor(stats.memory / 1024, limits.memory * 1024)}
description={limits.memory
? `This server is allowed to use up to ${megabytesToHuman(limits.memory)} of memory.`
: 'No memory limit has been configured for this server.'
}
>
{status === 'offline' ?
<span className={'text-gray-400'}>Offline</span>
:
bytesToHuman(stats.memory)
}
</StatBlock>
<StatBlock
icon={faHdd}
title={'Disk'}
color={getBackgroundColor(stats.disk / 1024, limits.disk * 1024)}
description={limits.disk
? `This server is allowed to use up to ${megabytesToHuman(limits.disk)} of disk space.`
: 'No disk space limit has been configured for this server.'
}
>
{bytesToHuman(stats.disk)}
</StatBlock>
<StatBlock
icon={faCloudDownloadAlt}
title={'Network (Inbound)'}
description={'The total amount of network traffic that your server has recieved since it was started.'}
>
{status === 'offline' ?
<span className={'text-gray-400'}>Offline</span>
:
bytesToHuman(stats.tx)
}
</StatBlock>
<StatBlock
icon={faCloudUploadAlt}
title={'Network (Outbound)'}
description={'The total amount of traffic your server has sent across the internet since it was started.'}
>
{status === 'offline' ?
<span className={'text-gray-400'}>Offline</span>
:
bytesToHuman(stats.rx)
}
</StatBlock>
<StatBlock
icon={faWifi}
title={'Address'}
description={`You can connect to your server at: ${allocation}`}
>
{allocation}
</StatBlock>
</div>
);
};
export default ServerDetailsBlock;

View file

@ -3,6 +3,7 @@ import Icon from '@/components/elements/Icon';
import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import classNames from 'classnames';
import Tooltip from '@/components/elements/tooltip/Tooltip';
import styles from './style.module.css';
interface StatBlockProps {
title: string;
@ -10,33 +11,26 @@ interface StatBlockProps {
color?: string | undefined;
icon: IconDefinition;
children: React.ReactNode;
className?: string;
}
export default ({ title, icon, color, description, children }: StatBlockProps) => {
export default ({ title, icon, color, description, className, children }: StatBlockProps) => {
return (
<Tooltip arrow placement={'top'} disabled={!description} content={description || ''}>
<div className={'flex items-center space-x-4 bg-gray-600 rounded p-4 shadow-lg'}>
<div
className={classNames(
'transition-colors duration-500',
'flex-shrink-0 flex items-center justify-center w-12 h-12 rounded-lg shadow-md',
color || 'bg-gray-700',
)}
>
<div className={classNames(styles.stat_block, 'bg-gray-600', className)}>
<div className={classNames(styles.status_bar, color || 'bg-gray-700')}/>
<div className={classNames(styles.icon, color || 'bg-gray-700')}>
<Icon
icon={icon}
className={classNames(
'w-6 h-6 m-auto',
{
'text-gray-100': !color || color === 'bg-gray-700',
'text-gray-50': color && color !== 'bg-gray-700',
},
)}
className={classNames({
'text-gray-100': !color || color === 'bg-gray-700',
'text-gray-50': color && color !== 'bg-gray-700',
})}
/>
</div>
<div className={'flex flex-col justify-center overflow-hidden'}>
<p className={'font-header leading-tight text-sm text-gray-200'}>{title}</p>
<p className={'text-xl font-semibold text-gray-50 truncate'}>
<p className={'font-header leading-tight text-xs md:text-sm text-gray-200'}>{title}</p>
<p className={'text-base md:text-xl font-semibold text-gray-50 truncate'}>
{children}
</p>
</div>

View file

@ -0,0 +1,170 @@
import React, { useCallback, useRef, useState } from 'react';
import Chart, { ChartConfiguration } from 'chart.js';
import { ServerContext } from '@/state/server';
import merge from 'deepmerge';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { faEthernet, faMemory, faMicrochip } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro';
import { SocketEvent } from '@/components/server/events';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
const chartDefaults = (ticks?: Chart.TickOptions): ChartConfiguration => ({
type: 'line',
options: {
legend: {
display: false,
},
tooltips: {
enabled: false,
},
animation: {
duration: 0,
},
elements: {
point: {
radius: 0,
},
line: {
tension: 0.3,
backgroundColor: 'rgba(15, 178, 184, 0.45)',
borderColor: '#32D0D9',
},
},
scales: {
xAxes: [ {
ticks: {
display: false,
},
gridLines: {
display: false,
},
} ],
yAxes: [ {
gridLines: {
drawTicks: false,
color: 'rgba(229, 232, 235, 0.15)',
zeroLineColor: 'rgba(15, 178, 184, 0.45)',
zeroLineWidth: 3,
},
ticks: merge(ticks || {}, {
fontSize: 10,
fontFamily: '"IBM Plex Mono", monospace',
fontColor: 'rgb(229, 232, 235)',
min: 0,
beginAtZero: true,
maxTicksLimit: 5,
}),
} ],
},
},
data: {
labels: Array(20).fill(''),
datasets: [
{
fill: true,
data: Array(20).fill(0),
},
],
},
});
type ChartState = [ (node: HTMLCanvasElement | null) => void, Chart | undefined ];
/**
* Creates an element ref and a chart instance.
*/
const useChart = (options?: Chart.TickOptions): ChartState => {
const [ chart, setChart ] = useState<Chart>();
const ref = useCallback<(node: HTMLCanvasElement | null) => void>(node => {
if (!node) return;
const chart = new Chart(node.getContext('2d')!, chartDefaults(options));
setChart(chart);
}, []);
return [ ref, chart ];
};
const updateChartDataset = (chart: Chart | null | undefined, value: Chart.ChartPoint & number): void => {
if (!chart || !chart.data?.datasets) return;
const data = chart.data.datasets[0].data!;
data.push(value);
data.shift();
chart.update({ lazy: true });
};
export default () => {
const status = ServerContext.useStoreState(state => state.status.value);
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const previous = useRef<Record<'tx' | 'rx', number>>({ tx: -1, rx: -1 });
const [ cpuRef, cpu ] = useChart({ callback: (value) => `${value}% `, suggestedMax: limits.cpu });
const [ memoryRef, memory ] = useChart({ callback: (value) => `${value}Mb `, suggestedMax: limits.memory });
const [ txRef, tx ] = useChart({ callback: (value) => `${value}Kb/s ` });
const [ rxRef, rx ] = useChart({ callback: (value) => `${value}Kb/s ` });
useWebsocketEvent(SocketEvent.STATS, (data: string) => {
let stats: any = {};
try {
stats = JSON.parse(data);
} catch (e) {
return;
}
updateChartDataset(cpu, stats.cpu_absolute);
updateChartDataset(memory, Math.floor(stats.memory_bytes / 1024 / 1024));
updateChartDataset(tx, previous.current.tx < 0 ? 0 : Math.max(0, stats.network.tx_bytes - previous.current.tx) / 1024);
updateChartDataset(rx, previous.current.rx < 0 ? 0 : Math.max(0, stats.network.rx_bytes - previous.current.rx) / 1024);
previous.current = { tx: stats.network.tx_bytes, rx: stats.network.rx_bytes };
});
return (
<>
<TitledGreyBox title={'Memory usage'} icon={faMemory}>
{status !== 'offline' ?
<canvas
id={'memory_chart'}
ref={memoryRef}
aria-label={'Server Memory Usage Graph'}
role={'img'}
/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip}>
{status !== 'offline' ?
<canvas id={'cpu_chart'} ref={cpuRef} aria-label={'Server CPU Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<TitledGreyBox title={'Inbound Data'} icon={faEthernet}>
{status !== 'offline' ?
<canvas id={'rx_chart'} ref={rxRef} aria-label={'Server Inbound Data'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<TitledGreyBox title={'Outbound Data'} icon={faEthernet}>
{status !== 'offline' ?
<canvas id={'tx_chart'} ref={txRef} aria-label={'Server Outbound Data'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
</>
);
};

View file

@ -0,0 +1,59 @@
.stat_block {
@apply flex items-center rounded shadow-lg relative;
@apply col-span-3 md:col-span-2 lg:col-span-6;
@apply px-3 py-2 md:p-3 lg:p-4;
& > .status_bar {
@apply w-1 h-full absolute left-0 top-0 rounded-l sm:hidden;
}
& > .icon {
@apply hidden flex-shrink-0 items-center justify-center rounded-lg shadow-md w-12 h-12;
@apply transition-colors duration-500;
@apply sm:flex sm:mr-4;
& > svg {
@apply w-6 h-6 m-auto;
}
}
}
.terminal {
@apply relative h-full flex flex-col;
& .overflows_container {
@apply -ml-4 sm:ml-0;
width: calc(100% + 2rem);
@screen sm {
@apply w-full;
}
}
& > .container {
@apply rounded-t p-1 sm:p-2 bg-black min-h-[16rem] flex-1 font-mono text-sm;
& #terminal {
@apply h-full;
&::-webkit-scrollbar-track {
@apply w-2;
}
&::-webkit-scrollbar-thumb {
@apply bg-gray-900;
}
}
}
& .command_icon {
@apply flex items-center top-0 left-0 absolute z-10 select-none h-full px-3 transition-colors duration-100;
}
& .command_input {
@apply relative bg-gray-900 px-2 text-gray-100 pl-10 pr-4 py-2 w-full font-mono text-sm sm:rounded-b;
@apply focus:ring-0 outline-none focus-visible:outline-none;
@apply border-0 border-b-2 border-transparent transition-colors duration-100;
@apply active:border-cyan-500 focus:border-cyan-500;
}
}