Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: console palette #53

Merged
merged 5 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/client/src/AuthenticatedApp.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import Navbar from 'features/Navbar'
import Stations from 'features/Stations/List'
import Channels from 'features/Channels/List'
Expand All @@ -7,6 +7,7 @@ import Messenger from 'features/Messages/Messenger'
import LogoutModal from 'features/Me/Logout'
import ChannelCreate from 'features/Channels/Create'
import JoinOrCreateStation from 'features/Stations/JoinOrCreateDialog'
import Console from 'features/Console'
import { selectCurrentStation } from 'features/Stations/slice'
import { selectCurrentChannel } from 'features/Channels/slice'
import { useSelector } from 'react-redux'
Expand All @@ -16,9 +17,25 @@ const AuthenticatedApp: React.FC = () => {
const [stationModalOpened, setStationModalOpened] = useState<boolean>(false)
const [logoutModalOpened, setLogoutModalOpened] = useState<boolean>(false)
const [newChannelModalOpened, setNewChannelModalOpened] = useState<boolean>(false)
const [consoleOpened, setConsoleOpened] = useState<boolean>(false)
const currentStation = useSelector(selectCurrentStation)
const currentChannelId = useSelector(selectCurrentChannel)

useEffect(() => {
const consoleListener = (e: KeyboardEvent) => {
if (e.code.toLowerCase() === 'keyp' && e.ctrlKey && e.shiftKey) {
e.preventDefault()
setConsoleOpened(opened => !opened)
}
}

document.addEventListener('keydown', consoleListener)

return () => {
document.removeEventListener('keydown', consoleListener)
}
}, [])

return (
<main className='flex-grow absolute'>
<React.Fragment>
Expand All @@ -30,6 +47,10 @@ const AuthenticatedApp: React.FC = () => {
opened={stationModalOpened}
handler={setStationModalOpened}
/>
<Console
opened={consoleOpened}
handler={setConsoleOpened}
/>
<ToastContainer style={{ zIndex: 99999 }} />
</React.Fragment>
<div className='flex flex-col'>
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/app/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ export default combineReducers({
channels: channels.reducer,
createChannel: createChannel.reducer,
messages: messages.reducer,
messenger: messenger.reducer,
messenger: messenger.reducer
})
6 changes: 3 additions & 3 deletions apps/client/src/features/Channels/Create/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ const Create: React.FC<Props> = ({ handler, opened, stationId }) => {
}, [isError])

return (
<Dialog open={opened} size='xs' className='transition-all ease-out' handler={handler} placeholder={undefined}>
<Card className="mx-auto w-full" placeholder={undefined}>
<CardBody className="flex flex-col gap-4" placeholder={undefined}>
<Dialog open={opened} size='xs' className='transition-all ease-out' handler={handler}>
<Card className="mx-auto w-full">
<CardBody className="flex flex-col gap-4">
<Typography variant='h3' className='text-center'>Add channel</Typography>
<form onSubmit={onSubmitHandler} className='flex flex-col gap-4'>
<Input
Expand Down
9 changes: 6 additions & 3 deletions apps/client/src/features/Channels/List/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const List: React.FC<Props> = ({ stationId, stationName, onNewChannel }) => {
const isFetching = useSelector(selectIsFetching)

const changeChannelHandler = (id: Channel['id']): void => {
if (current === id) {
return
}

dispatch(changeChannel(id))
}

Expand All @@ -32,17 +36,16 @@ const List: React.FC<Props> = ({ stationId, stationName, onNewChannel }) => {
}, [dispatch, stationId])

return (
<Card className='flex flex-col w-72 h-[calc(100vh-78px)] rounded-none bg-white' placeholder={undefined}>
<Card className='flex flex-col w-72 h-[calc(100vh-78px)] rounded-none bg-white'>
<StationControls stationName={stationName} onNewChannel={onNewChannel} />
<MatList placeholder={undefined}>
<MatList>
{isFetching
? <Fallback prediction={6} />
: items.map(
channel =>
<ListItem
key={channel.id}
selected={current === channel.id}
placeholder={undefined}
onClick={() => changeChannelHandler(channel.id)}
>
{channel.name}
Expand Down
19 changes: 19 additions & 0 deletions apps/client/src/features/Console/Commands/Logout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type AppDispatch, type RootState } from 'app/store'
import { CommandConfig, ICommand } from 'features/Console/ICommand'
import { ArrowRightStartOnRectangleIcon } from '@heroicons/react/24/outline'
import { logout } from 'features/Me/Logout/slice'

export default class Logout implements ICommand {
getConfig(): CommandConfig {
return ({
label: 'Logout',
tag: 'logout',
description: 'Logout from chatterer',
icon: <ArrowRightStartOnRectangleIcon strokeWidth={2.5} className={`h-3.5 w-3.5`} />
})
}

process(dispatch: AppDispatch, _state: RootState): void {
dispatch(logout())
}
}
5 changes: 5 additions & 0 deletions apps/client/src/features/Console/Commands/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Logout from 'features/Console/Commands/Logout'

export default [
new Logout()
]
15 changes: 15 additions & 0 deletions apps/client/src/features/Console/ICommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { type AppDispatch, type RootState } from 'app/store'
import { type ReactNode } from 'react'
import { type Nullable } from 'utils'

export type CommandConfig = {
label: string
tag: string
description: Nullable<string>
icon: Nullable<ReactNode>
}

export interface ICommand {
getConfig(): CommandConfig
process(dispatch: AppDispatch, state: RootState): void
}
45 changes: 45 additions & 0 deletions apps/client/src/features/Console/Prompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react'
import Input from 'widgets/Forms/Input'

type Props = {
ready: boolean,
value: string,
handler: React.Dispatch<React.SetStateAction<string>>
onSubmit: () => void
}

const Prompt: React.FC<Props> = ({ ready, value, handler, onSubmit }) => {
const onKeyDownHandler: React.KeyboardEventHandler<HTMLInputElement> = e => {
if (['arrowup', 'arrowdown'].includes(e.key.toLowerCase())) {
e.preventDefault()
return
}

if (e.code.toLowerCase() === 'enter') {
e.preventDefault()
onSubmit()
return
}
}

return (
<Input
autoFocus
size='lg'
className='w-full !bg-blue-gray-50'
placeholder='Search...'
label='Search'
onChange={e => handler(e.target.value)}
onKeyDown={onKeyDownHandler}
value={value}
disabled={!ready}
icon={
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
}
/>
)
}

export default Prompt
126 changes: 126 additions & 0 deletions apps/client/src/features/Console/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { type RootState } from 'app/store'
import { type ICommand } from 'features/Console/ICommand'
import {
Dialog,
Card,
CardBody,
List,
ListItem
} from '@material-tailwind/react'
import { useStore } from 'react-redux'
import Typography from 'widgets/Texts/Typography'
import Prompt from 'features/Console/Prompt'
import commands from 'features/Console/Commands/provider'

type Props = {
handler: React.Dispatch<React.SetStateAction<boolean>>,
opened: boolean
}

const Console: React.FC<Props> = ({ opened, handler }) => {
const store = useStore<RootState>()
const [prompt, setPrompt] = useState<string>('')
const [cursor, setCursor] = useState<number>(0)
const [registeredCommands, _setRegisteredCommands] = useState<ICommand[]>(commands)

const commandsInitialized = useMemo(() => {
return registeredCommands.length > 0
}, [registeredCommands])

const filteredCommands = useMemo(() => {
if (!prompt.length) {
return []
}

return commands
.filter(cmd => cmd.getConfig().label.toLowerCase().includes(prompt.toLowerCase()))
}, [commands, prompt])

const cursorNext = useCallback(() => {
if (filteredCommands.length <= 1) {
return
}

setCursor(c => c === filteredCommands.length -1 ? 0 : c + 1)
}, [cursor, filteredCommands])

const cursorPrevious = useCallback(() => {
if (filteredCommands.length <= 1) {
return
}

setCursor(c => c === 0 ? filteredCommands.length - 1 : c - 1)
}, [cursor, filteredCommands])

const processSelectedCmd = useCallback(() => {
return filteredCommands[cursor]?.process(store.dispatch, store.getState())
}, [cursor, filteredCommands, store])

useEffect(() => {
if (!opened) {
setPrompt('')
return
}

const cursorListener = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'arrowdown') {
cursorNext()
return
}

cursorPrevious()
}

document.addEventListener('keydown', cursorListener)

return () => {
document.removeEventListener('keydown', cursorListener)
}
}, [opened, filteredCommands])

return (
<Dialog
open={opened}
handler={handler}
size='sm'
className='transition-all ease-out'
animate={{
mount: { scale: 1, y: 0 },
unmount: { scale: 0.9, y: -100 },
}}
>
<Card className="mx-auto w-full">
<CardBody className="flex flex-col gap-0 !p-2">
<Prompt
value={prompt}
handler={setPrompt}
ready={commandsInitialized}
onSubmit={processSelectedCmd}
/>
<List className='transition-all ease-in-out'>
{filteredCommands.map(
(cmd, i) =>
<ListItem
key={cmd.getConfig().tag}
onClick={() => cmd.process(store.dispatch, store.getState())}
className='flex flex-row items-center gap-2'
selected={cursor === i}
>
{!!cmd.getConfig().icon && cmd.getConfig().icon}
<div className='flex flex-col gap-1'>
{cmd.getConfig().label}
{!!cmd.getConfig().description &&
<Typography variant='small'>{cmd.getConfig().description}</Typography>
}
</div>
</ListItem>
)}
</List>
</CardBody>
</Card>
</Dialog>
)
}

export default Console
7 changes: 3 additions & 4 deletions apps/client/src/features/Me/Authentication/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,20 @@ const Authenticate: React.FC = () => {

return (
<div className='flex h-screen'>
<Card className="w-96 m-auto" placeholder={undefined}>
<Card className="w-96 m-auto">
<form onSubmit={onSubmitHandler}>
<CardHeader
variant="gradient"
color="gray"
className="mb-4 grid h-28 place-items-center"
placeholder={undefined}
>
<Typography variant="h4" color="white">Welcome</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4" placeholder={undefined}>
<CardBody className="flex flex-col gap-4">
<Input name='username' label="Email" placeholder='Email' size="lg" required />
<Input name='password' label="Password" placeholder='Password' size="lg" type='password' required />
</CardBody>
<CardFooter className="pt-0" placeholder={undefined}>
<CardFooter className="pt-0">
<Button variant="gradient" type='submit' fullWidth>
Sign In
</Button>
Expand Down
3 changes: 2 additions & 1 deletion apps/client/src/features/Me/Logout/effects.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { call, put, takeLatest } from 'redux-saga/effects'
import { remove } from 'services/storage'
import { logout, error } from 'features/Me/Logout/slice'
import { logout, loggedOut, error } from 'features/Me/Logout/slice'

export function* logoutEffect(): Generator {
try {
yield (call(remove, 'token'))
yield put(loggedOut())
} catch (e) {
yield put(error())
}
Expand Down
8 changes: 4 additions & 4 deletions apps/client/src/features/Me/Logout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ const Logout: React.FC<Props> = ({ opened, handler }) => {
}

return (
<Dialog open={opened} handler={handler} placeholder={undefined}>
<Dialog open={opened} handler={handler}>
<form onSubmit={onSubmitHandler}>
<DialogHeader placeholder={undefined}>Logout</DialogHeader>
<DialogBody placeholder={undefined}>
<DialogHeader>Logout</DialogHeader>
<DialogBody>
Are you sure to logout from your account ?
</DialogBody>
<DialogFooter placeholder={undefined}>
<DialogFooter>
<Button
variant='text'
className='mr-1'
Expand Down
7 changes: 6 additions & 1 deletion apps/client/src/features/Me/Logout/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ const slice = createSlice({
reducers: {
logout: state => ({
...state,
state: LogoutStatus.LoggingOut
status: LogoutStatus.LoggingOut
}),
loggedOut: state => ({
...state,
status: LogoutStatus.Initial,
}),
error: state => ({
...state,
Expand All @@ -32,6 +36,7 @@ const slice = createSlice({

export const {
logout,
loggedOut,
error
} = slice.actions

Expand Down
Loading
Loading