import { html, reactive } from "/assets/vendor/arrow-core.js"; import { Modal } from "/assets/components/modal.js"; const defaultColors = { LaneLines: { red: 23, green: 134, blue: 68, alpha: 255 }, LeadMarker: { red: 23, green: 134, blue: 68, alpha: 255 }, Path: { red: 23, green: 134, blue: 68, alpha: 255 }, PathEdge: { red: 18, green: 107, blue: 54, alpha: 255 }, Sidebar1: { red: 23, green: 134, blue: 68, alpha: 255 }, Sidebar2: { red: 23, green: 134, blue: 68, alpha: 255 }, Sidebar3: { red: 23, green: 134, blue: 68, alpha: 255 }, }; const COLOR_LABELS = { LaneLines: "Lane Lines", LeadMarker: "Lead Marker", Path: "Path", PathEdge: "Path Edge", Sidebar1: "Sidebar Top", Sidebar2: "Sidebar Middle", Sidebar3: "Sidebar Bottom", }; const ICON_LABELS = { homeButton: "Home Button", settingsButton: "Settings Button", }; const SOUND_DEFINITIONS = [ { key: "disengage", label: "Disengage Sound" }, { key: "engage", label: "Engage Sound" }, { key: "prompt", label: "Prompt Sound" }, { key: "startup", label: "Startup Sound" }, ]; const BUILTIN_THEME_OPTIONS = { colors: { name: "Stock", path: "__stock__", type: "stock", builtin: true }, distance_icons: { name: "Stock", path: "__stock__", type: "stock", builtin: true }, icons: { name: "Stock", path: "__stock__", type: "stock", builtin: true }, sounds: { name: "Stock", path: "__stock__", type: "stock", builtin: true }, steering_wheel: { name: "Stock", path: "__stock__", type: "stock", builtin: true }, turn_signals: { name: "None (Stock)", path: "__stock_none__", type: "stock_none", builtin: true }, }; const PARAM_KEY_BY_ASSET_TYPE = { colors: "ColorScheme", distance_icons: "DistanceIconPack", icons: "IconPack", sounds: "SoundPack", turn_signals: "SignalAnimation", steering_wheel: "WheelIcon", }; const fileStore = { images: { distanceIcons: {} }, sounds: {}, sequentialFiles: [], }; const state = reactive({ themeName: "", discordUsername: "", downloadable: { colors: [], distance_icons: [], icons: [], signals: [], sounds: [], wheels: [] }, turnSignalStyle: "Static", turnSignalLength: 100, turnSignalType: "Single Image", sequentialImages: [], selectorAction: null, isApplying: false, isSaving: false, isSubmitting: false, isLoadingAsset: false, themeSubmitted: false, colors: { ...defaultColors }, imageFileNames: { homeButton: "", settingsButton: "", steeringWheel: "", turnSignal: "", turnSignalBlindspot: "", distanceIcons: { traffic: "", aggressive: "", standard: "", relaxed: "", }, }, soundFileNames: { startup: "", prompt: "", engage: "", disengage: "", }, saveChecklist: { colors: false, distance_icons: false, icons: false, sounds: false, turn_signals: false, steering_wheel: false, }, showTurnSignalHelp: false, showSubmitConfirmation: false, showSaveConfirmModal: false, showApplyConfirmModal: false, showManageThemesModal: false, showSequenceOrderModal: false, showDeleteConfirmModal: false, themeToDelete: null, themes: [], activeTab: "colors", selectedThemeSources: { colors: null, distance_icons: null, icons: null, sounds: null, turn_signals: null, steering_wheel: null, }, fetched: false, }); let draggedIndex = -1; let dropIndex = -1; const getHttpErrorMessage = (response) => { const statusText = response.statusText ? ` ${response.statusText}` : ""; return `Request failed (${response.status}${statusText})`; }; const readResponsePayload = async (response) => { const contentType = String(response.headers.get("content-type") || "").toLowerCase(); const text = await response.text(); const trimmed = text.trim(); if (contentType.includes("application/json")) { try { return trimmed ? JSON.parse(trimmed) : {}; } catch {} } if (!trimmed || trimmed.startsWith("<")) { return {}; } try { return JSON.parse(trimmed); } catch { return { message: trimmed, error: trimmed }; } }; const getResponseMessage = (response, payload = {}, fallbackMessage = "") => { return payload.error || payload.message || fallbackMessage || getHttpErrorMessage(response); }; const fetchFileAsFile = async (url, filename, errorMessage) => { const response = await fetch(url); if (!response.ok) { const payload = await readResponsePayload(response); throw new Error(getResponseMessage(response, payload, errorMessage)); } const blob = await response.blob(); return new File([blob], filename, { type: blob.type }); }; function handleDragStart(e, index) { draggedIndex = index; e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(index)); e.target.style.opacity = "0.5"; } function handleDragOver(e, index) { e.preventDefault(); const target = e.target.closest(".draggable-item"); if (target) { const rect = target.getBoundingClientRect(); const isAfter = e.clientY > rect.top + rect.height / 2; target.classList.toggle("drop-after", isAfter); target.classList.toggle("drop-before", !isAfter); dropIndex = isAfter ? index + 1 : index; } } function handleDrop(e) { e.preventDefault(); document.querySelectorAll(".draggable-item").forEach(el => { el.classList.remove("drop-before", "drop-after"); }); if (draggedIndex !== -1 && dropIndex !== -1 && draggedIndex !== dropIndex) { const move = (arr, from, to) => { const a = [...arr]; const [m] = a.splice(from, 1); const insertAt = from < to ? to - 1 : to; a.splice(insertAt, 0, m); return a; }; state.sequentialImages = move(state.sequentialImages, draggedIndex, dropIndex); fileStore.sequentialFiles = move(fileStore.sequentialFiles, draggedIndex, dropIndex); state.selectedThemeSources.turn_signals = null; } draggedIndex = -1; dropIndex = -1; } function handleDragLeave(e) { const target = e.target.closest(".draggable-item"); if (target) target.classList.remove("drop-before", "drop-after"); } function handleDragEnd(e) { e.target.style.opacity = "1"; draggedIndex = -1; dropIndex = -1; document.querySelectorAll(".draggable-item").forEach(item => { item.classList.remove("drop-before", "drop-after"); }); } const clearAsset = (type, key, subkey = null) => { state.themeSubmitted = false; const assetType = key === "turnSignal" || key === "turnSignalBlindspot" ? "turn_signals" : key === "distanceIcons" ? "distance_icons" : key === "homeButton" || key === "settingsButton" ? "icons" : key === "steeringWheel" ? "steering_wheel" : type === "audio" ? "sounds" : null; if (assetType) { state.selectedThemeSources[assetType] = null; } if (key === "turnSignal") { if (state.turnSignalType === "Sequential") { fileStore.sequentialFiles = []; state.sequentialImages = []; state.imageFileNames.turnSignal = ""; } else { fileStore.images.turnSignal = undefined; state.imageFileNames.turnSignal = ""; } return; } const store = type === "image" ? fileStore.images : fileStore.sounds; const nameState = type === "image" ? state.imageFileNames : state.soundFileNames; if (subkey) { if (store[key]) store[key][subkey] = undefined; if (nameState[key]) nameState[key][subkey] = ""; } else { store[key] = undefined; nameState[key] = ""; } }; const onClearClick = (e, type, key, subkey = null) => { e.preventDefault(); e.stopPropagation(); clearAsset(type, key, subkey); const input = e.currentTarget.parentElement.querySelector('input[type="file"]'); if (input) input.value = ""; }; const clearAssetType = (assetType) => { state.selectedThemeSources[assetType] = null; if (assetType === "colors") { state.colors = { ...defaultColors }; } if (assetType === "distance_icons") { ["traffic", "aggressive", "standard", "relaxed"].forEach(sub => { if (!fileStore.images.distanceIcons) fileStore.images.distanceIcons = {}; fileStore.images.distanceIcons[sub] = undefined; state.imageFileNames.distanceIcons[sub] = ""; }); } if (assetType === "icons") { ["homeButton", "settingsButton"].forEach(k => { fileStore.images[k] = undefined; state.imageFileNames[k] = ""; }); } if (assetType === "steering_wheel") { fileStore.images.steeringWheel = undefined; state.imageFileNames.steeringWheel = ""; } if (assetType === "sounds") { Object.keys(state.soundFileNames).forEach(k => { fileStore.sounds[k] = undefined; state.soundFileNames[k] = ""; }); } if (assetType === "turn_signals") { fileStore.images.turnSignal = undefined; fileStore.images.turnSignalBlindspot = undefined; state.imageFileNames.turnSignal = ""; state.imageFileNames.turnSignalBlindspot = ""; fileStore.sequentialFiles = []; state.sequentialImages = []; } }; const populateAssetNames = (themeData = {}) => { const { images = {}, sounds = {} } = themeData; const { distanceIcons = {} } = images; for (const key in state.imageFileNames) { if (key === "distanceIcons") { for (const subkey in state.imageFileNames.distanceIcons) { state.imageFileNames.distanceIcons[subkey] = (distanceIcons[subkey] || "").split("/").pop(); } } else { state.imageFileNames[key] = (images[key] || "").split("/").pop(); } } for (const key in state.soundFileNames) { state.soundFileNames[key] = (sounds[key] || "").split("/").pop(); } }; const isThemeAssetEmpty = () => { const hasNewFiles = Object.values(fileStore.images).some(val => { if (!val) return false; return typeof val === "object" && !(val instanceof File) ? Object.values(val).some(f => f) : true; }) || Object.values(fileStore.sounds).some(file => file) || fileStore.sequentialFiles.length > 0; if (hasNewFiles) return false; const hasExistingFileNames = Object.values(state.imageFileNames).some(val => { if (!val) return false; return typeof val === "object" ? Object.values(val).some(name => name) : val; }) || Object.values(state.soundFileNames).some(name => name) || state.sequentialImages.length > 0; return !hasExistingFileNames; }; const loadDefaultTheme = async () => { try { const response = await fetch("/api/themes/default"); const data = await response.json(); if (data.colors) { state.colors = data.colors; } if (data.images) { if (data.images.homeButton) { state.imageFileNames.homeButton = data.theme_names.icons || "Active"; } if (data.images.settingsButton) { state.imageFileNames.settingsButton = data.theme_names.icons || "Active"; } if (data.images.steeringWheel) { state.imageFileNames.steeringWheel = data.theme_names.steeringWheel || "Active"; } if (data.images.turnSignal) { state.imageFileNames.turnSignal = data.theme_names.turnSignals || "Active"; } if (data.images.turnSignalBlindspot) { state.imageFileNames.turnSignalBlindspot = data.theme_names.turnSignals || "Active"; } if (data.images.distanceIcons) { for (const key in data.images.distanceIcons) { if (data.images.distanceIcons[key]) { state.imageFileNames.distanceIcons[key] = data.theme_names.distanceIcons || "Active"; } } } } if (data.sounds) { for (const key in data.sounds) { if (data.sounds[key]) { state.soundFileNames[key] = data.theme_names.sounds || "Active"; } } } if (data.turnSignalLength) { state.turnSignalLength = data.turnSignalLength; } if (data.turnSignalStyle) { state.turnSignalStyle = data.turnSignalStyle; } if (data.turnSignalType) { state.turnSignalType = data.turnSignalType; } if (data.sequentialImages && data.sequentialImages.length > 0) { state.sequentialImages = data.sequentialImages; state.imageFileNames.turnSignal = data.theme_names.turnSignals || "Active"; } const fetchActive = async (relPath) => { const res = await fetch(`/api/themes/asset/__active__/${relPath}?type=active`); if (!res.ok) return null; const blob = await res.blob(); return new File([blob], relPath.split("/").pop(), { type: blob.type }); }; if (data.images?.turnSignal) { const f = await fetchActive(`signals/${data.images.turnSignal}`); if (f) fileStore.images.turnSignal = f; } if (data.images?.turnSignalBlindspot) { const f = await fetchActive(`signals/${data.images.turnSignalBlindspot}`); if (f) fileStore.images.turnSignalBlindspot = f; } fileStore.sequentialFiles = []; if (Array.isArray(data.sequentialImages) && data.sequentialImages.length) { for (const img of data.sequentialImages) { const f = await fetchActive(`signals/${img}`); if (f) fileStore.sequentialFiles.push(f); } } if (data.images?.distanceIcons) { if (!fileStore.images.distanceIcons) fileStore.images.distanceIcons = {}; for (const [k, v] of Object.entries(data.images.distanceIcons)) { const f = await fetchActive(`distance_icons/${v}`); if (f) fileStore.images.distanceIcons[k] = f; } } if (data.images?.homeButton) { const f = await fetchActive(`icons/${data.images.homeButton}`); if (f) fileStore.images.homeButton = f; } if (data.images?.settingsButton) { const f = await fetchActive(`icons/${data.images.settingsButton}`); if (f) fileStore.images.settingsButton = f; } if (data.images?.steeringWheel) { const f = await fetchActive(`steering_wheel/${data.images.steeringWheel}`); if (f) fileStore.images.steeringWheel = f; } if (data.sounds) { for (const [k, v] of Object.entries(data.sounds)) { const f = await fetchActive(`sounds/${v}`); if (f) fileStore.sounds[k] = f; } } } catch (error) { console.error("Failed to load default theme:", error); showSnackbar("Failed to load default theme.", "error"); } }; const fetchDownloadables = async () => { const keys = { colors: "DownloadableColors", distance_icons: "DownloadableDistanceIcons", icons: "DownloadableIcons", signals: "DownloadableSignals", sounds: "DownloadableSounds", wheels: "DownloadableWheels" }; const parseList = s => (s || "").split(",").map(v => v.trim()).filter(Boolean); for (const [slot, param] of Object.entries(keys)) { const r = await fetch(`/api/params?key=${encodeURIComponent(param)}`); const txt = await r.text(); state.downloadable[slot] = parseList(txt); } }; export function ThemeMaker() { if (!state.fetched) { state.fetched = true; (async () => { try { const response = await fetch("/api/params?key=DiscordUsername"); state.discordUsername = await response.text(); await loadDefaultTheme(); await fetchDownloadables(); } catch {} })(); } const normalize = (str) => (str || "") .toString() .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .replace(/\s+/g, " ") .trim(); const isDownloadable = (tab, name) => { const map = { colors: "colors", distance_icons: "distance_icons", icons: "icons", sounds: "sounds", steering_wheel: "wheels", turn_signals: "signals" }; const key = map[tab] || tab; const list = state.downloadable[key] || []; const target = normalize(name); return list.some(v => normalize(v) === target); }; const sortThemesAlphabetically = () => { state.themes.sort((a, b) => { const an = (a?.name || "").toString(); const bn = (b?.name || "").toString(); return an.localeCompare(bn, undefined, { sensitivity: "base", numeric: true }); }); }; const hasDistanceIcons = () => Object.values(fileStore.images.distanceIcons).some(f => f) || Object.values(state.imageFileNames.distanceIcons).some(name => name); const hasIcons = () => ["homeButton", "settingsButton"].some(key => fileStore.images[key] || state.imageFileNames[key]); const hasSounds = () => Object.values(fileStore.sounds).some(f => f) || Object.values(state.soundFileNames).some(name => name); const hasTurnSignals = () => fileStore.images.turnSignal || state.imageFileNames.turnSignal || fileStore.images.turnSignalBlindspot || state.imageFileNames.turnSignalBlindspot || fileStore.sequentialFiles.length > 0 || state.sequentialImages.length > 0; const hasSteeringWheel = () => fileStore.images.steeringWheel || state.imageFileNames.steeringWheel; const handleFileUpload = (e, type, key, subkey = null) => { const { files } = e.target; if (files.length === 0) return; for (const file of files) { if (key === "turnSignal" && state.turnSignalType === "Sequential" && file.type === "image/gif") { showSnackbar("GIFs are not supported for sequential turn signals...", "error"); e.target.value = ""; return; } if (file.size > 5 * 1024 * 1024) { showSnackbar(`File ${file.name} is too large! Please upload files under 5MB.`, "error"); e.target.value = ""; return; } if (!file.type.startsWith(`${type}/`)) { showSnackbar(`Invalid file type! Please upload an ${type} file.`, "error"); e.target.value = ""; return; } } state.themeSubmitted = false; const assetType = key === "turnSignal" || key === "turnSignalBlindspot" ? "turn_signals" : key === "distanceIcons" ? "distance_icons" : key === "homeButton" || key === "settingsButton" ? "icons" : key === "steeringWheel" ? "steering_wheel" : type === "audio" ? "sounds" : null; if (assetType) { state.selectedThemeSources[assetType] = null; } if (key === "turnSignal" && state.turnSignalType === "Sequential") { fileStore.sequentialFiles.push(...files); state.sequentialImages.push(...Array.from(files).map(file => file.name)); state.imageFileNames.turnSignal = ""; } else { const file = files[0]; const store = type === "image" ? fileStore.images : fileStore.sounds; const nameState = type === "image" ? state.imageFileNames : state.soundFileNames; if (subkey) { store[key][subkey] = file; nameState[key][subkey] = file ? file.name : ""; } else { store[key] = file; nameState[key] = file ? file.name : ""; } } }; const handleColorChange = (e, key) => { const hex = e.target.value; state.colors[key] = { red: parseInt(hex.slice(1, 3), 16), green: parseInt(hex.slice(3, 5), 16), blue: parseInt(hex.slice(5, 7), 16), alpha: 255, }; state.themeSubmitted = false; state.selectedThemeSources.colors = null; }; const validateTurnSignalLength = (e) => { const value = parseInt(e.target.value, 10); const clampedValue = isNaN(value) ? 25 : Math.max(25, Math.min(1000, value)); state.turnSignalLength = clampedValue; e.target.value = clampedValue; state.selectedThemeSources.turn_signals = null; }; const toggleTurnSignalType = (type) => { state.turnSignalType = type; state.themeSubmitted = false; state.selectedThemeSources.turn_signals = null; state.sequentialImages = []; fileStore.sequentialFiles = []; fileStore.images.turnSignal = undefined; state.imageFileNames.turnSignal = ""; }; const setTurnSignalStyle = (style) => { state.turnSignalStyle = style; state.themeSubmitted = false; state.selectedThemeSources.turn_signals = null; }; const getFormData = () => { const formData = new FormData(); formData.append("themeName", state.themeName); formData.append("saveChecklist", JSON.stringify(state.saveChecklist)); formData.append("selectedThemeSources", JSON.stringify(state.selectedThemeSources)); if (state.discordUsername && state.discordUsername.trim()) { formData.append("discordUsername", state.discordUsername.trim()); } if (state.saveChecklist.colors) { formData.append("colors", JSON.stringify(state.colors)); } if (state.saveChecklist.turn_signals) { formData.append("turnSignalStyle", state.turnSignalStyle); formData.append("turnSignalType", state.turnSignalType); formData.append("turnSignalLength", state.turnSignalLength); if (state.turnSignalType === "Sequential") { fileStore.sequentialFiles.forEach((file, index) => { formData.append(`turn_signal_${index + 1}`, file); }); } if (fileStore.images.turnSignal) { formData.append("turnSignal", fileStore.images.turnSignal); } if (fileStore.images.turnSignalBlindspot) { formData.append("turnSignalBlindspot", fileStore.images.turnSignalBlindspot); } } if (state.saveChecklist.icons) { ["homeButton", "settingsButton"].forEach(key => { if (fileStore.images[key]) formData.append(key, fileStore.images[key]); }); } if (state.saveChecklist.steering_wheel) { if (fileStore.images.steeringWheel) formData.append("steeringWheel", fileStore.images.steeringWheel); } if (state.saveChecklist.distance_icons) { for (const subkey in fileStore.images.distanceIcons) { const file = fileStore.images.distanceIcons[subkey]; if (file) formData.append(`distanceIcons_${subkey}`, file); } } if (state.saveChecklist.sounds) { for (const key in fileStore.sounds) { if (fileStore.sounds[key]) formData.append(key, fileStore.sounds[key]); } } return formData; }; const performApiAction = async (url, options, successMessage, errorMessage) => { try { const response = await fetch(url, options); const result = await readResponsePayload(response); const message = response.ok ? (result.message || successMessage) : getResponseMessage(response, result, errorMessage); if (message) { showSnackbar(message, response.ok ? "success" : "error"); } return { ok: response.ok, result }; } catch(e) { showSnackbar(`An error occurred: ${errorMessage} (${e.message})`, "error"); return { ok: false }; } }; const persistThemeSelection = async (theme, assetType) => { const paramKey = PARAM_KEY_BY_ASSET_TYPE[assetType]; if (!paramKey) { return true; } let value = theme?.path || ""; if (theme?.type === "stock") { value = "stock"; } else if (theme?.type === "stock_none") { value = "none"; } else if (assetType === "steering_wheel" && value) { value = value.replace(/\.[^.]+$/, ""); } const customThemesResult = await performApiAction( "/api/params", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: "CustomThemes", value: "true" }), }, "", "Failed to enable custom themes." ); if (!customThemesResult.ok) { return false; } const result = await performApiAction( "/api/params", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: paramKey, value }), }, "", `Failed to update ${assetType.replace("_", " ")}.` ); return result.ok; }; const applyTheme = async () => { if (!Object.values(state.saveChecklist).some(v => v)) { return showSnackbar("Please select at least one component to apply!", "error"); } state.showApplyConfirmModal = false; state.isApplying = true; await performApiAction( "/api/themes/apply", { method: "POST", body: getFormData() }, "Theme applied successfully!", "Failed to apply theme." ); state.isApplying = false; }; const saveTheme = async () => { if (!state.themeName.trim()) { return showSnackbar("Please enter a theme name!", "error"); } if (!Object.values(state.saveChecklist).some(v => v)) { return showSnackbar("Please select at least one component to save!", "error"); } state.showSaveConfirmModal = false; state.isSaving = true; const { ok } = await performApiAction("/api/themes", { method: "POST", body: getFormData() }, "Theme saved successfully!", "Failed to save theme."); if (ok) { state.themeName = ""; } state.isSaving = false; }; const submitTheme = async () => { if (!state.discordUsername.trim()) { return showSnackbar("Discord username is required for submission...", "error"); } if (!state.themeName.trim()) { return showSnackbar("Please enter a theme name...", "error"); } if (!Object.values(state.saveChecklist).some(v => v)) { return showSnackbar("Please select at least one component to submit!", "error"); } state.isSubmitting = true; state.showSubmitConfirmation = false; const { ok } = await performApiAction("/api/themes/submit", { method: "POST", body: getFormData() }, "Theme submitted successfully!", "Failed to submit theme."); if (ok) { state.themeSubmitted = true; state.themeName = ""; } state.isSubmitting = false; }; const confirmApply = () => { state.saveChecklist = { colors: false, distance_icons: false, icons: false, sounds: false, turn_signals: false, steering_wheel: false, }; state.showApplyConfirmModal = true; }; const confirmSave = () => { state.saveChecklist = { colors: false, distance_icons: false, icons: false, sounds: false, turn_signals: false, steering_wheel: false, }; state.showSaveConfirmModal = true; }; const confirmSubmit = () => { if (state.themeSubmitted) { return showSnackbar("This theme has already been submitted...", "error"); } if (isThemeAssetEmpty()) { return showSnackbar("Cannot submit an empty theme...", "error"); } state.saveChecklist = { colors: false, distance_icons: false, icons: false, sounds: false, turn_signals: false, steering_wheel: false, }; state.showSubmitConfirmation = true; }; const manageThemes = async () => { const response = await fetch("/api/themes/list"); const data = await readResponsePayload(response); if (!response.ok) { showSnackbar(getResponseMessage(response, data, "Failed to load themes."), "error"); return; } state.themes = (data.themes || []).map(t => ({ ...t, localHasColors: !!t.hasColors, localHasDistanceIcons: !!t.hasDistanceIcons, localHasIcons: !!t.hasIcons, localHasSounds: !!t.hasSounds, localHasSteeringWheel: !!t.hasSteeringWheel, localHasTurnSignals: !!t.hasTurnSignals, })); mergeDownloadablesIntoThemes(); sortThemesAlphabetically(); state.showManageThemesModal = true; }; const getVisibleThemesForActiveTab = () => { const builtinTheme = BUILTIN_THEME_OPTIONS[state.activeTab]; const visibleThemes = state.themes.filter(theme => { if (state.activeTab === "steering_wheel") { return theme.type === "steering_wheel" || (theme.type === "holiday" && theme.hasSteeringWheel); } const key = `has${state.activeTab.charAt(0).toUpperCase() + state.activeTab.slice(1).replace(/_([a-z])/g, g => g[1].toUpperCase())}`; return theme[key]; }); return builtinTheme ? [builtinTheme, ...visibleThemes] : visibleThemes; }; const mergeDownloadablesIntoThemes = () => { const byName = new Map(); const norm = (s) => (s || "") .toString() .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .replace(/\s+/g, " ") .trim(); for (const t of state.themes) byName.set(norm(t.name), t); const tabToFlag = { colors: "hasColors", distance_icons: "hasDistanceIcons", icons: "hasIcons", sounds: "hasSounds", steering_wheel: "hasSteeringWheel", turn_signals: "hasTurnSignals" }; const addIfMissing = (bucketKey, tabName) => { const list = state.downloadable[bucketKey] || []; for (const name of list) { const key = norm(name); if (!byName.has(key)) { const t = { name, type: "holiday", path: encodeURIComponent(name), is_user_created: false }; for (const flag of Object.values(tabToFlag)) t[flag] = false; t[tabToFlag[tabName]] = true; state.themes.push(t); byName.set(key, t); } else { const t = byName.get(key); t[tabToFlag[tabName]] = true; } } }; addIfMissing("colors", "colors"); addIfMissing("distance_icons", "distance_icons"); addIfMissing("icons", "icons"); addIfMissing("sounds", "sounds"); addIfMissing("wheels", "steering_wheel"); addIfMissing("signals", "turn_signals"); }; const refreshThemesAndDownloadables = async () => { const r = await fetch("/api/themes/list"); const data = await r.json(); state.themes = (data.themes || []).map(t => ({ ...t, localHasColors: !!t.hasColors, localHasDistanceIcons: !!t.hasDistanceIcons, localHasIcons: !!t.hasIcons, localHasSounds: !!t.hasSounds, localHasSteeringWheel: !!t.hasSteeringWheel, localHasTurnSignals: !!t.hasTurnSignals, })); mergeDownloadablesIntoThemes(); sortThemesAlphabetically(); await fetchDownloadables(); }; const pollDownloadProgress = async () => { return new Promise((resolve) => { const timer = setInterval(async () => { try { const r = await fetch("/api/params_memory?key=ThemeDownloadProgress"); const txt = (await r.text()) || ""; if (!txt) return; if (/Downloaded!/i.test(txt)) { clearInterval(timer); resolve({ ok: true, status: "done" }); } else if (/failed|cancelled/i.test(txt)) { clearInterval(timer); resolve({ ok: false, status: txt }); } } catch {} }, 1000); }); }; const startAssetDownload = async (tab, displayName) => { if (!displayName) return; state.isLoadingAsset = true; try { const res = await fetch("/api/themes/download_asset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ component: tab, name: displayName }) }); if (!res.ok) { showSnackbar("Unable to start download.", "error"); state.isLoadingAsset = false; return; } showSnackbar(`Downloading ${tab.replace('_', ' ')} for the "${displayName}" theme...`); const result = await pollDownloadProgress(); if (!result.ok) { showSnackbar(`Download ${result.status}`, "error"); state.isLoadingAsset = false; return; } await refreshThemesAndDownloadables(); const norm = s => (s || "").toString().trim().toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim(); const theme = state.themes.find(t => norm(t.name) === norm(displayName)); if (theme) { const tabToLocalFlag = { colors: "localHasColors", distance_icons: "localHasDistanceIcons", icons: "localHasIcons", sounds: "localHasSounds", steering_wheel: "localHasSteeringWheel", turn_signals: "localHasTurnSignals" }; const f = tabToLocalFlag[tab]; if (f) theme[f] = true; } showSnackbar(`Downloaded ${tab.replace('_', ' ')} for "${displayName}".`); } catch (e) { showSnackbar("Unexpected error during download.", "error"); } finally { state.isLoadingAsset = false; } }; const loadThemeAsset = async (theme, assetType) => { if (state.isLoadingAsset) return; state.isLoadingAsset = true; clearAssetType(assetType); try { const response = await fetch(`/api/themes/load/${theme.path}?type=${theme.type}`); const data = await readResponsePayload(response); if (!response.ok) { throw new Error(getResponseMessage(response, data, "Failed to load theme asset.")); } const fetchAndStoreFile = async (assetPath, key, subkey = null, type = "image") => { if (!assetPath) return; const url = `/api/themes/asset/${theme.path}/${assetPath}?type=${theme.type}`; const filename = assetPath.split("/").pop(); const file = await fetchFileAsFile(url, filename, `Failed to load ${assetType.replace("_", " ")} from "${theme.name}".`); const store = type === "image" ? fileStore.images : fileStore.sounds; if (subkey) { store[key][subkey] = file; } else { store[key] = file; } }; if (assetType === "colors" && data.colors) { state.colors = data.colors; } if (assetType === "distance_icons" && data.images.distanceIcons) { for (const [subkey, asset] of Object.entries(data.images.distanceIcons)) { state.imageFileNames.distanceIcons[subkey] = theme.name; await fetchAndStoreFile(asset.path, "distanceIcons", subkey, "image"); } } if (assetType === "icons" && (data.images.homeButton || data.images.settingsButton)) { if (data.images.homeButton) { state.imageFileNames.homeButton = theme.name; await fetchAndStoreFile(data.images.homeButton.path, "homeButton", null, "image"); } if (data.images.settingsButton) { state.imageFileNames.settingsButton = theme.name; await fetchAndStoreFile(data.images.settingsButton.path, "settingsButton", null, "image"); } } if (assetType === "steering_wheel" && data.images.steeringWheel?.path) { const url = `/api/themes/asset/${theme.path}/${data.images.steeringWheel.path}?type=${theme.type}`; const filename = data.images.steeringWheel.filename || theme.path; fileStore.images.steeringWheel = await fetchFileAsFile(url, filename, `Failed to load steering wheel from "${theme.name}".`); state.imageFileNames.steeringWheel = theme.name; } if (assetType === "sounds" && Object.keys(data.sounds).length) { for (const [key, asset] of Object.entries(data.sounds)) { state.soundFileNames[key] = theme.name; await fetchAndStoreFile(asset.path, key, null, "audio"); } } if (assetType === "turn_signals") { state.turnSignalLength = data.turnSignalLength; state.turnSignalType = data.turnSignalType; state.turnSignalStyle = data.turnSignalStyle || "Static"; state.imageFileNames.turnSignal = data.images.turnSignal?.filename ? theme.name : ""; state.imageFileNames.turnSignalBlindspot = data.images.turnSignalBlindspot?.filename ? theme.name : ""; if (data.images.turnSignal) { await fetchAndStoreFile(data.images.turnSignal.path, "turnSignal", null, "image"); } if (data.images.turnSignalBlindspot) { await fetchAndStoreFile(data.images.turnSignalBlindspot.path, "turnSignalBlindspot", null, "image"); } state.sequentialImages = data.sequentialImages || []; fileStore.sequentialFiles = []; if (data.sequentialImages && data.sequentialImages.length > 0) { state.imageFileNames.turnSignal = theme.name; for (const img of data.sequentialImages) { const url = `/api/themes/asset/${theme.path}/signals/${img}?type=${theme.type}`; fileStore.sequentialFiles.push(await fetchFileAsFile(url, img, `Failed to load turn signal frames from "${theme.name}".`)); } } } state.selectedThemeSources[assetType] = { path: theme.path, type: theme.type, }; showSnackbar(`Loaded ${assetType.replace("_", " ")} from "${theme.name}"!`); } catch (err) { console.error("Failed to load theme asset:", err); showSnackbar(err.message || "Failed to load theme asset.", "error"); } finally { state.isLoadingAsset = false; } }; const loadBuiltinTurnSignals = () => { clearAssetType("turn_signals"); state.themeSubmitted = false; state.turnSignalLength = 100; state.turnSignalType = "Single Image"; state.turnSignalStyle = "Traditional"; state.imageFileNames.turnSignal = "None"; state.imageFileNames.turnSignalBlindspot = ""; state.selectedThemeSources.turn_signals = { path: "__stock_none__", type: "stock_none", }; showSnackbar('Loaded stock turn signals.'); }; const loadSelectableThemeAsset = async (theme, assetType) => { const persisted = await persistThemeSelection(theme, assetType); if (!persisted) return; if (theme?.type === "stock_none" && assetType === "turn_signals") { loadBuiltinTurnSignals(); return; } await loadThemeAsset(theme, assetType); }; const confirmDelete = (theme) => { state.themeToDelete = theme; state.showDeleteConfirmModal = true; }; const deleteThemeAndRestoreDownloadables = (themeName) => { const norm = (s) => (s || "") .toString() .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .replace(/\s+/g, " ") .trim(); const flagToBucket = { hasColors: "colors", hasDistanceIcons: "distance_icons", hasIcons: "icons", hasSounds: "sounds", hasSteeringWheel: "wheels", hasTurnSignals: "signals" }; const index = state.themes.findIndex((t) => norm(t.name) === norm(themeName)); if (index === -1) return; const theme = state.themes[index]; state.themes.splice(index, 1); const ensureInBucket = (bucket, name) => { if (!state.downloadable[bucket]) state.downloadable[bucket] = []; const exists = state.downloadable[bucket].some((n) => norm(n) === norm(name)); if (!exists) state.downloadable[bucket].push(name); }; for (const [flag, bucket] of Object.entries(flagToBucket)) { if (theme[flag]) ensureInBucket(bucket, theme.name); } }; const deleteTheme = async () => { const theme = state.themeToDelete; if (!theme) return; const component = state.activeTab === "steering_wheel" ? "" : `&component=${state.activeTab === "turn_signals" ? "signals" : state.activeTab}`; const response = await fetch(`/api/themes/delete/${theme.path}?type=${theme.type}${component}`, { method: "DELETE" }); const result = await readResponsePayload(response); if (response.ok) { showSnackbar(getResponseMessage(response, result, "Theme deleted."), "success"); deleteThemeAndRestoreDownloadables(theme.name); manageThemes(); } else { showSnackbar(getResponseMessage(response, result, "Failed to delete theme."), "error"); } state.showDeleteConfirmModal = false; state.themeToDelete = null; }; return html`
Select components to apply:
${() => hasDistanceIcons() && html``} ${() => hasIcons() && html``} ${() => hasSounds() && html``} ${() => hasSteeringWheel() && html``} ${() => hasTurnSignals() && html``}Select components to save:
${() => hasDistanceIcons() && html``} ${() => hasIcons() && html``} ${() => hasSounds() && html``} ${() => hasSteeringWheel() && html``} ${() => hasTurnSignals() && html``}Submit your theme for everyone to use!
Please enter your Discord username below so we can contact you if needed.
state.discordUsername = e.target.value}" />Select components to submit:
${() => hasDistanceIcons() && html``} ${() => hasSounds() && html``} ${() => hasSteeringWheel() && html``} ${() => hasTurnSignals() && html``}