Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ dist-ssr

.yalc/
yalc.lock
.env
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"dependencies": {
"@fluentui/react-components": "^9.68.2",
"@reduxjs/toolkit": "^2.6.1",
"@reduxjs/toolkit": "^2.8.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-redux": "^9.2.0"
Expand Down
1 change: 0 additions & 1 deletion src/App.tsx

This file was deleted.

91 changes: 91 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useState } from "react"
import {
FluentProvider,
webLightTheme,
webDarkTheme,
makeStaticStyles,
} from "@fluentui/react-components"
import {
WrenchSettingsFilled,
WrenchSettingsRegular,
TimerFilled,
TimerRegular,
bundleIcon,
} from "@fluentui/react-icons"
import type { Theme } from "@fluentui/react-components"
import { useAppSelector } from "@/app/hooks"
import type { SelectTabData, SelectTabEvent } from "@fluentui/react-components"
import { TimersList } from "@/features/timersList/TimersList"
import { NewTimerForm } from "@/features/newTimerForm/NewTimerForm"
import { Settings } from "@/features/settings/Settings"
import { selectTabsView, selectTheme } from "@/features/settings/slice"
import { TabsView } from "@/components/TabsView"
import { useTimer } from "@/hooks/useTicker"
import { FREQUENCY_MS } from "./constants"
import type { AppTheme } from "./types"

const TimerIcon = bundleIcon(TimerFilled, TimerRegular)
const SettingsIcon = bundleIcon(WrenchSettingsFilled, WrenchSettingsRegular)

const useGlobalStyles = makeStaticStyles([
"html, body, #root, .App { margin: 0; padding: 0; min-height: 100vh; }",
])

const THEMES: { [K in AppTheme]: Theme } = {
dark: webDarkTheme,
light: webLightTheme,
}

export const App = () => {
useGlobalStyles()
useTimer(FREQUENCY_MS)

const tabsView: boolean = useAppSelector(selectTabsView)
const theme: AppTheme = useAppSelector(selectTheme)

const [selectedTab, setSelectedTab] = useState<string>("timers")

const onTabSelect = (_event: SelectTabEvent, data: SelectTabData) => {
setSelectedTab(data.value as string)
}

const tabs = [
{
id: "timers",
label: "Timers",
icon: <TimerIcon />,
content: (
<>
<TimersList />
<NewTimerForm />
</>
),
},
{
id: "settings",
label: "Settings",
icon: <SettingsIcon />,
content: <Settings />,
},
]

return (
<FluentProvider theme={THEMES[theme]}>
<div className="App">
{tabsView ? (
<TabsView
tabs={tabs}
selected={selectedTab}
onTabSelect={onTabSelect}
/>
) : (
<div>
<TimersList />
<NewTimerForm />
<Settings />
</div>
)}
</div>
</FluentProvider>
)
}
14 changes: 14 additions & 0 deletions src/app/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const FREQUENCY_MS = 250
export const LOCAL = "local"

export enum THEME {
dark = "dark",
light = "light",
}

export enum STATUS {
idle = "idle",
pending = "pending",
succeeded = "succeeded",
failed = "failed",
}
6 changes: 6 additions & 0 deletions src/app/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useDispatch, useSelector, useStore } from "react-redux"
import type { AppDispatch, AppStore, RootState } from "./store"

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()
22 changes: 22 additions & 0 deletions src/app/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { configureStore } from "@reduxjs/toolkit"
import settingsReducer from "@/features/settings/slice"
import timersReducer from "@/features/timersList/slice"
import timerReducer from "@/features/timer/slice"
import newTimerFormReducer from "@/features/newTimerForm/slice"
import { settingsMiddleware } from "@/features/settings/middleware"
import { timerMiddelware } from "@/features/timer/middelware"

export const store = configureStore({
reducer: {
settings: settingsReducer,
timers: timersReducer,
timer: timerReducer,
form: newTimerFormReducer,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(settingsMiddleware, timerMiddelware),
})

export type AppStore = typeof store
export type RootState = ReturnType<AppStore["getState"]>
export type AppDispatch = AppStore["dispatch"]
13 changes: 13 additions & 0 deletions src/app/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { STATUS, THEME } from "./constants"

export type AppTheme = THEME.dark | THEME.light
export type Status =
| STATUS.idle
| STATUS.pending
| STATUS.succeeded
| STATUS.failed

export interface FetchStatus {
status: Status
error: string | null
}
8 changes: 8 additions & 0 deletions src/app/withTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createAsyncThunk } from "@reduxjs/toolkit"

import type { RootState, AppDispatch } from "./store"

