🎨 Refactor and optimize GamemasterExplorer.vue by restructuring template, improving component usage, and enhancing code readability and maintainability

This commit is contained in:
2026-01-29 04:13:27 +00:00
parent 6fb5190582
commit 3d0b848699

View File

@@ -9,21 +9,20 @@
<!-- Error State --> <!-- Error State -->
<div v-else-if="error" class="error-state"> <div v-else-if="error" class="error-state">
<p class="error-message">{{ error }}</p> <p class="error-message">{{ error }}</p>
<button @click="loadStatus" class="btn-retry">Retry</button> <button @click="filesState.loadStatus" class="btn-retry">Retry</button>
</div> </div>
<!-- No Files State --> <!-- No Files State -->
<div v-else-if="!hasFiles" class="no-files-state"> <div v-else-if="!hasFiles" class="no-files-state">
<h2>No Gamemaster Files Available</h2> <h2>No Gamemaster Files Available</h2>
<p>Please process gamemaster data first in the Gamemaster Manager.</p> <p>Please process gamemaster data first in the Gamemaster Manager.</p>
<router-link to="/gamemaster" class="btn-primary" <router-link to="/gamemaster" class="btn-primary">
>Go to Gamemaster Manager</router-link Go to Gamemaster Manager
> </router-link>
</div> </div>
<!-- Main Explorer Interface --> <!-- Main Explorer Interface -->
<div v-else class="explorer-container"> <div v-else class="explorer-container">
<!-- Header -->
<header class="explorer-header"> <header class="explorer-header">
<div class="header-left"> <div class="header-left">
<router-link to="/" class="back-button"> Back Home</router-link> <router-link to="/" class="back-button"> Back Home</router-link>
@@ -48,297 +47,195 @@
</div> </div>
</header> </header>
<!-- Keyboard Shortcuts Help -->
<div v-if="showHelp" class="help-panel"> <div v-if="showHelp" class="help-panel">
<h3>Keyboard Shortcuts</h3> <h3>Keyboard Shortcuts</h3>
<ul> <ul>
<li><kbd>Ctrl</kbd>+<kbd>F</kbd> - Focus search</li> <li><kbd>Ctrl</kbd>+<kbd>F</kbd> - Focus search</li>
<li><kbd>Ctrl</kbd>+<kbd>C</kbd> - Copy selected lines</li> <li><kbd>Ctrl</kbd>+<kbd>C</kbd> - Copy selected lines</li>
</template> <li><kbd>Ctrl</kbd>+<kbd>G</kbd> - Go to next search result</li>
<li>
<kbd>Shift</kbd>+<kbd>Ctrl</kbd>+<kbd>G</kbd> - Go to previous result
</li>
<li><kbd>Escape</kbd> - Clear selection / Close dialogs</li>
</ul>
</div>
<script setup> <div v-if="showSettings" class="settings-panel">
import { ref, computed, nextTick, watch } from 'vue'; <h3>Settings</h3>
import { GamemasterClient } from '@/utilities/gamemaster-client.js'; <label>
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts.js'; <input type="checkbox" v-model="preferences.lineWrap" />
import { useUrlState } from '@/composables/useUrlState.js'; Line Wrap
import { useClipboard } from '@/composables/useClipboard.js'; </label>
import { useGamemasterFiles } from '@/composables/useGamemasterFiles.js'; <label>
import { useGamemasterSearch } from '@/composables/useGamemasterSearch.js'; <input type="checkbox" v-model="preferences.darkMode" />
import { useLineSelection } from '@/composables/useLineSelection.js'; Dark Mode
import useJsonFilter from '@/composables/useJsonFilter.js'; </label>
import SearchBar from '@/components/gamemaster/SearchBar.vue'; <label>
import FileSelector from '@/components/gamemaster/FileSelector.vue'; <input type="checkbox" v-model="preferences.showLineNumbers" />
import JsonViewer from '@/components/gamemaster/JsonViewer.vue'; Show Line Numbers
import FilterPanel from '@/components/gamemaster/FilterPanel.vue'; </label>
import ActionToolbar from '@/components/gamemaster/ActionToolbar.vue'; <label>
Performance:
<select v-model="preferences.performanceMode">
<option value="auto">Auto</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</label>
</div>
const client = new GamemasterClient(); <FileSelector :files-state="filesState" />
const filesState = useGamemasterFiles(client);
const {
selectedFile,
fileContent,
fileLines,
displayLines,
isLoading,
fileError,
preferences,
hasFiles,
fileTooLarge
} = filesState;
const searchState = useGamemasterSearch(fileLines, displayLines); <SearchBar
const selectionState = useLineSelection(displayLines, fileContent, selectedFile); v-if="fileContent"
const filterState = useJsonFilter(); :file-lines="fileLines"
:display-lines="displayLines"
:search-state="searchState"
/>
const showHelp = ref(false); <FilterPanel v-if="fileContent" :data="filterData" :filter-state="filterState" />
const showSettings = ref(false);
const operationProgress = ref({
active: false,
percent: 0,
message: '',
complete: false
});
const loading = computed(() => isLoading.value); <div v-if="operationProgress.active" class="progress-bar-container">
const error = computed(() => fileError.value); <div class="progress-bar" :class="{ complete: operationProgress.complete }">
<div
class="progress-fill"
:style="{ width: `${operationProgress.percent}%` }"
></div>
</div>
<div class="progress-text">{{ operationProgress.message }}</div>
</div>
const lineHeight = computed(() => (globalThis.innerWidth < 768 ? 24 : 20)); <JsonViewer
const highlightConfig = computed(() => ({ v-if="fileContent"
theme: preferences.value.darkMode ? 'github-dark' : 'github', :display-lines="displayLines"
language: 'json' :file-content="fileContent"
})); :selected-file="selectedFile"
:file-too-large="fileTooLarge"
:preferences="preferences"
:search-results="searchResults"
:current-result-index="currentResultIndex"
:highlight-config="highlightConfig"
:line-height="lineHeight"
:selection-state="selectionState"
/>
const filterData = computed(() => { <ActionToolbar
if (!fileContent.value) return []; v-if="fileContent"
try { :display-lines="displayLines"
const parsed = JSON.parse(fileContent.value); :file-content="fileContent"
return Array.isArray(parsed) ? parsed : []; :selected-file="selectedFile"
} catch { :selection-state="selectionState"
return []; />
}
});
useUrlState({ <div v-if="clipboard.copied.value" class="toast success">
file: selectedFile, Copied to clipboard!
search: searchState.searchQuery, </div>
filter: filterState.filterProperty, <div v-if="clipboard.error.value" class="toast error">
value: filterState.filterValue {{ clipboard.error.value }}
}); </div>
</div>
</div>
</template>
const clipboard = useClipboard(); <script setup>
import { ref, computed, watch } from 'vue';
import { GamemasterClient } from '@/utilities/gamemaster-client.js';
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts.js';
import { useUrlState } from '@/composables/useUrlState.js';
import { useClipboard } from '@/composables/useClipboard.js';
import { useGamemasterFiles } from '@/composables/useGamemasterFiles.js';
import { useGamemasterSearch } from '@/composables/useGamemasterSearch.js';
import { useLineSelection } from '@/composables/useLineSelection.js';
import useJsonFilter from '@/composables/useJsonFilter.js';
import SearchBar from '@/components/gamemaster/SearchBar.vue';
import FileSelector from '@/components/gamemaster/FileSelector.vue';
import JsonViewer from '@/components/gamemaster/JsonViewer.vue';
import FilterPanel from '@/components/gamemaster/FilterPanel.vue';
import ActionToolbar from '@/components/gamemaster/ActionToolbar.vue';
useKeyboardShortcuts({ const client = new GamemasterClient();
'ctrl+f': () => { const filesState = useGamemasterFiles(client);
showHelp.value = false; const {
showSettings.value = false; selectedFile,
}, fileContent,
'ctrl+c': selectionState.copySelected, fileLines,
'ctrl+g': searchState.goToNextResult, displayLines,
'shift+ctrl+g': searchState.goToPrevResult, isLoading,
escape: () => { fileError,
if (showHelp.value || showSettings.value) { preferences,
showHelp.value = false; hasFiles,
showSettings.value = false; fileTooLarge
} else if (selectionState.selectedLines.value.size > 0) { } = filesState;
selectionState.selectedLines.value.clear();
} else if (searchState.searchQuery.value) {
searchState.clearSearch();
}
}
});
watch(selectedFile, () => { const searchState = useGamemasterSearch(fileLines, displayLines);
if (selectionState.selectedLines.value.size > 0) { const { searchResults, currentResultIndex } = searchState;
selectionState.clearSelection(); const selectionState = useLineSelection(displayLines, fileContent, selectedFile);
} const filterState = useJsonFilter();
});
</script> const showHelp = ref(false);
0, const showSettings = ref(false);
Math.min(lineIndex + 1000, fileLines.value.length) const operationProgress = ref({
); active: false,
displayLines.value = newLinesToDisplay.map((content, index) => ({ percent: 0,
lineNumber: index + 1, message: '',
content, complete: false
hasMatch: searchResults.value.includes(index) });
}));
const loading = computed(() => isLoading.value);
const error = computed(() => fileError.value);
const lineHeight = computed(() => (globalThis.innerWidth < 768 ? 24 : 20));
const highlightConfig = computed(() => ({
theme: preferences.value.darkMode ? 'github-dark' : 'github',
language: 'json'
}));
const filterData = computed(() => {
if (!fileContent.value) return [];
try {
const parsed = JSON.parse(fileContent.value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
} }
});
// Use virtual scroller API if available (for large files) useUrlState({
if (virtualScroller.value && displayLines.value.length > 1000) { file: selectedFile,
nextTick(() => { search: searchState.searchQuery,
// Calculate the scroll position to center the item filter: filterState.filterProperty,
const scroller = virtualScroller.value; value: filterState.filterValue
const itemHeight = lineHeight.value; });
const containerHeight = scroller.$el.clientHeight;
// Calculate scroll position to center the item const clipboard = useClipboard();
const targetScrollTop =
lineIndex * itemHeight - containerHeight / 2 + itemHeight / 2;
// Use the scroller's internal scrollTop useKeyboardShortcuts({
scroller.$el.scrollTop = Math.max(0, targetScrollTop); 'ctrl+f': () => {
}); showHelp.value = false;
} else { showSettings.value = false;
// Fallback for non-virtual scrolled content },
const attemptScroll = (attempt = 0) => { 'ctrl+c': selectionState.copySelected,
const lineElement = document.querySelector(`[data-line="${lineNumber}"]`); 'ctrl+g': searchState.goToNextResult,
'shift+ctrl+g': searchState.goToPrevResult,
if (lineElement) { escape: () => {
// Scroll only within the container, not the whole page if (showHelp.value || showSettings.value) {
const container = lineElement.closest('.scroller, .lines-container'); showHelp.value = false;
if (container) { showSettings.value = false;
// Get element's position relative to the container } else if (selectionState.selectedLines.value.size > 0) {
const elementOffsetTop = lineElement.offsetTop; selectionState.selectedLines.value.clear();
const containerHeight = container.clientHeight; } else if (searchState.searchQuery.value) {
const elementHeight = lineElement.offsetHeight; searchState.clearSearch();
// Calculate scroll position to center element in container
const scrollTo =
elementOffsetTop - containerHeight / 2 + elementHeight / 2;
container.scrollTo({ top: scrollTo, behavior: 'smooth' });
}
return true;
} else if (attempt < 3) {
// Virtual scroller may not have rendered yet, try again
setTimeout(() => attemptScroll(attempt + 1), 50);
return false;
} else {
// Fallback: scroll container to approximate position
const container = document.querySelector('.scroller, .lines-container');
if (container) {
const estimatedScroll =
(lineIndex / fileLines.value.length) *
(container.scrollHeight - container.clientHeight);
container.scrollTop = estimatedScroll;
}
return false;
}
};
attemptScroll();
}
}
function applyHistoryItem(item) {
searchQuery.value = item;
onSearchInput();
}
function onFilterChange() {
// Implement property filtering
// This is complex - needs to parse JSON and filter based on property paths
console.log(
'Filter change:',
filterProperty.value,
filterValue.value,
filterMode.value
);
}
function toggleLineSelection(lineNumber, event) {
if (event.shiftKey && selectedLines.value.size > 0) {
// Range selection
const lastSelected = Math.max(...selectedLines.value);
const start = Math.min(lastSelected, lineNumber);
const end = Math.max(lastSelected, lineNumber);
for (let i = start; i <= end; i++) {
selectedLines.value.add(i);
} }
} else if (event.ctrlKey || event.metaKey) {
// Toggle individual line
if (selectedLines.value.has(lineNumber)) {
selectedLines.value.delete(lineNumber);
} else {
selectedLines.value.add(lineNumber);
}
} else {
// Single selection
selectedLines.value.clear();
selectedLines.value.add(lineNumber);
} }
} });
async function copySelected() { watch(selectedFile, () => {
if (selectedLines.value.size === 0) return; if (selectionState.selectedLines.value.size > 0) {
selectionState.clearSelection();
const lines = [...selectedLines.value].sort((a, b) => a - b); }
const content = lines });
.map(lineNum => { </script>
return (
displayLines.value.find(l => l.lineNumber === lineNum)?.content || ''
);
})
.join('\n');
await clipboard.copyToClipboard(content);
}
async function copyAll() {
await clipboard.copyToClipboard(fileContent.value);
}
function exportSelected() {
if (selectedLines.value.size === 0) return;
const lines = [...selectedLines.value].sort((a, b) => a - b);
const content = lines
.map(lineNum => {
return (
displayLines.value.find(l => l.lineNumber === lineNum)?.content || ''
);
})
.join('\n');
downloadFile(content, `${selectedFile.value}-selected-${Date.now()}.json`);
}
function exportAll() {
downloadFile(fileContent.value, `${selectedFile.value}-${Date.now()}.json`);
}
function downloadFile(content, filename) {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function shareUrl() {
const url = globalThis.location.href;
await clipboard.copyToClipboard(url);
}
function formatSize(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function getFileType(filename) {
if (filename.includes('AllForms') || filename.includes('allForms'))
return 'allForms';
if (filename.includes('moves')) return 'moves';
if (filename.includes('pokemon')) return 'pokemon';
if (filename.includes('raw')) return 'raw';
return '';
}
function formatFileName(filename) {
if (filename.includes('AllForms') || filename.includes('allForms'))
return 'Pokemon All Forms';
if (filename.includes('moves')) return 'Moves';
if (filename.includes('pokemon')) return 'Pokemon';
if (filename.includes('raw')) return 'Raw Gamemaster';
return filename;
}
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {