Compare commits

...
Author SHA1 Message Date
Tanish Manku 42fad7392d artcraft: hidden experimental settings menu via 7x version tap 2026-04-17 14:15:28 -07:00
5 changed files with 228 additions and 45 deletions
@@ -0,0 +1,44 @@
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
interface Props {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
text: string;
confirmText?: string;
cancelText?: string;
confirmVariant?: "primary" | "secondary" | "destructive" | "ghost" | "action";
}
export const ExperimentalConfirmModal = ({
isOpen,
onClose,
onConfirm,
title,
text,
confirmText = "Confirm",
cancelText = "Cancel",
confirmVariant = "destructive",
}: Props) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
className="max-w-md"
showClose={false}
>
<div className="text-sm text-base-fg">{text}</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
{cancelText}
</Button>
<Button variant={confirmVariant} onClick={onConfirm}>
{confirmText}
</Button>
</div>
</Modal>
);
};
@@ -0,0 +1,43 @@
import { create } from "zustand";
const STORAGE_KEY = "artcraft_experimental_enabled";
const readInitial = (): boolean => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(STORAGE_KEY) === "true";
} catch {
return false;
}
};
const writeStorage = (enabled: boolean) => {
if (typeof window === "undefined") return;
try {
if (enabled) {
window.localStorage.setItem(STORAGE_KEY, "true");
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
} catch {
// ignore storage failures
}
};
interface ExperimentalState {
enabled: boolean;
enable: () => void;
disable: () => void;
}
export const useExperimentalStore = create<ExperimentalState>((set) => ({
enabled: readInitial(),
enable: () => {
writeStorage(true);
set({ enabled: true });
},
disable: () => {
writeStorage(false);
set({ enabled: false });
},
}));
@@ -1,9 +1,15 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
GetAppInfo,
GetAppInfoPayload,
} from "@storyteller/tauri-api";
import { Label } from "@storyteller/ui-label";
import { toast } from "react-hot-toast";
import { gtagEvent } from "@storyteller/google-analytics";
import { useExperimentalStore } from "../experimental-store";
const UNLOCK_CLICK_COUNT = 7;
const UNLOCK_WINDOW_MS = 5000;
interface AboutSettingsPaneProps {}
@@ -12,6 +18,10 @@ export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
GetAppInfoPayload | undefined
>(undefined);
const enabled = useExperimentalStore((s) => s.enabled);
const enable = useExperimentalStore((s) => s.enable);
const clickTimes = useRef<number[]>([]);
useEffect(() => {
const fetchData = async () => {
const prefs = await GetAppInfo();
@@ -20,6 +30,19 @@ export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
fetchData();
}, []);
const handleVersionClick = () => {
if (enabled) return;
const now = performance.now();
const recent = clickTimes.current.filter((t) => now - t < UNLOCK_WINDOW_MS);
recent.push(now);
clickTimes.current = recent;
if (recent.length >= UNLOCK_CLICK_COUNT) {
clickTimes.current = [];
enable();
gtagEvent("unlock_experimental_menu", {});
toast.success("Experimental features unlocked");
}
};
return (
<>
@@ -28,7 +51,12 @@ export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
<Label>
Artcraft Version
</Label>
<div>{appInfo?.artcraft_version}</div>
<div
onClick={handleVersionClick}
style={{ cursor: "default", userSelect: "none" }}
>
{appInfo?.artcraft_version}
</div>
</div>
<div className="space-y-1">
@@ -0,0 +1,15 @@
import { Label } from "@storyteller/ui-label";
export const ExperimentalSettingsPane = () => {
return (
<div className="space-y-4 text-base-fg">
<div className="space-y-1">
<Label>Experimental Features</Label>
<p className="opacity-80">
This section is reserved for in-development features. There are no
experimental options available right now.
</p>
</div>
</div>
);
};
@@ -8,6 +8,7 @@ import {
faCircleInfo,
faCreditCard,
faPalette,
faFlask,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { MiscSettingsPane } from "./panes/MiscSettingsPane";
@@ -15,9 +16,13 @@ import { AudioSettingsPane } from "./panes/AudioSettingsPane";
import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane";
import { AboutSettingsPane } from "./panes/AboutSettingsPane";
import { ProviderPrioritySettingsPane } from "./panes/ProviderPrioritySettingsPane";
import { ExperimentalSettingsPane } from "./panes/ExperimentalSettingsPane";
import { gtagEvent } from "@storyteller/google-analytics";
import { BillingSettingsPane } from "./panes/BillingSettingsPane";
import { AppearanceSettingsPane } from "./panes/AppearanceSettingsPane";
import { Button } from "@storyteller/ui-button";
import { useExperimentalStore } from "./experimental-store";
import { ExperimentalConfirmModal } from "./ExperimentalConfirmModal";
interface SettingsModalProps {
isOpen: boolean;
@@ -33,7 +38,8 @@ type SettingsSection =
| "alerts"
| "about"
| "provider_priority"
| "billing";
| "billing"
| "experimental";
export const SettingsModal = ({
isOpen,
@@ -44,6 +50,10 @@ export const SettingsModal = ({
const [selectedSection, setSelectedSection] =
useState<SettingsSection>(initialSection);
const experimentalEnabled = useExperimentalStore((s) => s.enabled);
const disableExperimental = useExperimentalStore((s) => s.disable);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
// Sync the selected section with incoming prop when modal opens or prop changes
useEffect(() => {
if (isOpen) {
@@ -51,6 +61,13 @@ export const SettingsModal = ({
}
}, [isOpen, initialSection]);
// If experimental gets disabled while user is on that pane, fall back to General
useEffect(() => {
if (!experimentalEnabled && selectedSection === "experimental") {
setSelectedSection("general");
}
}, [experimentalEnabled, selectedSection]);
const sections = [
{ id: "general" as const, label: "General", icon: faCog },
@@ -67,6 +84,9 @@ export const SettingsModal = ({
{ id: "about" as const, label: "About", icon: faCircleInfo },
//{ id: "video" as const, label: "Video", icon: faVideo },
//{ id: "image" as const, label: "Image", icon: faImage },
...(experimentalEnabled
? [{ id: "experimental" as const, label: "Experimental", icon: faFlask }]
: []),
];
const renderContent = () => {
@@ -89,57 +109,90 @@ export const SettingsModal = ({
return <ProviderPrioritySettingsPane />;
case "billing":
return <BillingSettingsPane />;
case "experimental":
return <ExperimentalSettingsPane />;
}
};
const handleConfirmReset = () => {
disableExperimental();
gtagEvent("reset_experimental_menu", {});
setIsResetConfirmOpen(false);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-3xl"
childPadding={false}
>
<div className="h-[560px]">
<div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-ui-panel-border">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">Settings</h2>
<>
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-3xl"
childPadding={false}
>
<div className="h-[560px]">
<div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-ui-panel-border">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">Settings</h2>
</div>
<hr className="my-2 w-full border-ui-panel-border" />
<div className="space-y-1">
{sections.map((section) => (
<button
key={section.id}
className={twMerge(
"h-9 w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/30",
section.id === selectedSection ? "bg-[#63636B]/20" : ""
)}
onClick={() => {
gtagEvent("switch_settings_section", {
section: section.id,
});
setSelectedSection(section.id);
}}
>
<div className="flex items-center gap-2.5 text-sm">
<FontAwesomeIcon icon={section.icon} />
{section.label}
</div>
</button>
))}
</div>
</div>
<hr className="my-2 w-full border-ui-panel-border" />
<div className="space-y-1">
{sections.map((section) => (
<button
key={section.id}
className={twMerge(
"h-9 w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/30",
section.id === selectedSection ? "bg-[#63636B]/20" : ""
)}
onClick={() => {
gtagEvent("switch_settings_section", {
section: section.id,
});
setSelectedSection(section.id);
}}
>
<div className="flex items-center gap-2.5 text-sm">
<FontAwesomeIcon icon={section.icon} />
{section.label}
<div className="col-span-8 flex h-full flex-col overflow-y-auto relative">
<div className="w-full border-b border-ui-panel-border py-2.5 ps-0">
<h2 className="text-[18px] font-semibold">
{sections.find((s) => s.id === selectedSection)?.label}
</h2>
</div>
{experimentalEnabled && selectedSection === "experimental" && (
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-ui-panel-border bg-ui-modal/95 backdrop-blur px-3 py-2">
<div className="flex items-center gap-2 text-xs opacity-80">
<FontAwesomeIcon icon={faFlask} />
Experimental features enabled
</div>
</button>
))}
<Button
variant="destructive"
onClick={() => setIsResetConfirmOpen(true)}
className="px-2 py-1 text-xs"
>
Reset
</Button>
</div>
)}
<div className="p-3 ps-0 text-sm h-full">{renderContent()}</div>
</div>
</div>
<div className="col-span-8 flex h-full flex-col">
<div className="w-full border-b border-ui-panel-border py-2.5 ps-0">
<h2 className="text-[18px] font-semibold">
{sections.find((s) => s.id === selectedSection)?.label}
</h2>
</div>
<div className="p-3 ps-0 text-sm h-full">{renderContent()}</div>
</div>
</div>
</div>
</Modal>
</Modal>
<ExperimentalConfirmModal
isOpen={isResetConfirmOpen}
onClose={() => setIsResetConfirmOpen(false)}
onConfirm={handleConfirmReset}
title="Reset experimental settings?"
text="This will hide the Experimental section and clear any experimental settings. You can unlock it again from the About page."
confirmText="Reset"
/>
</>
);
};