diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9289520..44107e7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -564,7 +564,8 @@ function App() { const success = await api('/set_config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(merged) + body: JSON.stringify(merged), + keepalive: true, }) if (success) { refreshConfig() @@ -817,7 +818,13 @@ function App() { )}>
{view === 'dashboard' && (() => { diff --git a/frontend/src/components/views/AutoloadView.jsx b/frontend/src/components/views/AutoloadView.jsx index 0a29956..2b3f085 100644 --- a/frontend/src/components/views/AutoloadView.jsx +++ b/frontend/src/components/views/AutoloadView.jsx @@ -1,9 +1,11 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' -import { RefreshCw, ArrowLeft, ArrowRight, Activity, Zap, ChevronUp, ChevronDown, Trash2, CheckCircle2 } from 'lucide-react' -import { cn, isPS5, isSystemPayload } from '../../utils/helpers' +import { RefreshCw, ArrowLeft, ArrowRight, Activity, Zap, ChevronUp, ChevronDown, Trash2, Globe } from 'lucide-react' +import { cn, isPS5, isSystemPayload, parsePayloadName } from '../../utils/helpers' import { useTranslation } from 'react-i18next' import PayloadName from '../ui/PayloadName' +import PayloadIcon from '../ui/PayloadIcon' import Modal from '../ui/Modal' +import { useRepository } from '../../contexts/RepositoryContext' const AutoloadView = ({ items = [], @@ -16,6 +18,9 @@ const AutoloadView = ({ setDelayModal: setDelayModalProp, }) => { const { t } = useTranslation() + const repoContext = useRepository() + const getRepoEntry = repoContext?.getRepoEntry + const findRepoEntryById = repoContext?.findRepoEntryById const [subView, setSubView] = useState('list') const [enabled, setEnabled] = useState(false) const [autoloadList, setAutoloadList] = useState([]) @@ -23,51 +28,103 @@ const AutoloadView = ({ const showDelayModal = delayModalProp !== undefined ? delayModalProp : localDelayModal const setShowDelayModal = setDelayModalProp || setLocalDelayModal const [customDelay, setCustomDelay] = useState('') - const [saved, setSaved] = useState(false) - const [saving, setSaving] = useState(false) const [isInitialized, setIsInitialized] = useState(false) + const stateRef = useRef({ enabled: false, list: [] }) + stateRef.current = { enabled, list: autoloadList } + + const isSavingRef = useRef(false) + const pendingSaveRef = useRef(false) const lastSyncedRef = useRef('') // Load initial config useEffect(() => { - if (config) { + if (config && !isInitialized) { const en = config.AUTOLOAD_ENABLED === true || config.AUTOLOAD_ENABLED === "true" const listStr = config.AUTOLOAD_LIST || '' setEnabled(en) - setAutoloadList(listStr.split(',').filter(Boolean)) + const parsedList = listStr.split(',').filter(Boolean) + setAutoloadList(parsedList) + stateRef.current = { enabled: en, list: parsedList } lastSyncedRef.current = `${en}:${listStr}` setIsInitialized(true) } - }, [config]) + }, [config, isInitialized]) - // Debounced Auto-Save - useEffect(() => { - if (!isInitialized) return + // Immediate save coordinator with in-flight queueing + const saveState = useCallback(async (targetList, targetEnabled) => { + const listToSave = targetList !== undefined ? targetList : stateRef.current.list + const enabledToSave = targetEnabled !== undefined ? targetEnabled : stateRef.current.enabled - const currentState = `${enabled}:${autoloadList.join(',')}` - if (currentState === lastSyncedRef.current) return + const finalList = listToSave.map((p) => (p === 'DELAY' ? '!1000' : p)) + const finalStr = finalList.join(',') + const syncKey = `${enabledToSave}:${finalStr}` - const timer = setTimeout(async () => { - const shouldEnable = enabled - const finalList = autoloadList.map((p) => (p === 'DELAY' ? '!1000' : p)) - const finalStr = finalList.join(',') + if (syncKey === lastSyncedRef.current) return - setSaving(true) + if (isSavingRef.current) { + pendingSaveRef.current = true + return + } + + isSavingRef.current = true + pendingSaveRef.current = false + + try { const success = await onSaveConfig({ - AUTOLOAD_ENABLED: shouldEnable, + AUTOLOAD_ENABLED: enabledToSave, AUTOLOAD_LIST: finalStr, }) if (success) { - lastSyncedRef.current = `${shouldEnable}:${finalStr}` - setSaved(true) - setTimeout(() => setSaved(false), 2000) + lastSyncedRef.current = syncKey } - setSaving(false) - }, 1500) + } finally { + isSavingRef.current = false + if (pendingSaveRef.current) { + saveState() + } + } + }, [onSaveConfig]) - return () => clearTimeout(timer) - }, [autoloadList, enabled, isInitialized, onSaveConfig]) + // Safety flush on component unmount and window unload/pagehide + useEffect(() => { + const flushSave = () => { + const { list, enabled: en } = stateRef.current + const finalList = list.map((p) => (p === 'DELAY' ? '!1000' : p)) + const finalStr = finalList.join(',') + const syncKey = `${en}:${finalStr}` + if (syncKey !== lastSyncedRef.current) { + lastSyncedRef.current = syncKey + try { + const body = JSON.stringify({ + ...config, + AUTOLOAD_ENABLED: en, + AUTOLOAD_LIST: finalStr, + }) + if (navigator.sendBeacon) { + navigator.sendBeacon('/set_config', body) + } else { + fetch('/set_config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }) + } + } catch { + /* ignore flush errors */ + } + } + } + + window.addEventListener('beforeunload', flushSave) + window.addEventListener('pagehide', flushSave) + return () => { + flushSave() + window.removeEventListener('beforeunload', flushSave) + window.removeEventListener('pagehide', flushSave) + } + }, [config]) const availableSlots = useMemo(() => { if (items && items.length > 0) { @@ -99,8 +156,87 @@ const AutoloadView = ({ [items] ) + const idCounts = useMemo(() => { + const counts = {} + if (items) { + for (const it of items) { + if (it?.kind === 'repo' && it?.id) { + counts[it.id] = (counts[it.id] || 0) + 1 + } + } + } + return counts + }, [items]) + + const resolveCardMeta = useCallback( + (keyOrItem) => { + if (!keyOrItem) return null + const item = typeof keyOrItem === 'object' ? keyOrItem : getItemInfo(keyOrItem) + const rawKey = typeof keyOrItem === 'string' ? keyOrItem : (keyOrItem.slot || keyOrItem.filename || '') + + const parsed = parsePayloadName(item?.filename || rawKey) + if (parsed.isDelay) { + return { + isDelay: true, + displayName: t("autoload.delay_item", "Delay ({{seconds}}s)", { seconds: parsed.delaySeconds || 1 }), + version: null, + author: null, + icon: null, + sourceName: null, + iconName: 'Delay', + } + } + + const itemObj = item || { + slot: rawKey, + filename: rawKey.split('/').pop(), + name: rawKey.split('/').pop(), + kind: 'local', + } + + let displayName = itemObj.name || itemObj.filename || '' + let version = itemObj.version || null + let author = null + let icon = null + let sourceName = null + + if (itemObj.kind === 'local') { + displayName = itemObj.name || parsed.displayName + version = itemObj.version || parsed.version + sourceName = t("app.dashboard.other_source_badge", "Other Source") + } else { + const repoMeta = (getRepoEntry && itemObj.source_url && itemObj.id) + ? getRepoEntry(itemObj.source_url, itemObj.id) + : (findRepoEntryById && itemObj.id ? findRepoEntryById(itemObj.id) : null) + displayName = repoMeta?.name || itemObj.name || itemObj.id || itemObj.filename + version = itemObj.version || repoMeta?.version || null + author = repoMeta?.author || itemObj.author || null + icon = repoMeta?.icon || null + const hasDup = Boolean(itemObj.id && idCounts[itemObj.id] > 1) + if (hasDup || config?.MULTI_SOURCES_ENABLED) { + sourceName = itemObj.source_name || repoMeta?.source_name || null + } + } + + return { + isDelay: false, + slot: itemObj.slot || rawKey, + filename: itemObj.filename || rawKey, + displayName, + version, + author, + icon, + sourceName, + iconName: itemObj.filename || displayName || 'Payload', + } + }, + [getItemInfo, getRepoEntry, findRepoEntryById, idCounts, config, t] + ) + const handleToggle = (val) => { setEnabled(val) + stateRef.current = { enabled: val, list: autoloadList } + saveState(autoloadList, val) } const addPayload = (slotKey) => { @@ -112,93 +248,145 @@ const AutoloadView = ({ return } } - setAutoloadList([...autoloadList, slotKey]) + const nextList = [...autoloadList, slotKey] + setAutoloadList(nextList) + stateRef.current = { enabled, list: nextList } + saveState(nextList, enabled) setSubView('list') } const addDelay = (ms) => { - setAutoloadList([...autoloadList, `!${ms}`]) + const nextList = [...autoloadList, `!${ms}`] + setAutoloadList(nextList) + stateRef.current = { enabled, list: nextList } + saveState(nextList, enabled) setShowDelayModal(false) setSubView('list') } + const removePayload = (index) => { + const nextList = autoloadList.filter((_, idx) => idx !== index) + setAutoloadList(nextList) + stateRef.current = { enabled, list: nextList } + saveState(nextList, enabled) + } + const moveUp = (index) => { if (index === 0) return - const newList = [...autoloadList] - ;[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]] - setAutoloadList(newList) + const nextList = [...autoloadList] + ;[nextList[index - 1], nextList[index]] = [nextList[index], nextList[index - 1]] + setAutoloadList(nextList) + stateRef.current = { enabled, list: nextList } + saveState(nextList, enabled) } const moveDown = (index) => { if (index === autoloadList.length - 1) return - const newList = [...autoloadList] - ;[newList[index + 1], newList[index]] = [newList[index], newList[index + 1]] - setAutoloadList(newList) + const nextList = [...autoloadList] + ;[nextList[index + 1], nextList[index]] = [nextList[index], nextList[index + 1]] + setAutoloadList(nextList) + stateRef.current = { enabled, list: nextList } + saveState(nextList, enabled) } const renderAvailable = () => ( -
-
+
+
{subView === 'add' && ( )} -

{t("autoload.available_title", "Available Payloads")}

+

+ {t("autoload.available_title", "Available Payloads")} +

-
-
- {availableSlots.map((item) => { - const isKstuff = (item.id || item.filename || item.slot || '').toLowerCase().includes('kstuff') - const hasKstuff = autoloadList.some((x) => x.toLowerCase().includes('kstuff')) - const isBlocked = isKstuff && hasKstuff +
+
+
+ {availableSlots.map((item) => { + const meta = resolveCardMeta(item) + const isKstuff = (item.id || item.filename || item.slot || '').toLowerCase().includes('kstuff') + const hasKstuff = autoloadList.some((x) => x.toLowerCase().includes('kstuff')) + const isBlocked = isKstuff && hasKstuff - return ( + return ( + + ) + })} +
- ) - })} -
- -
-
-

{t("autoload.missing_payload", "Missing a payload?")}

- +
@@ -206,66 +394,115 @@ const AutoloadView = ({ ) const renderSequence = () => ( -
-
-
-

- {t("autoload.sequence_title_1", "Autoload")} {t("autoload.sequence_title_2", "Sequence")} -

-
- {saving ? ( -
- - {t("autoload.saving", "Saving Changes...")} -
- ) : saved ? ( -
- - {t("autoload.saved", "All Changes Saved")} -
- ) : null} -
-
+
+
+

+ {t("autoload.sequence_title_1", "Autoload")} {t("autoload.sequence_title_2", "Sequence")} +

-
-
- {autoloadList.map((p, i) => { - const item = getItemInfo(p) - return ( -
-
- {i + 1} +
+
+
+ {autoloadList.map((p, i) => { + const meta = resolveCardMeta(p) + return ( +
+
+
+
+ {meta?.isDelay ? ( +
+ +
+ ) : ( + + )} +
+ {i + 1} +
+
+
+ +
+ {meta?.version && ( + + {meta.version} + + )} + {meta?.author && ( +
+ {meta.author} +
+ )} +
+ {meta?.sourceName && ( +
+ + + {meta.sourceName} + +
+ )} +
+
+
+ + + +
+
+
-
- -
-
- - - -
-
- ) - })} + ) + })} +
{autoloadList.length === 0 && (
@@ -315,50 +552,66 @@ const AutoloadView = ({ } return ( -
-
-
+
+
+
+ {renderAvailable()} {renderSequence()}
-
- {renderAvailable()} +
+ {subView === 'list' ? renderSequence() : renderAvailable()}
- setShowDelayModal(false)}> -
-

- {t("autoload.add_delay_modal_desc", "Add a pause between payloads to allow background services to initialize properly.")} -

-
- {[1, 2, 3, 5, 10, 15].map((s) => ( + setShowDelayModal(false)} + footer={ + + } + > +
+
+ {[1, 3, 5].map(s => ( ))}
-
- setCustomDelay(e.target.value)} - className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-zinc-600 focus:outline-none focus:border-ps-blue" - /> - + +
+

{t("autoload.delay_modal.custom_delay_label", "Custom Delay (ms)")}

+
+ setCustomDelay(e.target.value)} + className="flex-1 bg-white/5 border border-white/10 rounded-2xl p-4 md:p-5 text-white font-mono text-xl md:text-2xl focus:border-ps-blue outline-none transition-all" + /> + +