🎨 Refactor and optimize GamemasterExplorer.vue by restructuring template, improving component usage, and enhancing code readability and maintainability
This commit is contained in:
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user