diff --git a/client/src/app.tsx b/client/src/app.tsx index 871c226..64e5004 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -8,40 +8,24 @@ import '@fontsource/roboto/700.css'; import './css/reset.css'; import './css/app.css'; -import { MobileContext } from './components/infoview/context'; -import { useMobile } from './hooks'; -import { AUTO_SWITCH_THRESHOLD, getWindowDimensions} from './state/preferences'; +import { PreferencesContext} from './components/infoview/context'; +import UsePreferences from "./state/hooks/use_preferences" export const GameIdContext = React.createContext(undefined); function App() { - const { mobile, setMobile, lockMobile, setLockMobile } = useMobile(); const params = useParams() const gameId = "g/" + params.owner + "/" + params.repo - const automaticallyAdjustLayout = () => { - const {width} = getWindowDimensions() - setMobile(width < AUTO_SWITCH_THRESHOLD) - } - - React.useEffect(()=>{ - if (!lockMobile){ - void automaticallyAdjustLayout() - window.addEventListener('resize', automaticallyAdjustLayout) - - return () => { - window.removeEventListener('resize', automaticallyAdjustLayout) - } - } - }, [lockMobile]) + const {mobile, layout, isSavePreferences, setLayout, setIsSavePreferences} = UsePreferences() return (
- - - + + +
) diff --git a/client/src/components/app_bar.tsx b/client/src/components/app_bar.tsx index ced97d5..f8a171f 100644 --- a/client/src/components/app_bar.tsx +++ b/client/src/components/app_bar.tsx @@ -7,7 +7,7 @@ import { faDownload, faUpload, faEraser, faBook, faBookOpen, faGlobe, faHome, faArrowRight, faArrowLeft, faXmark, faBars, faCode, faCircleInfo, faTerminal, faMobileScreenButton, faDesktop, faGear } from '@fortawesome/free-solid-svg-icons' import { GameIdContext } from "../app" -import { InputModeContext, MobileContext, WorldLevelIdContext } from "./infoview/context" +import { InputModeContext, PreferencesContext, WorldLevelIdContext } from "./infoview/context" import { GameInfo, useGetGameInfoQuery } from '../state/api' import { changedOpenedIntro, selectCompleted, selectDifficulty, selectProgress } from '../state/progress' import { useAppDispatch, useAppSelector } from '../hooks' @@ -162,7 +162,7 @@ export function WelcomeAppBar({pageNumber, setPageNumber, gameInfo, toggleImpres }) { const gameId = React.useContext(GameIdContext) const gameProgress = useAppSelector(selectProgress(gameId)) - const {mobile, setMobile} = React.useContext(MobileContext) + const {mobile} = React.useContext(PreferencesContext) const [navOpen, setNavOpen] = React.useState(false) return
@@ -212,7 +212,7 @@ export function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber= }) { const gameId = React.useContext(GameIdContext) const {worldId, levelId} = React.useContext(WorldLevelIdContext) - const {mobile} = React.useContext(MobileContext) + const {mobile} = React.useContext(PreferencesContext) const [navOpen, setNavOpen] = React.useState(false) const gameInfo = useGetGameInfoQuery({game: gameId}) const completed = useAppSelector(selectCompleted(gameId, worldId, levelId)) diff --git a/client/src/components/infoview/context.ts b/client/src/components/infoview/context.ts index 8a62b36..e425749 100644 --- a/client/src/components/infoview/context.ts +++ b/client/src/components/infoview/context.ts @@ -5,6 +5,7 @@ import * as React from 'react'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' import { InteractiveDiagnostic, InteractiveTermGoal } from '@leanprover/infoview-api'; import { GameHint, InteractiveGoal, InteractiveGoals } from './rpc_api'; +import { PreferencesState } from '../../state/preferences'; export const MonacoEditorContext = React.createContext( null as any) @@ -62,18 +63,18 @@ export const ProofStateContext = React.createContext<{ setProofState: () => {}, }) -export interface IMobileContext { - mobile : boolean, - setMobile: React.Dispatch>, - lockMobile: boolean, - setLockMobile: React.Dispatch>, +export interface IPreferencesContext extends PreferencesState{ + mobile: boolean, // The variables that actually control the page 'layout' can only be changed through layout. + setLayout: React.Dispatch>; + setIsSavePreferences: React.Dispatch>; } -export const MobileContext = React.createContext({ +export const PreferencesContext = React.createContext({ mobile: false, - setMobile: () => {}, - lockMobile: false, - setLockMobile: () => {} + layout: "auto", + isSavePreferences: false, + setLayout: () => {}, + setIsSavePreferences: () => {} }) export const WorldLevelIdContext = React.createContext<{ diff --git a/client/src/components/infoview/main.tsx b/client/src/components/infoview/main.tsx index c90eba8..956d2f6 100644 --- a/client/src/components/infoview/main.tsx +++ b/client/src/components/infoview/main.tsx @@ -27,7 +27,7 @@ import Markdown from '../markdown'; import { Infos } from './infos'; import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages'; import { Goal } from './goals'; -import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './context'; +import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './context'; import { Typewriter, hasErrors, hasInteractiveErrors } from './typewriter'; import { InteractiveDiagnostic } from '@leanprover/infoview/*'; import { Button } from '../button'; @@ -349,7 +349,7 @@ export function TypewriterInterface({props}) { const [disableInput, setDisableInput] = React.useState(false) const [loadingProgress, setLoadingProgress] = React.useState(0) const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext) - const {mobile} = React.useContext(MobileContext) + const {mobile} = React.useContext(PreferencesContext) const { proof } = React.useContext(ProofContext) const { setTypewriterInput } = React.useContext(InputModeContext) const { selectedStep, setSelectedStep } = React.useContext(SelectionContext) diff --git a/client/src/components/level.tsx b/client/src/components/level.tsx index 4b5a53d..dbd62a5 100644 --- a/client/src/components/level.tsx +++ b/client/src/components/level.tsx @@ -27,7 +27,7 @@ import { Button } from './button' import Markdown from './markdown' import {InventoryPanel} from './inventory' import { hasInteractiveErrors } from './infoview/typewriter' -import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, +import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context' import { DualEditor } from './infoview/main' import { GameHint } from './infoview/rpc_api' @@ -74,7 +74,7 @@ function Level() { function ChatPanel({lastLevel}) { const chatRef = useRef(null) - const {mobile} = useContext(MobileContext) + const {mobile} = useContext(PreferencesContext) const gameId = useContext(GameIdContext) const {worldId, levelId} = useContext(WorldLevelIdContext) const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId}) @@ -215,7 +215,7 @@ function PlayableLevel({impressum, setImpressum}) { const codeviewRef = useRef(null) const gameId = React.useContext(GameIdContext) const {worldId, levelId} = useContext(WorldLevelIdContext) - const {mobile} = React.useContext(MobileContext) + const {mobile} = React.useContext(PreferencesContext) const dispatch = useAppDispatch() @@ -441,7 +441,7 @@ function PlayableLevel({impressum, setImpressum}) { function IntroductionPanel({gameInfo}) { const gameId = React.useContext(GameIdContext) const {worldId} = useContext(WorldLevelIdContext) - const {mobile} = React.useContext(MobileContext) + const {mobile} = React.useContext(PreferencesContext) let text: Array = gameInfo.data?.worlds.nodes[worldId].introduction.split(/\n(\s*\n)+/) @@ -468,7 +468,7 @@ export default Level /** The site with the introduction text of a world */ function Introduction({impressum, setImpressum}) { const gameId = React.useContext(GameIdContext) - const {mobile} = useContext(MobileContext) + const {mobile} = useContext(PreferencesContext) const inventory = useLoadInventoryOverviewQuery({game: gameId}) diff --git a/client/src/components/popup/preferences.tsx b/client/src/components/popup/preferences.tsx index 73b2dbe..aadc319 100644 --- a/client/src/components/popup/preferences.tsx +++ b/client/src/components/popup/preferences.tsx @@ -1,16 +1,42 @@ import * as React from 'react' import { Input, Typography } from '@mui/material' import Markdown from '../markdown' -import Switch from '@mui/material/Switch'; +import { Switch, Button, ButtonGroup } from '@mui/material'; +import Box from '@mui/material/Box'; +import Slider from '@mui/material/Slider'; + import FormControlLabel from '@mui/material/FormControlLabel'; -import { IMobileContext } from "../infoview/context" +import { IPreferencesContext } from "../infoview/context" -interface PreferencesPopupProps extends IMobileContext{ +interface PreferencesPopupProps extends Omit { handleClose: () => void -} +} + +export function PreferencesPopup({ layout, setLayout, isSavePreferences, setIsSavePreferences, handleClose }: PreferencesPopupProps) { + + const marks = [ + { + value: 0, + label: 'Mobile', + key: "mobile" + }, + { + value: 1, + label: 'Auto', + key: "auto" + }, + { + value: 2, + label: 'Desktop', + key: "desktop" + }, + ]; + + const handlerChangeLayout = (_: Event, value: number) => { + setLayout(marks[value].key as IPreferencesContext["layout"]) + } -export function PreferencesPopup({ mobile, setMobile, lockMobile, setLockMobile, handleClose }: PreferencesPopupProps) { return
@@ -18,34 +44,43 @@ export function PreferencesPopup({ mobile, setMobile, lockMobile, setLockMobile,
-

Mobile layout

+

Layout

-
+
setMobile(!mobile)} - name="checked" - color="primary" - /> + + item.key === layout).value} + step={1} + marks={marks} + max={2} + sx={{ + '& .MuiSlider-track': { display: 'none', }, + }} + onChange={handlerChangeLayout} + /> + } - label="Enable" - labelPlacement="start" + label="" />
+
+ +
setLockMobile(!lockMobile)} - name="checked" - color="primary" - /> + setIsSavePreferences(!isSavePreferences)} + name="checked" + color="primary" + /> } - label="Auto" - labelPlacement="start" + label="Save my settings (in the browser store)" + labelPlacement="end" />
diff --git a/client/src/components/welcome.tsx b/client/src/components/welcome.tsx index 89b9adc..5d4b3de 100644 --- a/client/src/components/welcome.tsx +++ b/client/src/components/welcome.tsx @@ -10,7 +10,7 @@ import { useAppDispatch, useAppSelector } from '../hooks' import { changedOpenedIntro, selectOpenedIntro } from '../state/progress' import { useGetGameInfoQuery, useLoadInventoryOverviewQuery } from '../state/api' import { Button } from './button' -import { MobileContext } from './infoview/context' +import { PreferencesContext } from './infoview/context' import { InventoryPanel } from './inventory' import { ErasePopup } from './popup/erase' import { InfoPopup } from './popup/game_info' @@ -27,7 +27,7 @@ import { Hint } from './hints' /** the panel showing the game's introduction text */ function IntroductionPanel({introduction, setPageNumber}: {introduction: string, setPageNumber}) { - const {mobile} = React.useContext(MobileContext) + const {mobile} = React.useContext(PreferencesContext) const gameId = React.useContext(GameIdContext) const dispatch = useAppDispatch() @@ -64,7 +64,9 @@ function IntroductionPanel({introduction, setPageNumber}: {introduction: string, /** main page of the game showing among others the tree of worlds/levels */ function Welcome() { const gameId = React.useContext(GameIdContext) - const {mobile, setMobile, lockMobile, setLockMobile} = React.useContext(MobileContext) + const {mobile} = React.useContext(PreferencesContext) + const {layout, isSavePreferences, setLayout, setIsSavePreferences} = React.useContext(PreferencesContext) + const gameInfo = useGetGameInfoQuery({game: gameId}) const inventory = useLoadInventoryOverviewQuery({game: gameId}) @@ -134,7 +136,7 @@ function Welcome() { {eraseMenu? : null} {uploadMenu? : null} {info ? : null} - {preferencesPopup ? : null} + {preferencesPopup ? : null} } diff --git a/client/src/components/world_tree.tsx b/client/src/components/world_tree.tsx index 1aa495b..65fcc8a 100644 --- a/client/src/components/world_tree.tsx +++ b/client/src/components/world_tree.tsx @@ -11,11 +11,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faXmark, faCircleQuestion } from '@fortawesome/free-solid-svg-icons' import { GameIdContext } from '../app' -import { useAppDispatch, useMobile } from '../hooks' +import { useAppDispatch } from '../hooks' import { selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress' import { store } from '../state/store' import '../css/world_tree.css' +import { PreferencesContext } from './infoview/context' // Settings for the world tree cytoscape.use( klay ) @@ -197,7 +198,7 @@ export function WorldSelectionMenu({rulesHelp, setRulesHelp}) { const gameId = React.useContext(GameIdContext) const difficulty = useSelector(selectDifficulty(gameId)) const dispatch = useAppDispatch() - const { mobile } = useMobile() + const { mobile } = React.useContext(PreferencesContext) function label(x : number) { diff --git a/client/src/css/welcome.css b/client/src/css/welcome.css index 94e17e3..e48f7d0 100644 --- a/client/src/css/welcome.css +++ b/client/src/css/welcome.css @@ -187,3 +187,15 @@ h5, h6 { margin-left: 0.3rem; margin-right: 0.3rem; } + +.preferences-category.tail-category{ + margin-top: 2em; +} + +.preferences-item.first{ + margin-top: 1em; +} + +.preferences-item.leave-left-gap{ + margin-left: 3em; +} \ No newline at end of file diff --git a/client/src/hooks.ts b/client/src/hooks.ts index 0ac6e2f..afdce0a 100644 --- a/client/src/hooks.ts +++ b/client/src/hooks.ts @@ -1,30 +1,6 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import type { RootState, AppDispatch } from './state/store' -import { setMobile as setMobileState, setLockMobile as setLockMobileState} from "./state/preferences" - // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch export const useAppSelector: TypedUseSelectorHook = useSelector - -export const useMobile = () => { - const dispatch = useAppDispatch(); - - const mobile = useAppSelector((state) => state.preferences.mobile); - const lockMobile = useAppSelector((state) => state.preferences.lockMobile); - - const setMobile = (val: boolean) => { - dispatch(setMobileState(val)); - }; - - const setLockMobile = (val: boolean) => { - dispatch(setLockMobileState(val)); - }; - - return { - mobile, - setMobile, - lockMobile, - setLockMobile, - }; -}; diff --git a/client/src/state/hooks/use_preferences.ts b/client/src/state/hooks/use_preferences.ts new file mode 100644 index 0000000..3b4f66c --- /dev/null +++ b/client/src/state/hooks/use_preferences.ts @@ -0,0 +1,41 @@ +import React, { useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../hooks"; +import { + PreferencesState, + setLayout as setPreferencesLayout, + setIsSavePreferences as setPreferencesIsSavePreferences, + getWindowDimensions, + AUTO_SWITCH_THRESHOLD +} from "../preferences"; + + +const UsePreferences = () => { + const dispatch = useAppDispatch() + const [mobile, setMobile] = React.useState() + + const layout = useAppSelector((state) => state.preferences.layout); + const setLayout = (layout: PreferencesState["layout"]) => dispatch(setPreferencesLayout(layout)) + + const isSavePreferences = useAppSelector((state) => state.preferences.isSavePreferences); + const setIsSavePreferences = (isSave: boolean) => dispatch(setPreferencesIsSavePreferences(isSave)) + + const automaticallyAdjustLayout = () => { + const {width} = getWindowDimensions() + setMobile(width < AUTO_SWITCH_THRESHOLD) + } + + React.useEffect(()=>{ + if (layout === "auto"){ + void automaticallyAdjustLayout() + window.addEventListener('resize', automaticallyAdjustLayout) + + return () => window.removeEventListener('resize', automaticallyAdjustLayout) + } else { + setMobile(layout === "mobile") + } + }, [layout]) + + return {mobile, layout, isSavePreferences, setLayout, setIsSavePreferences} +} + +export default UsePreferences; \ No newline at end of file diff --git a/client/src/state/local_storage.ts b/client/src/state/local_storage.ts index ea165ce..098768e 100644 --- a/client/src/state/local_storage.ts +++ b/client/src/state/local_storage.ts @@ -57,3 +57,12 @@ export function savePreferences(state: any) { // Ignore } } + +export function removePreferences() { + try { + localStorage.removeItem(PREFERENCES_KEY); + } catch (e) { + // Ignore + } +} + diff --git a/client/src/state/preferences.ts b/client/src/state/preferences.ts index 6ee0fa1..7ccbe30 100644 --- a/client/src/state/preferences.ts +++ b/client/src/state/preferences.ts @@ -1,10 +1,10 @@ import { createSlice } from "@reduxjs/toolkit"; -import { loadPreferences } from "./local_storage"; +import { loadPreferences, removePreferences, savePreferences } from "./local_storage"; -interface PreferencesState { - mobile: boolean; - lockMobile: boolean; +export interface PreferencesState { + layout: "mobile" | "auto" | "desktop"; + isSavePreferences: boolean; } export function getWindowDimensions() { @@ -12,26 +12,24 @@ export function getWindowDimensions() { return {width, height} } -const { width } = getWindowDimensions() - export const AUTO_SWITCH_THRESHOLD = 800 -const initialState: PreferencesState = loadPreferences() ?? { - mobile: width < AUTO_SWITCH_THRESHOLD, - lockMobile: false +const initialState: PreferencesState = loadPreferences() ??{ + layout: "auto", + isSavePreferences: false } export const preferencesSlice = createSlice({ name: "preferences", initialState, reducers: { - setMobile: (state, action) => { - state.mobile = action.payload; + setLayout: (state, action) => { + state.layout = action.payload; }, - setLockMobile: (state, action) => { - state.lockMobile = action.payload; + setIsSavePreferences: (state, action) => { + state.isSavePreferences = action.payload; }, }, }); -export const { setMobile, setLockMobile } = preferencesSlice.actions; +export const { setLayout, setIsSavePreferences } = preferencesSlice.actions; diff --git a/client/src/state/store.ts b/client/src/state/store.ts index 4406c84..e73818e 100644 --- a/client/src/state/store.ts +++ b/client/src/state/store.ts @@ -8,7 +8,7 @@ import { connection } from '../connection' import { apiSlice } from './api' import { progressSlice } from './progress' import { preferencesSlice } from "./preferences" -import { saveState, savePreferences } from "./local_storage"; +import { saveState, savePreferences, removePreferences} from "./local_storage"; export const store = configureStore({ @@ -29,7 +29,9 @@ export const store = configureStore({ store.subscribe( debounce(() => { saveState(store.getState()[progressSlice.name]); - savePreferences(store.getState()[preferencesSlice.name]); + + const preferencesState = store.getState()[preferencesSlice.name] + preferencesState.isSavePreferences ? savePreferences(preferencesState) : removePreferences() }, 800) );