mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 01:12:40 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fad7392d |
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user