974 lines
32 KiB
JavaScript
974 lines
32 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const SETTINGS_STORAGE_KEY = "tarot-time-settings-v1";
|
|
const SETTINGS_LAST_SAVED_AT_STORAGE_KEY = "tarot-time-settings-last-saved-at-v1";
|
|
|
|
let config = {
|
|
defaultSettings: {
|
|
latitude: 51.5074,
|
|
longitude: -0.1278,
|
|
timeFormat: "minutes",
|
|
birthDate: "",
|
|
tarotDeck: "ceremonial-magick",
|
|
stellariumBackgroundEnabled: false,
|
|
detailTextScale: 1,
|
|
hasExplicitLocation: false
|
|
},
|
|
onSettingsApplied: null,
|
|
onSyncSkyBackground: null,
|
|
onStatus: null,
|
|
onConnectionSaved: null,
|
|
onReopenActiveSection: null,
|
|
setActiveSection: null,
|
|
getActiveSection: null,
|
|
onRenderWeek: null
|
|
};
|
|
|
|
let lastConnectionProbeResult = null;
|
|
|
|
let lastNonSettingsSection = "home";
|
|
|
|
function getElements() {
|
|
return {
|
|
openSettingsEl: document.getElementById("open-settings"),
|
|
closeSettingsEl: document.getElementById("close-settings"),
|
|
latEl: document.getElementById("lat"),
|
|
lngEl: document.getElementById("lng"),
|
|
timeFormatEl: document.getElementById("time-format"),
|
|
birthDateEl: document.getElementById("birth-date"),
|
|
tarotDeckEl: document.getElementById("tarot-deck"),
|
|
tarotDeckCacheProgressEl: document.getElementById("tarot-deck-cache-progress"),
|
|
tarotDeckCacheProgressLabelEl: document.getElementById("tarot-deck-cache-progress-label"),
|
|
tarotDeckCacheStatusEl: document.getElementById("tarot-deck-cache-status"),
|
|
detailTextScaleEl: document.getElementById("detail-text-scale"),
|
|
detailTextScaleValueEl: document.getElementById("detail-text-scale-value"),
|
|
stellariumBackgroundEl: document.getElementById("stellarium-background"),
|
|
stellariumBackgroundHintEl: document.getElementById("stellarium-background-hint"),
|
|
apiBaseUrlEl: document.getElementById("api-base-url"),
|
|
apiKeyEl: document.getElementById("api-key"),
|
|
apiConnectionSummaryEl: document.getElementById("api-connection-summary"),
|
|
apiConnectionSummaryStateEl: document.getElementById("api-connection-summary-state"),
|
|
apiConnectionSummaryClientEl: document.getElementById("api-connection-summary-client"),
|
|
apiConnectionSummaryAccessEl: document.getElementById("api-connection-summary-access"),
|
|
apiConnectionSummaryPermissionsEl: document.getElementById("api-connection-summary-permissions"),
|
|
settingsPageStatusEl: document.getElementById("settings-page-status"),
|
|
settingsPageStatusTextEl: document.getElementById("settings-page-status-text"),
|
|
settingsPageStatusTimeEl: document.getElementById("settings-page-status-time"),
|
|
saveSettingsEl: document.getElementById("save-settings"),
|
|
useLocationEl: document.getElementById("use-location")
|
|
};
|
|
}
|
|
|
|
function loadLastSavedAt() {
|
|
try {
|
|
const raw = window.localStorage.getItem(SETTINGS_LAST_SAVED_AT_STORAGE_KEY);
|
|
const parsed = Number(raw);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function persistLastSavedAt(timestamp) {
|
|
try {
|
|
window.localStorage.setItem(SETTINGS_LAST_SAVED_AT_STORAGE_KEY, String(timestamp));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function formatLastSavedAt(timestamp) {
|
|
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
|
return "";
|
|
}
|
|
|
|
try {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short"
|
|
}).format(new Date(timestamp));
|
|
} catch {
|
|
return new Date(timestamp).toLocaleString();
|
|
}
|
|
}
|
|
|
|
function setSettingsPageStatus(message, tone = "neutral", options = {}) {
|
|
const {
|
|
settingsPageStatusEl,
|
|
settingsPageStatusTextEl,
|
|
settingsPageStatusTimeEl
|
|
} = getElements();
|
|
|
|
if (settingsPageStatusEl) {
|
|
settingsPageStatusEl.dataset.tone = String(tone || "neutral");
|
|
}
|
|
|
|
if (settingsPageStatusTextEl) {
|
|
settingsPageStatusTextEl.textContent = String(message || "Settings ready.");
|
|
}
|
|
|
|
const savedAt = options.savedAt === undefined ? loadLastSavedAt() : options.savedAt;
|
|
const formattedSavedAt = formatLastSavedAt(savedAt);
|
|
if (settingsPageStatusTimeEl) {
|
|
settingsPageStatusTimeEl.hidden = !formattedSavedAt;
|
|
settingsPageStatusTimeEl.textContent = formattedSavedAt ? `Last saved ${formattedSavedAt}` : "";
|
|
}
|
|
}
|
|
|
|
function syncSavedSettingsStatus(message = "Settings ready.") {
|
|
setSettingsPageStatus(message, "neutral", { savedAt: loadLastSavedAt() });
|
|
}
|
|
|
|
function setSaveButtonBusy(isBusy) {
|
|
const { saveSettingsEl } = getElements();
|
|
if (!saveSettingsEl) {
|
|
return;
|
|
}
|
|
|
|
if (!saveSettingsEl.dataset.defaultLabel) {
|
|
saveSettingsEl.dataset.defaultLabel = String(saveSettingsEl.textContent || "Save Settings").trim() || "Save Settings";
|
|
}
|
|
|
|
saveSettingsEl.disabled = Boolean(isBusy);
|
|
saveSettingsEl.textContent = isBusy ? "Saving..." : saveSettingsEl.dataset.defaultLabel;
|
|
}
|
|
|
|
function setLocationEntryState(isExplicit) {
|
|
const { latEl, lngEl } = getElements();
|
|
const normalizedValue = isExplicit ? "true" : "false";
|
|
|
|
if (latEl) {
|
|
latEl.dataset.explicitLocation = normalizedValue;
|
|
}
|
|
|
|
if (lngEl) {
|
|
lngEl.dataset.explicitLocation = normalizedValue;
|
|
}
|
|
}
|
|
|
|
function hasExplicitLocationEntry() {
|
|
const { latEl } = getElements();
|
|
return latEl?.dataset.explicitLocation === "true";
|
|
}
|
|
|
|
function syncStellariumBackgroundAvailability() {
|
|
const { stellariumBackgroundEl, stellariumBackgroundHintEl } = getElements();
|
|
if (!stellariumBackgroundEl) {
|
|
return;
|
|
}
|
|
|
|
const hasExplicitLocation = hasExplicitLocationEntry();
|
|
stellariumBackgroundEl.disabled = !hasExplicitLocation;
|
|
|
|
if (!hasExplicitLocation) {
|
|
stellariumBackgroundEl.checked = false;
|
|
}
|
|
|
|
if (stellariumBackgroundHintEl) {
|
|
stellariumBackgroundHintEl.textContent = hasExplicitLocation
|
|
? "Uses your saved location to load the live sky background."
|
|
: "Enter a location or use My Location before enabling the live sky background.";
|
|
}
|
|
}
|
|
|
|
function markLocationAsExplicit() {
|
|
setLocationEntryState(true);
|
|
syncStellariumBackgroundAvailability();
|
|
}
|
|
function getConnectionSettings() {
|
|
return window.TarotAppConfig?.getConnectionSettings?.() || {
|
|
apiBaseUrl: String(window.TarotAppConfig?.apiBaseUrl || "").trim(),
|
|
apiKey: String(window.TarotAppConfig?.apiKey || "").trim()
|
|
};
|
|
}
|
|
|
|
function hasTarotAccess() {
|
|
return window.TarotAppConfig?.hasTarotAccess?.() === true;
|
|
}
|
|
|
|
function syncConnectionInputs() {
|
|
const { apiBaseUrlEl, apiKeyEl } = getElements();
|
|
const connectionSettings = getConnectionSettings();
|
|
|
|
if (apiBaseUrlEl) {
|
|
apiBaseUrlEl.value = String(connectionSettings.apiBaseUrl || "");
|
|
}
|
|
|
|
if (apiKeyEl) {
|
|
apiKeyEl.value = String(connectionSettings.apiKey || "");
|
|
}
|
|
}
|
|
|
|
function hasConnectionChanged(previous, next) {
|
|
return String(previous?.apiBaseUrl || "").trim() !== String(next?.apiBaseUrl || "").trim()
|
|
|| String(previous?.apiKey || "").trim() !== String(next?.apiKey || "").trim();
|
|
}
|
|
|
|
function normalizeConnectionValues(values) {
|
|
return Array.isArray(values)
|
|
? values.map((entry) => String(entry || "").trim()).filter(Boolean)
|
|
: [];
|
|
}
|
|
|
|
function hasAdminCapability(auth) {
|
|
const roles = normalizeConnectionValues(auth?.roles);
|
|
const scopes = normalizeConnectionValues(auth?.scopes);
|
|
return roles.includes("admin") || scopes.includes("api:admin");
|
|
}
|
|
|
|
function formatConnectionValues(values, fallback = "none") {
|
|
const normalized = normalizeConnectionValues(values);
|
|
return normalized.length ? normalized.join(", ") : fallback;
|
|
}
|
|
|
|
function setConnectionSummary(result = null) {
|
|
const {
|
|
apiConnectionSummaryEl,
|
|
apiConnectionSummaryStateEl,
|
|
apiConnectionSummaryClientEl,
|
|
apiConnectionSummaryAccessEl,
|
|
apiConnectionSummaryPermissionsEl
|
|
} = getElements();
|
|
|
|
if (!apiConnectionSummaryEl) {
|
|
return;
|
|
}
|
|
|
|
if (!result) {
|
|
apiConnectionSummaryEl.dataset.tone = "neutral";
|
|
if (apiConnectionSummaryStateEl) {
|
|
apiConnectionSummaryStateEl.textContent = "Not checked yet.";
|
|
}
|
|
if (apiConnectionSummaryClientEl) {
|
|
apiConnectionSummaryClientEl.textContent = "No authenticated API identity.";
|
|
}
|
|
if (apiConnectionSummaryAccessEl) {
|
|
apiConnectionSummaryAccessEl.textContent = "Unknown";
|
|
}
|
|
if (apiConnectionSummaryPermissionsEl) {
|
|
apiConnectionSummaryPermissionsEl.textContent = "Save settings to validate this API key.";
|
|
}
|
|
return;
|
|
}
|
|
|
|
const tone = result.ok ? "success" : (result.reason === "auth-required" ? "warning" : "error");
|
|
const auth = result.auth || result.health?.auth || {};
|
|
const roles = normalizeConnectionValues(auth.roles);
|
|
const scopes = normalizeConnectionValues(auth.scopes);
|
|
const authenticated = auth.authenticated === true;
|
|
const tarotAccessEnabled = result?.capabilities?.tarot === true;
|
|
const accessValue = authenticated
|
|
? `${String(auth.accessLevel || "premium").trim() || "premium"}${hasAdminCapability(auth) ? " - admin capable" : ""}${tarotAccessEnabled ? " - tarot enabled" : " - tarot hidden"}`
|
|
: (result.health?.apiKeyRequired ? "API key required" : "public");
|
|
const permissionsValue = authenticated
|
|
? `roles: ${formatConnectionValues(roles)} | scopes: ${formatConnectionValues(scopes)}`
|
|
: (result.message || "Unable to validate the API connection.");
|
|
|
|
apiConnectionSummaryEl.dataset.tone = tone;
|
|
if (apiConnectionSummaryStateEl) {
|
|
apiConnectionSummaryStateEl.textContent = result.ok
|
|
? `Connected${Number.isInteger(result.deckCount) ? ` • ${result.deckCount} deck${result.deckCount === 1 ? "" : "s"}` : ""}`
|
|
: String(result.message || "Unable to validate the API connection.");
|
|
}
|
|
if (apiConnectionSummaryClientEl) {
|
|
apiConnectionSummaryClientEl.textContent = authenticated
|
|
? [String(auth.clientId || "").trim(), String(auth.accountId || "").trim()].filter(Boolean).join(" / ") || "Authenticated client"
|
|
: (result.health?.apiKeyRequired ? "No valid API identity returned." : "Public access");
|
|
}
|
|
if (apiConnectionSummaryAccessEl) {
|
|
apiConnectionSummaryAccessEl.textContent = accessValue;
|
|
}
|
|
if (apiConnectionSummaryPermissionsEl) {
|
|
apiConnectionSummaryPermissionsEl.textContent = permissionsValue;
|
|
}
|
|
}
|
|
|
|
async function refreshConnectionSummary(connectionSettings = getConnectionSettings(), { probeResult = null } = {}) {
|
|
const apiBaseUrl = String(connectionSettings?.apiBaseUrl || "").trim();
|
|
if (!apiBaseUrl) {
|
|
lastConnectionProbeResult = null;
|
|
setConnectionSummary(null);
|
|
return null;
|
|
}
|
|
|
|
const result = probeResult || await window.TarotDataService?.probeConnection?.(connectionSettings) || null;
|
|
lastConnectionProbeResult = result;
|
|
setConnectionSummary(result);
|
|
return result;
|
|
}
|
|
|
|
|
|
function setStatus(text) {
|
|
if (typeof config.onStatus === "function") {
|
|
config.onStatus(text);
|
|
}
|
|
}
|
|
|
|
function applyExternalSettings(settings) {
|
|
if (typeof config.onSettingsApplied === "function") {
|
|
config.onSettingsApplied(settings);
|
|
}
|
|
}
|
|
|
|
function formatDeckCacheStatus(status) {
|
|
const activeDeckId = String(status?.activeDeckId || normalizeTarotDeck(getElements().tarotDeckEl?.value)).trim().toLowerCase();
|
|
const loadedCount = Math.max(0, Number(status?.selectedDeckLoadedCount) || 0);
|
|
const totalCount = Math.max(0, Number(status?.selectedDeckTotalCount) || 0);
|
|
const warmedDeckCount = Math.max(0, Number(status?.warmedDeckCount) || 0);
|
|
const totalDeckCount = Math.max(0, Number(status?.totalDeckCount) || 0);
|
|
const backgroundProgress = totalDeckCount > 1
|
|
? ` (${Math.min(warmedDeckCount, totalDeckCount)}/${totalDeckCount} decks warmed)`
|
|
: "";
|
|
|
|
if (status?.selectedDeckPhase === "loading") {
|
|
if (totalCount > 0) {
|
|
return `Caching selected deck images to this browser... (${loadedCount}/${totalCount})${backgroundProgress}`;
|
|
}
|
|
return `Caching selected deck images to this browser...${backgroundProgress}`;
|
|
}
|
|
|
|
if (status?.selectedDeckPhase === "error") {
|
|
return "Selected deck cache warmup hit an error. Images will still load on demand.";
|
|
}
|
|
|
|
if (status?.selectedDeckPhase === "ready") {
|
|
if (totalDeckCount > 1 && warmedDeckCount < totalDeckCount) {
|
|
return `Selected deck cached and ready for fullscreen use (${activeDeckId}). Warming the rest of the decks in background${backgroundProgress}.`;
|
|
}
|
|
|
|
if (totalDeckCount > 1) {
|
|
return `All connected deck images cached and ready (${totalDeckCount}/${totalDeckCount} decks warmed).`;
|
|
}
|
|
|
|
return `Selected deck cached and ready for fullscreen use (${activeDeckId}).`;
|
|
}
|
|
|
|
if (totalDeckCount > 1 && warmedDeckCount > 0) {
|
|
return `Deck cache idle. Background warmup has ${Math.min(warmedDeckCount, totalDeckCount)}/${totalDeckCount} decks ready.`;
|
|
}
|
|
|
|
return "Deck cache idle.";
|
|
}
|
|
|
|
function syncDeckCacheStatus(status) {
|
|
const {
|
|
tarotDeckCacheStatusEl,
|
|
tarotDeckCacheProgressEl,
|
|
tarotDeckCacheProgressLabelEl
|
|
} = getElements();
|
|
if (!tarotDeckCacheStatusEl) {
|
|
return;
|
|
}
|
|
|
|
tarotDeckCacheStatusEl.textContent = formatDeckCacheStatus(status);
|
|
|
|
const totalCount = Math.max(0, Number(status?.selectedDeckTotalCount) || 0);
|
|
const loadedCount = Math.max(0, Number(status?.selectedDeckLoadedCount) || 0);
|
|
const derivedPercent = totalCount > 0 ? Math.round((loadedCount / totalCount) * 100) : 0;
|
|
const percent = Math.max(0, Math.min(100, Number(status?.selectedDeckPercent) || derivedPercent));
|
|
|
|
if (tarotDeckCacheProgressEl) {
|
|
tarotDeckCacheProgressEl.max = 100;
|
|
tarotDeckCacheProgressEl.value = percent;
|
|
}
|
|
|
|
if (tarotDeckCacheProgressLabelEl) {
|
|
tarotDeckCacheProgressLabelEl.textContent = `${percent}%`;
|
|
}
|
|
}
|
|
|
|
function syncDetailTextScaleLabel(detailTextScale) {
|
|
const { detailTextScaleValueEl } = getElements();
|
|
if (!detailTextScaleValueEl) {
|
|
return;
|
|
}
|
|
|
|
detailTextScaleValueEl.textContent = `${Math.round(normalizeDetailTextScale(detailTextScale) * 100)}%`;
|
|
}
|
|
|
|
function syncSky(geo, options) {
|
|
if (typeof config.onSyncSkyBackground === "function") {
|
|
config.onSyncSkyBackground(geo, options);
|
|
}
|
|
}
|
|
|
|
function normalizeTimeFormat(value) {
|
|
if (value === "hours") {
|
|
return "hours";
|
|
}
|
|
|
|
if (value === "seconds") {
|
|
return "seconds";
|
|
}
|
|
|
|
return "minutes";
|
|
}
|
|
|
|
function clampNumber(value, min, max, fallback) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) {
|
|
return fallback;
|
|
}
|
|
|
|
return Math.min(max, Math.max(min, parsed));
|
|
}
|
|
|
|
function normalizeDetailTextScale(value) {
|
|
return clampNumber(value, 0.85, 1.35, 1);
|
|
}
|
|
|
|
function normalizeBirthDate(value) {
|
|
const normalized = String(value || "").trim();
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
|
|
return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : "";
|
|
}
|
|
|
|
function getKnownTarotDeckIds() {
|
|
if (!hasTarotAccess()) {
|
|
return new Set([String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase()]);
|
|
}
|
|
|
|
const knownDeckIds = new Set();
|
|
const deckOptions = window.TarotCardImages?.getDeckOptions?.();
|
|
|
|
if (Array.isArray(deckOptions)) {
|
|
deckOptions.forEach((option) => {
|
|
const id = String(option?.id || "").trim().toLowerCase();
|
|
if (id) {
|
|
knownDeckIds.add(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!knownDeckIds.size) {
|
|
knownDeckIds.add(String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase());
|
|
}
|
|
|
|
return knownDeckIds;
|
|
}
|
|
|
|
function getFallbackTarotDeckId() {
|
|
if (!hasTarotAccess()) {
|
|
return String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase();
|
|
}
|
|
|
|
const deckOptions = window.TarotCardImages?.getDeckOptions?.();
|
|
if (Array.isArray(deckOptions)) {
|
|
for (let i = 0; i < deckOptions.length; i += 1) {
|
|
const id = String(deckOptions[i]?.id || "").trim().toLowerCase();
|
|
if (id) {
|
|
return id;
|
|
}
|
|
}
|
|
}
|
|
|
|
return String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeTarotDeck(value) {
|
|
if (!hasTarotAccess()) {
|
|
const preservedValue = String(value || "").trim().toLowerCase();
|
|
return preservedValue || String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase();
|
|
}
|
|
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
const knownDeckIds = getKnownTarotDeckIds();
|
|
|
|
if (knownDeckIds.has(normalized)) {
|
|
return normalized;
|
|
}
|
|
|
|
return getFallbackTarotDeckId();
|
|
}
|
|
|
|
function parseStoredNumber(value, fallback) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function parseStoredBoolean(value, fallback = false) {
|
|
return typeof value === "boolean" ? value : fallback;
|
|
}
|
|
|
|
function hasLegacyExplicitLocation(settings, normalizedLatitude, normalizedLongitude) {
|
|
const hasStoredCoordinates = Number.isFinite(Number(settings?.latitude)) && Number.isFinite(Number(settings?.longitude));
|
|
if (!hasStoredCoordinates) {
|
|
return false;
|
|
}
|
|
|
|
return Math.abs(normalizedLatitude - Number(config.defaultSettings.latitude)) > 0.00005
|
|
|| Math.abs(normalizedLongitude - Number(config.defaultSettings.longitude)) > 0.00005;
|
|
}
|
|
|
|
function normalizeSettings(settings) {
|
|
const latitude = parseStoredNumber(settings?.latitude, config.defaultSettings.latitude);
|
|
const longitude = parseStoredNumber(settings?.longitude, config.defaultSettings.longitude);
|
|
const hasExplicitLocation = typeof settings?.hasExplicitLocation === "boolean"
|
|
? settings.hasExplicitLocation
|
|
: hasLegacyExplicitLocation(settings, latitude, longitude);
|
|
|
|
return {
|
|
latitude,
|
|
longitude,
|
|
timeFormat: normalizeTimeFormat(settings?.timeFormat),
|
|
birthDate: normalizeBirthDate(settings?.birthDate),
|
|
tarotDeck: normalizeTarotDeck(settings?.tarotDeck),
|
|
detailTextScale: normalizeDetailTextScale(settings?.detailTextScale),
|
|
stellariumBackgroundEnabled: parseStoredBoolean(settings?.stellariumBackgroundEnabled, false) && hasExplicitLocation,
|
|
hasExplicitLocation
|
|
};
|
|
}
|
|
|
|
function getResolvedTimeZone() {
|
|
try {
|
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
return String(timeZone || "");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function buildBirthDateParts(birthDate) {
|
|
const normalized = normalizeBirthDate(birthDate);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const [year, month, day] = normalized.split("-").map((value) => Number(value));
|
|
if (!year || !month || !day) {
|
|
return null;
|
|
}
|
|
|
|
const localNoon = new Date(year, month - 1, day, 12, 0, 0, 0);
|
|
const utcNoon = new Date(Date.UTC(year, month - 1, day, 12, 0, 0, 0));
|
|
|
|
return {
|
|
year,
|
|
month,
|
|
day,
|
|
isoDate: normalized,
|
|
localNoonIso: localNoon.toISOString(),
|
|
utcNoonIso: utcNoon.toISOString(),
|
|
timezoneOffsetMinutesAtNoon: localNoon.getTimezoneOffset()
|
|
};
|
|
}
|
|
|
|
function buildNatalContext(settings) {
|
|
const normalized = normalizeSettings(settings);
|
|
const birthDateParts = buildBirthDateParts(normalized.birthDate);
|
|
const timeZone = getResolvedTimeZone();
|
|
|
|
return {
|
|
latitude: normalized.latitude,
|
|
longitude: normalized.longitude,
|
|
birthDate: normalized.birthDate || null,
|
|
birthDateParts,
|
|
timeZone: timeZone || "UTC",
|
|
timezoneOffsetMinutesNow: new Date().getTimezoneOffset(),
|
|
timezoneOffsetMinutesAtBirthDateNoon: birthDateParts?.timezoneOffsetMinutesAtNoon ?? null
|
|
};
|
|
}
|
|
|
|
function emitSettingsUpdated(settings) {
|
|
const normalized = normalizeSettings(settings);
|
|
const natalContext = buildNatalContext(normalized);
|
|
document.dispatchEvent(new CustomEvent("settings:updated", {
|
|
detail: {
|
|
settings: normalized,
|
|
natalContext
|
|
}
|
|
}));
|
|
}
|
|
|
|
function loadSavedSettings() {
|
|
try {
|
|
const raw = window.localStorage.getItem(SETTINGS_STORAGE_KEY);
|
|
if (!raw) {
|
|
return { ...config.defaultSettings };
|
|
}
|
|
|
|
const parsed = JSON.parse(raw);
|
|
return normalizeSettings(parsed);
|
|
} catch {
|
|
return { ...config.defaultSettings };
|
|
}
|
|
}
|
|
|
|
function saveSettings(settings) {
|
|
try {
|
|
const normalized = normalizeSettings(settings);
|
|
window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(normalized));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function syncTarotDeckInputOptions() {
|
|
const { tarotDeckEl } = getElements();
|
|
if (!tarotDeckEl) {
|
|
return;
|
|
}
|
|
|
|
if (!hasTarotAccess()) {
|
|
tarotDeckEl.innerHTML = "";
|
|
const hiddenOption = document.createElement("option");
|
|
hiddenOption.value = String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase();
|
|
hiddenOption.textContent = "Tarot deck unavailable for this API key";
|
|
tarotDeckEl.appendChild(hiddenOption);
|
|
tarotDeckEl.disabled = true;
|
|
return;
|
|
}
|
|
|
|
const deckOptions = window.TarotCardImages?.getDeckOptions?.();
|
|
const previousValue = String(tarotDeckEl.value || "").trim().toLowerCase();
|
|
tarotDeckEl.innerHTML = "";
|
|
|
|
if (!Array.isArray(deckOptions) || !deckOptions.length) {
|
|
const emptyOption = document.createElement("option");
|
|
emptyOption.value = String(config.defaultSettings?.tarotDeck || "ceremonial-magick").trim().toLowerCase();
|
|
emptyOption.textContent = "No deck manifests found";
|
|
tarotDeckEl.appendChild(emptyOption);
|
|
tarotDeckEl.disabled = true;
|
|
return;
|
|
}
|
|
|
|
tarotDeckEl.disabled = false;
|
|
|
|
deckOptions.forEach((option) => {
|
|
const id = String(option?.id || "").trim().toLowerCase();
|
|
if (!id) {
|
|
return;
|
|
}
|
|
|
|
const label = String(option?.label || id);
|
|
const optionEl = document.createElement("option");
|
|
optionEl.value = id;
|
|
optionEl.textContent = label;
|
|
tarotDeckEl.appendChild(optionEl);
|
|
});
|
|
|
|
tarotDeckEl.value = normalizeTarotDeck(previousValue);
|
|
}
|
|
|
|
function syncActiveTarotDeck(deckId) {
|
|
if (!hasTarotAccess()) {
|
|
return;
|
|
}
|
|
|
|
const normalizedDeckId = normalizeTarotDeck(deckId);
|
|
if (window.TarotCardImages?.setActiveDeck) {
|
|
window.TarotCardImages.setActiveDeck(normalizedDeckId);
|
|
}
|
|
|
|
const { tarotDeckEl } = getElements();
|
|
if (tarotDeckEl) {
|
|
tarotDeckEl.value = normalizedDeckId;
|
|
}
|
|
|
|
syncDeckCacheStatus(window.TarotCardImages?.getDeckPreloadStatus?.());
|
|
}
|
|
|
|
function applySettingsToInputs(settings) {
|
|
const { latEl, lngEl, timeFormatEl, birthDateEl, tarotDeckEl, detailTextScaleEl, stellariumBackgroundEl } = getElements();
|
|
syncTarotDeckInputOptions();
|
|
syncConnectionInputs();
|
|
const normalized = normalizeSettings(settings);
|
|
latEl.value = String(normalized.latitude);
|
|
lngEl.value = String(normalized.longitude);
|
|
timeFormatEl.value = normalized.timeFormat;
|
|
birthDateEl.value = normalized.birthDate;
|
|
if (detailTextScaleEl) {
|
|
detailTextScaleEl.value = String(Math.round(normalized.detailTextScale * 100));
|
|
}
|
|
syncDetailTextScaleLabel(normalized.detailTextScale);
|
|
if (tarotDeckEl) {
|
|
tarotDeckEl.value = normalized.tarotDeck;
|
|
}
|
|
setLocationEntryState(normalized.hasExplicitLocation);
|
|
if (stellariumBackgroundEl) {
|
|
stellariumBackgroundEl.checked = normalized.stellariumBackgroundEnabled;
|
|
}
|
|
syncStellariumBackgroundAvailability();
|
|
syncActiveTarotDeck(normalized.tarotDeck);
|
|
applyExternalSettings(normalized);
|
|
return normalized;
|
|
}
|
|
|
|
function getSettingsFromInputs() {
|
|
const { latEl, lngEl, timeFormatEl, birthDateEl, tarotDeckEl, detailTextScaleEl, stellariumBackgroundEl } = getElements();
|
|
const latitudeText = String(latEl?.value || "").trim();
|
|
const longitudeText = String(lngEl?.value || "").trim();
|
|
|
|
if (!latitudeText || !longitudeText) {
|
|
throw new Error("Latitude/Longitude must be entered before saving settings.");
|
|
}
|
|
|
|
const latitude = Number(latitudeText);
|
|
const longitude = Number(longitudeText);
|
|
|
|
if (Number.isNaN(latitude) || Number.isNaN(longitude)) {
|
|
throw new Error("Latitude/Longitude must be valid numbers.");
|
|
}
|
|
|
|
return normalizeSettings({
|
|
latitude,
|
|
longitude,
|
|
timeFormat: normalizeTimeFormat(timeFormatEl.value),
|
|
birthDate: normalizeBirthDate(birthDateEl.value),
|
|
tarotDeck: normalizeTarotDeck(tarotDeckEl?.value),
|
|
detailTextScale: normalizeDetailTextScale(Number(detailTextScaleEl?.value || 100) / 100),
|
|
stellariumBackgroundEnabled: Boolean(stellariumBackgroundEl?.checked),
|
|
hasExplicitLocation: hasExplicitLocationEntry()
|
|
});
|
|
}
|
|
|
|
function getConnectionSettingsFromInputs() {
|
|
const { apiBaseUrlEl, apiKeyEl } = getElements();
|
|
|
|
return {
|
|
apiBaseUrl: String(apiBaseUrlEl?.value || "").trim(),
|
|
apiKey: String(apiKeyEl?.value || "").trim()
|
|
};
|
|
}
|
|
|
|
function openSettingsPopup() {
|
|
const activeSection = typeof config.getActiveSection === "function" ? config.getActiveSection() : "home";
|
|
if (activeSection && activeSection !== "settings") {
|
|
lastNonSettingsSection = activeSection;
|
|
}
|
|
applySettingsToInputs(loadSavedSettings());
|
|
syncSavedSettingsStatus();
|
|
void refreshConnectionSummary(getConnectionSettings(), {
|
|
probeResult: lastConnectionProbeResult
|
|
});
|
|
config.setActiveSection?.("settings");
|
|
}
|
|
|
|
function closeSettingsPopup() {
|
|
config.setActiveSection?.(lastNonSettingsSection || "home");
|
|
}
|
|
|
|
async function handleSaveSettings() {
|
|
setSaveButtonBusy(true);
|
|
setSettingsPageStatus("Saving settings...", "info");
|
|
|
|
try {
|
|
const settings = getSettingsFromInputs();
|
|
const previousConnectionSettings = getConnectionSettings();
|
|
const connectionSettings = getConnectionSettingsFromInputs();
|
|
const connectionChanged = hasConnectionChanged(previousConnectionSettings, connectionSettings);
|
|
|
|
if (connectionChanged) {
|
|
setSettingsPageStatus("Validating API connection...", "info", {
|
|
savedAt: loadLastSavedAt()
|
|
});
|
|
|
|
const probeResult = await window.TarotDataService?.probeConnection?.(connectionSettings);
|
|
if (!probeResult?.ok) {
|
|
setConnectionSummary(probeResult);
|
|
throw new Error(probeResult?.message || "Unable to validate the API connection.");
|
|
}
|
|
|
|
lastConnectionProbeResult = probeResult;
|
|
setConnectionSummary(probeResult);
|
|
}
|
|
|
|
const connectionResult = window.TarotAppConfig?.updateConnectionSettings?.(connectionSettings) || { didPersist: true };
|
|
const normalized = applySettingsToInputs(settings);
|
|
syncSky(
|
|
{ latitude: normalized.latitude, longitude: normalized.longitude },
|
|
{
|
|
force: true,
|
|
backgroundEnabled: normalized.stellariumBackgroundEnabled,
|
|
hasExplicitLocation: normalized.hasExplicitLocation
|
|
}
|
|
);
|
|
const didPersist = saveSettings(normalized);
|
|
emitSettingsUpdated(normalized);
|
|
if (typeof config.getActiveSection === "function" && config.getActiveSection() !== "home" && config.getActiveSection() !== "settings") {
|
|
config.onReopenActiveSection?.(config.getActiveSection());
|
|
}
|
|
if (connectionChanged && typeof config.onConnectionSaved === "function") {
|
|
await config.onConnectionSaved(connectionResult, connectionSettings);
|
|
} else if (typeof config.onRenderWeek === "function") {
|
|
await config.onRenderWeek();
|
|
}
|
|
|
|
if (!didPersist || connectionResult.didPersist === false) {
|
|
setSettingsPageStatus("Settings applied for this session. Browser storage is unavailable.", "warning", {
|
|
savedAt: loadLastSavedAt()
|
|
});
|
|
setStatus("Settings applied for this session. Browser storage is unavailable.");
|
|
} else {
|
|
const savedAt = Date.now();
|
|
persistLastSavedAt(savedAt);
|
|
setSettingsPageStatus("Settings saved.", "success", { savedAt });
|
|
setStatus("Settings saved.");
|
|
}
|
|
} catch (error) {
|
|
setSettingsPageStatus(error?.message || "Unable to save settings.", "error", {
|
|
savedAt: loadLastSavedAt()
|
|
});
|
|
setStatus(error?.message || "Unable to save settings.");
|
|
} finally {
|
|
setSaveButtonBusy(false);
|
|
}
|
|
}
|
|
|
|
function requestGeoLocation() {
|
|
const { latEl, lngEl } = getElements();
|
|
if (!navigator.geolocation) {
|
|
setSettingsPageStatus("Geolocation not available in this browser.", "warning", {
|
|
savedAt: loadLastSavedAt()
|
|
});
|
|
setStatus("Geolocation not available in this browser.");
|
|
return;
|
|
}
|
|
|
|
setSettingsPageStatus("Getting your location...", "info", {
|
|
savedAt: loadLastSavedAt()
|
|
});
|
|
setStatus("Getting your location...");
|
|
navigator.geolocation.getCurrentPosition(
|
|
({ coords }) => {
|
|
latEl.value = coords.latitude.toFixed(4);
|
|
lngEl.value = coords.longitude.toFixed(4);
|
|
markLocationAsExplicit();
|
|
syncSky(
|
|
{ latitude: coords.latitude, longitude: coords.longitude },
|
|
{
|
|
force: true,
|
|
backgroundEnabled: Boolean(getElements().stellariumBackgroundEl?.checked),
|
|
hasExplicitLocation: true
|
|
}
|
|
);
|
|
setSettingsPageStatus("Location captured. Save Settings to keep it in this browser.", "info", {
|
|
savedAt: loadLastSavedAt()
|
|
});
|
|
setStatus("Location set from browser. Save Settings to use it across the app.");
|
|
},
|
|
(err) => {
|
|
const detail = err?.message || `code ${err?.code ?? "unknown"}`;
|
|
setSettingsPageStatus(`Could not get location (${detail}).`, "error", {
|
|
savedAt: loadLastSavedAt()
|
|
});
|
|
setStatus(`Could not get location (${detail}).`);
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 10000 }
|
|
);
|
|
}
|
|
|
|
function bindInteractions() {
|
|
const {
|
|
saveSettingsEl,
|
|
useLocationEl,
|
|
openSettingsEl,
|
|
closeSettingsEl,
|
|
detailTextScaleEl,
|
|
latEl,
|
|
lngEl
|
|
} = getElements();
|
|
|
|
if (saveSettingsEl) {
|
|
saveSettingsEl.addEventListener("click", () => {
|
|
void handleSaveSettings();
|
|
});
|
|
}
|
|
|
|
if (useLocationEl) {
|
|
useLocationEl.addEventListener("click", requestGeoLocation);
|
|
}
|
|
|
|
if (detailTextScaleEl) {
|
|
detailTextScaleEl.addEventListener("input", () => {
|
|
syncDetailTextScaleLabel(Number(detailTextScaleEl.value) / 100);
|
|
});
|
|
}
|
|
|
|
[latEl, lngEl].forEach((inputEl) => {
|
|
if (!inputEl) {
|
|
return;
|
|
}
|
|
|
|
inputEl.addEventListener("input", markLocationAsExplicit);
|
|
inputEl.addEventListener("change", markLocationAsExplicit);
|
|
});
|
|
|
|
if (openSettingsEl) {
|
|
openSettingsEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if ((config.getActiveSection?.() || "home") !== "settings") {
|
|
openSettingsPopup();
|
|
} else {
|
|
closeSettingsPopup();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (closeSettingsEl) {
|
|
closeSettingsEl.addEventListener("click", closeSettingsPopup);
|
|
}
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && (config.getActiveSection?.() || "home") === "settings") {
|
|
closeSettingsPopup();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("connection:updated", () => {
|
|
syncConnectionInputs();
|
|
void refreshConnectionSummary(getConnectionSettings());
|
|
});
|
|
|
|
document.addEventListener("connection:access-updated", () => {
|
|
syncTarotDeckInputOptions();
|
|
syncActiveTarotDeck(getElements().tarotDeckEl?.value || loadSavedSettings().tarotDeck);
|
|
void refreshConnectionSummary(getConnectionSettings());
|
|
});
|
|
|
|
document.addEventListener("tarot:deck-cache-status", (event) => {
|
|
if (hasTarotAccess()) {
|
|
syncDeckCacheStatus(event?.detail);
|
|
}
|
|
});
|
|
}
|
|
|
|
function init(nextConfig = {}) {
|
|
config = {
|
|
...config,
|
|
...nextConfig,
|
|
defaultSettings: {
|
|
...config.defaultSettings,
|
|
...(nextConfig.defaultSettings || {})
|
|
}
|
|
};
|
|
|
|
syncSavedSettingsStatus();
|
|
setConnectionSummary(lastConnectionProbeResult);
|
|
bindInteractions();
|
|
}
|
|
|
|
function loadInitialSettingsAndApply() {
|
|
const initialSettings = loadSavedSettings();
|
|
const normalized = applySettingsToInputs(initialSettings);
|
|
emitSettingsUpdated(normalized);
|
|
return normalized;
|
|
}
|
|
|
|
window.TarotSettingsUi = {
|
|
...(window.TarotSettingsUi || {}),
|
|
init,
|
|
openSettingsPopup,
|
|
closeSettingsPopup,
|
|
loadInitialSettingsAndApply,
|
|
buildNatalContext,
|
|
normalizeSettings
|
|
};
|
|
})();
|