Skip to content

Commit 8e4e22e

Browse files
default to hardcore setting
1 parent 477517c commit 8e4e22e

6 files changed

Lines changed: 136 additions & 31 deletions

File tree

src/app/layout.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import "./globals.css";
44

55
import {ViewTransition} from "react";
66
import {RangleScoresProvider} from "@/context/RangleScoresContext";
7+
import {SettingsProvider} from "@/context/SettingsContext";
78

89
// used specifically for Rangle title
910
const title = Encode_Sans({
@@ -82,9 +83,11 @@ export default function RootLayout({
8283
<body className="min-h-full flex flex-col">
8384
<noscript className="absolute top-1/4 left-1/2 -translate-x-1/2">Please enable JavaScript to play Rangle!</noscript>
8485
<ViewTransition name="zoom-and-fade">
85-
<RangleScoresProvider>
86-
{children}
87-
</RangleScoresProvider>
86+
<SettingsProvider>
87+
<RangleScoresProvider>
88+
{children}
89+
</RangleScoresProvider>
90+
</SettingsProvider>
8891
</ViewTransition>
8992
</body>
9093
</html>

src/components/HardcoreToggle.tsx

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"use client";
22

3+
import {ToggleSwitch} from "@/components/ToggleSwitch";
4+
35
import {Lock} from "lucide-react";
46

57
interface HardcoreToggleProps {
@@ -9,27 +11,10 @@ interface HardcoreToggleProps {
911
className?: string;
1012
}
1113

12-
export const HardcoreToggle = ({ attempt_count, hardcore, on_toggle, className = "" }: HardcoreToggleProps) => {
13-
const handle_toggle = (e: React.ChangeEvent<HTMLInputElement>) => {
14-
on_toggle(e.target.checked);
15-
};
16-
17-
return (
18-
<label className={`flex items-center space-x-2 ${className}`}>
19-
<input
20-
type="checkbox"
21-
className="h-5 w-5 sr-only peer"
22-
disabled={attempt_count > 0}
23-
onChange={handle_toggle}
24-
checked={hardcore}
25-
title="Toggle hardcore mode (locked after first attempt)"
26-
/>
27-
<div aria-hidden="true" title="Toggle hardcore mode (locked after first attempt)" className="cursor-pointer peer-disabled:cursor-not-allowed relative w-9 h-5 bg-gray-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-buffer after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-75" />
28-
<span className={`text-sm transition-colors duration-300 ${attempt_count > 0 ? "text-gray-400 dark:text-gray-500" : "text-gray-700 dark:text-gray-300"} flex items-center gap-2`}>
29-
Hardcore Mode
14+
export const HardcoreToggle = ({ attempt_count, hardcore, on_toggle, className = "" }: HardcoreToggleProps) => (
15+
<ToggleSwitch title="Toggle hardcore mode (locked after first attempt)" value={hardcore} on_toggle={on_toggle} disabled={attempt_count > 0} className={className}>
16+
Hardcore Mode
3017

31-
<Lock className={`h-4 w-4 transition-opacity duration-300 ${attempt_count > 0 ? "opacity-100" : "opacity-0"}`} />
32-
</span>
33-
</label>
34-
);
35-
}
18+
<Lock className={`h-4 w-4 transition-opacity duration-300 ${attempt_count > 0 ? "opacity-100" : "opacity-0"}`} />
19+
</ToggleSwitch>
20+
);

src/components/SettingsPopup.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"use client";
22

33
import {useEffect, useRef} from "react";
4+
import {ToggleSwitch} from "@/components/ToggleSwitch";
5+
6+
import {useSettings} from "@/context/SettingsContext";
47

58
interface SettingsPopupProps {
69
open: boolean;
@@ -10,6 +13,8 @@ interface SettingsPopupProps {
1013
export const SettingsPopup = ({open, on_close}: SettingsPopupProps) => {
1114
const dialog_ref = useRef<HTMLDialogElement>(null);
1215

16+
const { settings, update_settings } = useSettings();
17+
1318
// sync with open prop
1419
useEffect(() => {
1520
if (open) {
@@ -23,10 +28,15 @@ export const SettingsPopup = ({open, on_close}: SettingsPopupProps) => {
2328
return (
2429
<dialog onAbort={on_close} ref={dialog_ref} className="rounded-lg p-4 fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-w-[95vw] sm:max-w-md w-full bg-background-variant text-foreground-variant flex flex-col items-center">
2530
<h2 className="text-xl font-bold mb-2">Settings</h2>
26-
<p className="mb-4 opacity-60">Not yet implemented :P</p>
31+
32+
<div className="flex flex-col items-start justify-center gap-4 my-4">
33+
<ToggleSwitch value={settings.default_hardcore} on_toggle={(new_value) => update_settings({ default_hardcore: new_value })}>
34+
Default to Hardcore Mode
35+
</ToggleSwitch>
36+
</div>
2737

2838
<button
29-
className="px-4 py-2 bg-red-500 text-white rounded cursor-pointer"
39+
className="mt-2 px-4 py-2 bg-red-500 text-white rounded cursor-pointer"
3040
onClick={on_close}
3141
>
3242
Close

src/components/ToggleSwitch.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"use client";
2+
3+
interface ToggleSwitchProps {
4+
value: boolean;
5+
on_toggle: (new_value: boolean) => void;
6+
children?: React.ReactNode;
7+
title?: string;
8+
disabled?: boolean;
9+
className?: string;
10+
}
11+
12+
export const ToggleSwitch = ({ value, on_toggle, children, title, disabled = false, className = "" }: ToggleSwitchProps) => {
13+
const handle_toggle = (e: React.ChangeEvent<HTMLInputElement>) => {
14+
on_toggle(e.target.checked);
15+
};
16+
17+
return (
18+
<label className={`flex items-center space-x-2 ${className}`}>
19+
<input
20+
type="checkbox"
21+
className="h-5 w-5 sr-only peer"
22+
disabled={disabled}
23+
onChange={handle_toggle}
24+
checked={value}
25+
title={title}
26+
/>
27+
<div aria-hidden="true" title={title} className="cursor-pointer peer-disabled:cursor-not-allowed relative w-9 h-5 bg-gray-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-buffer after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-75" />
28+
<span className="text-sm transition-colors duration-300 text-gray-700 dark:text-gray-300 peer-disabled:text-gray-400 peer-disabled:dark:text-gray-500 flex items-center gap-2">
29+
{children}
30+
</span>
31+
</label>
32+
);
33+
}

src/context/SettingsContext.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client";
2+
3+
import {createContext, useContext, useEffect, useState} from "react";
4+
5+
const DEFAULT_SETTINGS = {
6+
default_hardcore: false as boolean,
7+
theme: "default" as string,
8+
} as const;
9+
10+
export type Settings = typeof DEFAULT_SETTINGS;
11+
12+
interface SettingsContextType {
13+
settings: Settings;
14+
update_settings: (new_settings: Partial<Settings>) => void;
15+
}
16+
17+
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
18+
19+
export const SettingsProvider = ({ children }: { children: React.ReactNode }) => {
20+
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
21+
22+
// load settings from localStorage on initial mount
23+
useEffect(() => {
24+
const settings_raw = localStorage.getItem("rangle_settings_v1");
25+
if (settings_raw) {
26+
try {
27+
const parsed_settings = JSON.parse(settings_raw);
28+
setSettings((prev) => ({ ...prev, ...parsed_settings }));
29+
} catch (err) {
30+
console.error("Error parsing settings from localStorage:", err);
31+
}
32+
}
33+
}, []);
34+
35+
const update_settings = (new_settings: Partial<Settings>) => {
36+
setSettings((prev) => ({ ...prev, ...new_settings }));
37+
38+
const settings_raw = localStorage.getItem("rangle_settings_v1");
39+
const settings_to_save = settings_raw ? JSON.parse(settings_raw) : DEFAULT_SETTINGS;
40+
const updated_settings = { ...settings_to_save, ...new_settings };
41+
localStorage.setItem("rangle_settings_v1", JSON.stringify(updated_settings));
42+
};
43+
44+
return (
45+
<SettingsContext.Provider value={{ settings, update_settings }}>
46+
{children}
47+
</SettingsContext.Provider>
48+
);
49+
};
50+
51+
export const useSettings = () => {
52+
const context = useContext(SettingsContext);
53+
if (!context) {
54+
throw new Error("useSettings must be used within a SettingsProvider");
55+
}
56+
return context;
57+
};
58+
59+
export const useSettingValue = <K extends keyof Settings>(key: K) => {
60+
const { settings, update_settings } = useSettings();
61+
return [
62+
settings[key],
63+
(new_value: Settings[K]) => update_settings({ [key]: new_value }),
64+
] as const;
65+
};

src/hooks/useRangleState.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {time_zone} from "../../time";
33
import {PuzzleStat, StatPositionFlags, TodayData} from "@/components/Game";
44
import {useRangleScores} from "@/context/RangleScoresContext";
55
import {useCallback, useEffect, useMemo, useState} from "react";
6+
import {useSettingValue} from "@/context/SettingsContext";
67

78
interface RangleStateHookProps {
89
on_loaded?: () => void;
@@ -29,10 +30,18 @@ export const useRangleState = ({ on_loaded, on_load_error, date_override }: Rang
2930
const [finished, setFinished] = useState(false);
3031
const [finished_correctly, setFinishedCorrectly] = useState(false);
3132

32-
const [hardcore, setHardcore] = useState(false);
33+
const [default_hardcore] = useSettingValue("default_hardcore");
34+
const [hardcore, setHardcore] = useState(default_hardcore);
3335

3436
const {update_score} = useRangleScores();
3537

38+
// sync hardcore setting with default from settings context if no attempts have been made yet
39+
useEffect(() => {
40+
if (attempts.length === 0) {
41+
setHardcore(default_hardcore);
42+
}
43+
}, [default_hardcore, attempts.length]);
44+
3645
// fetch today's data on load, as well as local save state if today's exists
3746
useEffect(() => {
3847
const today_date_iso = date_override
@@ -65,9 +74,9 @@ export const useRangleState = ({ on_loaded, on_load_error, date_override }: Rang
6574
id_to_stat[stat.id] = stat;
6675
});
6776

68-
if (today_save.hardcore) {
77+
if (today_save.hardcore === true) {
6978
setHardcore(true);
70-
} else {
79+
} else if (today_save.hardcore === false) {
7180
setHardcore(false);
7281
}
7382

0 commit comments

Comments
 (0)