export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
}>()
40 changes: 40 additions & 0 deletions src/components/TabsView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ReactNode } from "react"
import { TabList, Tab } from "@fluentui/react-components"
import type {
TabSlots,
SelectTabData,
SelectTabEvent,
} from "@fluentui/react-components"

type TabConfig = {
id: string
label: string
icon?: TabSlots["icon"]
content: ReactNode
}

interface TabsViewProps {
tabs: TabConfig[]
selected: string
onTabSelect: (event: SelectTabEvent, data: SelectTabData) => void
}

export function TabsView({ tabs, selected, onTabSelect }: TabsViewProps) {
return (
<>
<TabList
selectedValue={selected}
onTabSelect={onTabSelect}
style={{ marginBottom: "20px" }}
>
{tabs.map(({ id, icon, label }) => (
<Tab key={id} id={id} icon={icon} value={id}>
{label}
</Tab>
))}
</TabList>

<div>{tabs.find(tab => tab.id === selected)?.content}</div>
</>
)
}
15 changes: 15 additions & 0 deletions src/components/ThemeSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Spinner, SpinnerProps } from "@fluentui/react-components"
import { selectTheme } from "@/features/settings/slice"
import { useAppSelector } from "@/app/hooks"
import { THEME } from "@/app/constants"

export function ThemeSpinner(props: SpinnerProps) {
const theme = useAppSelector(selectTheme)

return (
<Spinner
appearance={theme === THEME.dark ? "inverted" : "primary"}
{...props}
/>
)
}
80 changes: 80 additions & 0 deletions src/features/newTimerForm/NewTimerForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useState, FormEvent } from "react"
import {
Input,
Label,
Button,
makeStyles,
useId,
} from "@fluentui/react-components"
import { useAppDispatch, useAppSelector } from "@/app/hooks"
import { createTimer } from "@/features/timersList/thunks"
import { resetTimer } from "@/features/timer/slice"
import {
selectFormStatus,
selectFormError,
} from "@/features/newTimerForm/slice"
import { LOCAL, STATUS } from "@/app/constants"
import { name } from "@/features/newTimerForm/slice"
import { ThemeSpinner } from "@/components/ThemeSpinner"

const useStyles = makeStyles({
form: {
display: "flex",
flexDirection: "column",
gap: "5px",
padding: "20px 0 0 20px",
},
input: {
marginRight: "10px",
},
})

export const NewTimerForm = () => {
const inputId = useId("input")
const styles = useStyles()
const dispatch = useAppDispatch()
const status = useAppSelector(selectFormStatus)
const error = useAppSelector(selectFormError)
const [newTimerName, setNewTimerName] = useState<string>("")

const onSubmit = (e: FormEvent) => {
e.preventDefault()

if (newTimerName === LOCAL) {
dispatch(resetTimer())
} else {
dispatch(createTimer({ id: newTimerName, source: name }))
}

setNewTimerName("")
}

return (
<form className={styles.form} onSubmit={onSubmit}>
<Label htmlFor={inputId}>New timer</Label>
<div style={{ display: "flex", alignItems: "center" }}>
<Input
id={inputId}
className={styles.input}
value={newTimerName}
onChange={e => setNewTimerName(e.target.value)}
title="Enter the name of a new timer"
placeholder="Enter the name"
/>
<Button
appearance="primary"
disabled={newTimerName.length === 0}
type="submit"
>
add
</Button>
{status === STATUS.pending && (
<ThemeSpinner size="tiny" style={{ marginLeft: "10px" }} />
)}
{status === STATUS.failed && (
<span style={{ color: "red", marginLeft: "10px" }}>{error}</span>
)}
</div>
</form>
)
}
45 changes: 45 additions & 0 deletions src/features/newTimerForm/slice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createSlice } from "@reduxjs/toolkit"
import type { RootState } from "@/app/store"
import { createTimer } from "@/features/timersList/thunks"
import { STATUS } from "@/app/constants"
import type { FetchStatus } from "@/app/types"

export const name = "form"

const initialState: FetchStatus = {
status: STATUS.idle,
error: null,
}

export const form = createSlice({
name,
initialState,
reducers: {},
extraReducers: builder => {
builder.addCase(createTimer.pending, (state, action) => {
if (action.meta.arg.source === name) {
state.status = STATUS.pending
state.error = null
}
})
builder.addCase(createTimer.fulfilled, (state, action) => {
if (action.meta.arg.source === name) {
state.status = STATUS.succeeded
state.error = null
}
})
builder.addCase(createTimer.rejected, (state, action: any) => {
if (action.meta.arg.source === name) {
state.status = STATUS.failed
state.error = action.payload ?? "Unknown Error"
}
})
},
})

export const {} = form.actions

export default form.reducer

export const selectFormStatus = (state: RootState) => state.form.status
export const selectFormError = (state: RootState) => state.form.error
Loading