feat: add AI settings controls

This commit is contained in:
2026-04-10 15:40:29 -04:00
parent e01a7ed1ad
commit 12849b2362
16 changed files with 907 additions and 127 deletions

15
src/lib/ai-create-flow.ts Normal file
View File

@@ -0,0 +1,15 @@
export type AiCreateOutcome =
| "review-single"
| "create-single"
| "create-multiple";
export const getAiCreateOutcome = (
eventCount: number,
skipAiReview: boolean,
): AiCreateOutcome => {
if (eventCount > 1) {
return "create-multiple";
}
return skipAiReview ? "create-single" : "review-single";
};

View File

@@ -0,0 +1,32 @@
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
const DISABLED_VALUES = new Set(["0", "false", "no", "off"]);
const normalizeFlagValue = (value?: string) => value?.trim().toLowerCase();
export const isAiFlagEnabled = (value?: string): boolean => {
const normalizedValue = normalizeFlagValue(value);
if (!normalizedValue) {
return true;
}
if (ENABLED_VALUES.has(normalizedValue)) {
return true;
}
if (DISABLED_VALUES.has(normalizedValue)) {
return false;
}
return true;
};
export const isAdminAiEnabled = (
env: Record<string, string | undefined> = process.env,
) => isAiFlagEnabled(env.AI_ENABLED);
export const isClientAiEnabled = (
env: Record<string, string | undefined> = process.env,
) => isAiFlagEnabled(env.NEXT_PUBLIC_AI_ENABLED ?? env.AI_ENABLED);
export const getAiDisabledMessage = () =>
"AI integrations are currently disabled by the administrator.";

View File

@@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
export const APP_HEADER_SURFACE_CLASSES =
"glass-surface mb-4 flex items-center justify-between gap-3 px-4 py-3";
export const APP_SECTION_SURFACE_CLASSES = "glass-panel p-4 sm:p-5";
export const APP_ACTION_BAR_CLASSES = "glass-subtle mb-4 p-3";
export const APP_NAV_SURFACE_CLASSES =
"glass-surface fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between px-3 py-2 sm:inset-x-6 lg:inset-x-8";
const CONNECTION_BADGE_BASE_CLASSES =
"gap-1.5 border px-2.5 py-1 text-xs font-medium shadow-none";
export const getConnectionBadgeClasses = (isOnline: boolean) =>
cn(
CONNECTION_BADGE_BASE_CLASSES,
isOnline
? "border-emerald-500/35 bg-emerald-500/15 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-500/15 dark:text-emerald-300 [&>svg]:text-emerald-500"
: "border-border/70 bg-muted/55 text-muted-foreground dark:bg-muted/35 [&>svg]:text-muted-foreground",
);

118
src/lib/user-settings.ts Normal file
View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
export interface UserSettings {
aiEnabled: boolean;
skipAiReview: boolean;
}
export const USER_SETTINGS_STORAGE_KEY = "localcal:user-settings";
const DEFAULT_USER_SETTINGS: UserSettings = {
aiEnabled: true,
skipAiReview: false,
};
export const getDefaultUserSettings = (): UserSettings => ({
...DEFAULT_USER_SETTINGS,
});
type UserSettingsStorage = Pick<Storage, "getItem" | "setItem">;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const parseUserSettings = (value: unknown): UserSettings => {
if (!isRecord(value)) {
return getDefaultUserSettings();
}
return {
aiEnabled:
typeof value.aiEnabled === "boolean"
? value.aiEnabled
: DEFAULT_USER_SETTINGS.aiEnabled,
skipAiReview:
typeof value.skipAiReview === "boolean"
? value.skipAiReview
: DEFAULT_USER_SETTINGS.skipAiReview,
};
};
export const loadUserSettings = (
storage?: UserSettingsStorage,
): UserSettings => {
if (!storage) {
return getDefaultUserSettings();
}
try {
const storedValue = storage.getItem(USER_SETTINGS_STORAGE_KEY);
if (!storedValue) {
return getDefaultUserSettings();
}
return parseUserSettings(JSON.parse(storedValue));
} catch {
return getDefaultUserSettings();
}
};
export const saveUserSettings = (
settings: UserSettings,
storage?: UserSettingsStorage,
): UserSettings => {
if (!storage) {
return settings;
}
try {
storage.setItem(USER_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
} catch {
return settings;
}
return settings;
};
const getBrowserStorage = (): UserSettingsStorage | undefined => {
if (typeof window === "undefined") {
return undefined;
}
try {
return window.localStorage;
} catch {
return undefined;
}
};
export const useUserSettings = () => {
const [settings, setSettings] = useState<UserSettings>(
getDefaultUserSettings,
);
const [hasLoadedSettings, setHasLoadedSettings] = useState(false);
useEffect(() => {
setSettings(loadUserSettings(getBrowserStorage()));
setHasLoadedSettings(true);
}, []);
const updateSettings = (changes: Partial<UserSettings>) => {
setSettings((currentSettings) => {
const nextSettings = {
...currentSettings,
...changes,
};
saveUserSettings(nextSettings, getBrowserStorage());
return nextSettings;
});
};
return {
hasLoadedSettings,
settings,
updateSettings,
};
};