1351 lines
31 KiB
Vue
1351 lines
31 KiB
Vue
<template>
|
||
<div class="gamemaster-explorer">
|
||
<!-- Loading State -->
|
||
<div v-if="loading" class="loading-state">
|
||
<div class="spinner"></div>
|
||
<p>Loading Gamemaster Explorer...</p>
|
||
</div>
|
||
|
||
<!-- Error State -->
|
||
<div v-else-if="error" class="error-state">
|
||
<p class="error-message">{{ error }}</p>
|
||
<button @click="loadStatus" class="btn-retry">Retry</button>
|
||
</div>
|
||
|
||
<!-- No Files State -->
|
||
<div v-else-if="!hasFiles" class="no-files-state">
|
||
<h2>No Gamemaster Files Available</h2>
|
||
<p>Please process gamemaster data first in the Gamemaster Manager.</p>
|
||
<router-link to="/gamemaster" class="btn-primary"
|
||
>Go to Gamemaster Manager</router-link
|
||
>
|
||
</div>
|
||
|
||
<!-- Main Explorer Interface -->
|
||
<div v-else class="explorer-container">
|
||
<!-- Header -->
|
||
<header class="explorer-header">
|
||
<router-link to="/" class="back-button">← Back Home</router-link>
|
||
<h1>🔍 Gamemaster Explorer</h1>
|
||
<div class="header-controls">
|
||
<button
|
||
@click="showHelp = !showHelp"
|
||
class="btn-icon"
|
||
title="Keyboard Shortcuts (?)"
|
||
>
|
||
<span v-if="!showHelp">?</span>
|
||
<span v-else>✕</span>
|
||
</button>
|
||
<button
|
||
@click="showSettings = !showSettings"
|
||
class="btn-icon"
|
||
title="Settings"
|
||
>
|
||
⚙️
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Keyboard Shortcuts Help -->
|
||
<div v-if="showHelp" class="help-panel">
|
||
<h3>Keyboard Shortcuts</h3>
|
||
<ul>
|
||
<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>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>
|
||
|
||
<!-- Settings Panel -->
|
||
<div v-if="showSettings" class="settings-panel">
|
||
<h3>Settings</h3>
|
||
<label>
|
||
<input type="checkbox" v-model="preferences.lineWrap" />
|
||
Line Wrap
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" v-model="preferences.darkMode" />
|
||
Dark Mode
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" v-model="preferences.showLineNumbers" />
|
||
Show Line Numbers
|
||
</label>
|
||
<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>
|
||
|
||
<!-- File Selector -->
|
||
<div class="file-selector">
|
||
<label for="file-select">Select File:</label>
|
||
<select id="file-select" v-model="selectedFile" @change="onFileChange">
|
||
<option value="">-- Choose a file --</option>
|
||
<option
|
||
v-for="file in status.available"
|
||
:key="file.filename"
|
||
:value="getFileType(file.filename)"
|
||
>
|
||
{{ formatFileName(file.filename) }} ({{ formatSize(file.size) }})
|
||
</option>
|
||
</select>
|
||
<span v-if="fileContent" class="file-info"
|
||
>{{ fileLines.length.toLocaleString() }} lines</span
|
||
>
|
||
</div>
|
||
|
||
<!-- Search Bar -->
|
||
<div v-if="fileContent" class="search-bar">
|
||
<div class="search-input-wrapper">
|
||
<input
|
||
ref="searchInput"
|
||
type="text"
|
||
v-model="searchQuery"
|
||
@input="onSearchInput"
|
||
placeholder="Search in file... (Ctrl+F)"
|
||
class="search-input"
|
||
/>
|
||
<button
|
||
v-if="searchQuery"
|
||
@click="clearSearch"
|
||
class="btn-clear"
|
||
title="Clear search"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="searchResults.length > 0" class="search-results">
|
||
<span :title="`Line ${searchResults[currentResultIndex] + 1}`">
|
||
{{ currentResultIndex + 1 }} / {{ searchResults.length }} (Line
|
||
{{ searchResults[currentResultIndex] + 1 }})
|
||
</span>
|
||
<button
|
||
@click="goToPrevResult"
|
||
class="btn-nav"
|
||
:disabled="searchResults.length === 0"
|
||
title="Previous result (Shift+Ctrl+G)"
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
@click="goToNextResult"
|
||
class="btn-nav"
|
||
:disabled="searchResults.length === 0"
|
||
title="Next result (Ctrl+G)"
|
||
>
|
||
↓
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
v-if="searchHistory.history.value.length > 0"
|
||
class="search-history"
|
||
>
|
||
<button
|
||
v-for="(item, index) in searchHistory.history.value"
|
||
:key="index"
|
||
@click="applyHistoryItem(item)"
|
||
class="history-item"
|
||
>
|
||
{{ item }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Property Filter -->
|
||
<div v-if="fileContent && jsonPaths.length > 0" class="property-filter">
|
||
<label for="property-select">Filter by Property:</label>
|
||
<select
|
||
id="property-select"
|
||
v-model="filterProperty"
|
||
@change="onFilterChange"
|
||
>
|
||
<option value="">All Properties</option>
|
||
<option v-for="path in jsonPaths" :key="path" :value="path">
|
||
{{ path }}
|
||
</option>
|
||
</select>
|
||
<input
|
||
v-if="filterProperty"
|
||
type="text"
|
||
v-model="filterValue"
|
||
@input="onFilterChange"
|
||
placeholder="Enter value..."
|
||
class="filter-value-input"
|
||
/>
|
||
<label v-if="filterProperty" class="filter-mode">
|
||
<input
|
||
type="radio"
|
||
value="OR"
|
||
v-model="filterMode"
|
||
@change="onFilterChange"
|
||
/>
|
||
OR
|
||
<input
|
||
type="radio"
|
||
value="AND"
|
||
v-model="filterMode"
|
||
@change="onFilterChange"
|
||
/>
|
||
AND
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Progress Bar (for long operations) -->
|
||
<div v-if="operationProgress.active" class="progress-bar-container">
|
||
<div
|
||
class="progress-bar"
|
||
:class="{ complete: operationProgress.complete }"
|
||
>
|
||
<div
|
||
class="progress-fill"
|
||
:style="{ width: operationProgress.percent + '%' }"
|
||
></div>
|
||
</div>
|
||
<span class="progress-text">{{ operationProgress.message }}</span>
|
||
</div>
|
||
|
||
<!-- JSON Content Viewer -->
|
||
<div
|
||
v-if="fileContent"
|
||
class="content-viewer"
|
||
:class="{
|
||
'dark-mode': preferences.darkMode,
|
||
'line-wrap': preferences.lineWrap
|
||
}"
|
||
>
|
||
<!-- Virtual Scroller for large files -->
|
||
<RecycleScroller
|
||
v-if="displayLines.length > 1000"
|
||
class="scroller"
|
||
:items="displayLines"
|
||
:item-size="lineHeight"
|
||
key-field="lineNumber"
|
||
>
|
||
<template #default="{ item }">
|
||
<div
|
||
:data-line="item.lineNumber"
|
||
:class="[
|
||
'line',
|
||
{ selected: selectedLines.has(item.lineNumber) },
|
||
{ 'highlight-match': item.hasMatch },
|
||
{
|
||
'current-result':
|
||
item.lineNumber === searchResults[currentResultIndex] + 1
|
||
}
|
||
]"
|
||
@click="toggleLineSelection(item.lineNumber, $event)"
|
||
>
|
||
<span v-if="preferences.showLineNumbers" class="line-number">{{
|
||
item.lineNumber
|
||
}}</span>
|
||
<pre
|
||
v-highlight="highlightConfig"
|
||
><code>{{ item.content }}</code></pre>
|
||
</div>
|
||
</template>
|
||
</RecycleScroller>
|
||
|
||
<!-- Regular render for smaller files -->
|
||
<div v-else class="lines-container">
|
||
<div
|
||
v-for="line in displayLines"
|
||
:key="line.lineNumber"
|
||
:data-line="line.lineNumber"
|
||
:class="[
|
||
'line',
|
||
{ selected: selectedLines.has(line.lineNumber) },
|
||
{ 'highlight-match': line.hasMatch },
|
||
{
|
||
'current-result':
|
||
line.lineNumber === searchResults[currentResultIndex] + 1
|
||
}
|
||
]"
|
||
@click="toggleLineSelection(line.lineNumber, $event)"
|
||
>
|
||
<span v-if="preferences.showLineNumbers" class="line-number">{{
|
||
line.lineNumber
|
||
}}</span>
|
||
<pre
|
||
v-highlight="highlightConfig"
|
||
><code>{{ line.content }}</code></pre>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Too Large Warning -->
|
||
<div v-if="fileTooLarge" class="warning-banner">
|
||
⚠️ File exceeds 10,000 lines. Showing first 10,000 lines only. Use
|
||
search or filters to find specific content.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Action Bar -->
|
||
<div v-if="fileContent" class="action-bar">
|
||
<button
|
||
@click="copySelected"
|
||
:disabled="selectedLines.size === 0"
|
||
class="btn-action"
|
||
>
|
||
📋 Copy Selected ({{ selectedLines.size }} lines)
|
||
</button>
|
||
<button @click="copyAll" class="btn-action">📋 Copy All</button>
|
||
<button
|
||
@click="exportSelected"
|
||
:disabled="selectedLines.size === 0"
|
||
class="btn-action"
|
||
>
|
||
💾 Export Selected
|
||
</button>
|
||
<button @click="exportAll" class="btn-action">💾 Export All</button>
|
||
<button @click="shareUrl" class="btn-action">🔗 Share Link</button>
|
||
</div>
|
||
|
||
<!-- Toast Notifications -->
|
||
<div v-if="clipboard.copied.value" class="toast success">
|
||
✓ Copied to clipboard!
|
||
</div>
|
||
<div v-if="clipboard.error.value" class="toast error">
|
||
✗ {{ clipboard.error.value }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, nextTick, watch } from 'vue';
|
||
import { RecycleScroller } from 'vue-virtual-scroller';
|
||
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 {
|
||
useLocalStorage,
|
||
useSearchHistory
|
||
} from '@/composables/useLocalStorage.js';
|
||
import {
|
||
perfMonitor,
|
||
debounce,
|
||
getDevicePerformance
|
||
} from '@/utilities/performance-utils.js';
|
||
import {
|
||
extractJsonPaths,
|
||
extractJsonPathsLazy
|
||
} from '@/utilities/json-utils.js';
|
||
|
||
// Core state
|
||
const loading = ref(true);
|
||
const error = ref(null);
|
||
const status = ref({});
|
||
const selectedFile = ref('');
|
||
const fileContent = ref('');
|
||
const fileLines = ref([]);
|
||
const displayLines = ref([]);
|
||
|
||
// UI state
|
||
const showHelp = ref(false);
|
||
const showSettings = ref(false);
|
||
const searchInput = ref(null);
|
||
|
||
// Search state
|
||
const searchQuery = ref('');
|
||
const searchResults = ref([]);
|
||
const currentResultIndex = ref(0);
|
||
const searchHistory = useSearchHistory();
|
||
|
||
// Filter state
|
||
const filterProperty = ref('');
|
||
const filterValue = ref('');
|
||
const filterMode = ref('OR');
|
||
const jsonPaths = ref([]);
|
||
|
||
// Selection state
|
||
const selectedLines = ref(new Set());
|
||
|
||
// Progress state
|
||
const operationProgress = ref({
|
||
active: false,
|
||
percent: 0,
|
||
message: '',
|
||
complete: false
|
||
});
|
||
|
||
// Preferences (persisted)
|
||
const preferences = useLocalStorage('gamemaster-explorer-prefs', {
|
||
darkMode: false,
|
||
lineWrap: false,
|
||
showLineNumbers: true,
|
||
performanceMode: 'auto',
|
||
lastFile: ''
|
||
});
|
||
|
||
// Composables
|
||
const clipboard = useClipboard();
|
||
const client = new GamemasterClient();
|
||
|
||
// Computed
|
||
const hasFiles = computed(() => status.value.totalFiles > 0);
|
||
const fileTooLarge = computed(() => fileLines.value.length > 10000);
|
||
const lineHeight = computed(() => {
|
||
// Mobile-first: 24px on mobile, 20px on desktop
|
||
return globalThis.innerWidth < 768 ? 24 : 20;
|
||
});
|
||
const highlightConfig = computed(() => ({
|
||
theme: preferences.value.darkMode ? 'github-dark' : 'github',
|
||
language: 'json'
|
||
}));
|
||
|
||
// Helper to get highlighted content for search results
|
||
function getHighlightedContent(lineContent) {
|
||
if (!searchQuery.value.trim()) return lineContent;
|
||
|
||
const searchTerm = searchQuery.value;
|
||
const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
||
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||
return lineContent.replaceAll(regex, '<mark>$1</mark>');
|
||
}
|
||
|
||
// URL state sync
|
||
const urlStateRefs = {
|
||
file: selectedFile,
|
||
search: searchQuery,
|
||
filter: filterProperty,
|
||
value: filterValue
|
||
};
|
||
useUrlState(urlStateRefs);
|
||
|
||
// Keyboard shortcuts
|
||
useKeyboardShortcuts({
|
||
'ctrl+f': () => {
|
||
showHelp.value = false;
|
||
showSettings.value = false;
|
||
nextTick(() => searchInput.value?.focus());
|
||
},
|
||
'ctrl+c': copySelected,
|
||
'ctrl+g': goToNextResult,
|
||
'shift+ctrl+g': goToPrevResult,
|
||
escape: () => {
|
||
if (showHelp.value || showSettings.value) {
|
||
showHelp.value = false;
|
||
showSettings.value = false;
|
||
} else if (selectedLines.value.size > 0) {
|
||
selectedLines.value.clear();
|
||
} else if (searchQuery.value) {
|
||
clearSearch();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Watch selectedFile changes to load the file
|
||
watch(selectedFile, newFile => {
|
||
if (newFile) {
|
||
loadFile();
|
||
}
|
||
});
|
||
|
||
// Methods
|
||
async function loadStatus() {
|
||
try {
|
||
loading.value = true;
|
||
error.value = null;
|
||
status.value = await perfMonitor('Load Status', async () => {
|
||
return await client.getStatus();
|
||
});
|
||
|
||
// Auto-load last file if set
|
||
if (preferences.value.lastFile && !selectedFile.value) {
|
||
selectedFile.value = preferences.value.lastFile;
|
||
await loadFile();
|
||
}
|
||
} catch (err) {
|
||
error.value = err.message;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadFile() {
|
||
if (!selectedFile.value) return;
|
||
|
||
try {
|
||
loading.value = true;
|
||
error.value = null;
|
||
|
||
// Check if raw file is too large (> 50MB)
|
||
if (selectedFile.value === 'raw') {
|
||
const rawFile = status.value.available?.find(f =>
|
||
f.filename.includes('raw')
|
||
);
|
||
if (rawFile && rawFile.size > 50 * 1024 * 1024) {
|
||
error.value =
|
||
'⚠️ Raw gamemaster file is very large (' +
|
||
formatSize(rawFile.size) +
|
||
'). It may be slow to load. Try a specific file type instead (Pokemon, All Forms, or Moves).';
|
||
loading.value = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
let data;
|
||
switch (selectedFile.value) {
|
||
case 'pokemon':
|
||
data = await perfMonitor('Load Pokemon', () => client.getPokemon());
|
||
break;
|
||
case 'allForms':
|
||
data = await perfMonitor('Load All Forms', () => client.getAllForms());
|
||
break;
|
||
case 'moves':
|
||
data = await perfMonitor('Load Moves', () => client.getMoves());
|
||
break;
|
||
case 'raw':
|
||
data = await perfMonitor('Load Raw', () => client.getRaw());
|
||
break;
|
||
default:
|
||
throw new Error('Unknown file type');
|
||
}
|
||
|
||
fileContent.value = JSON.stringify(data, null, 2);
|
||
fileLines.value = fileContent.value.split('\n');
|
||
|
||
// Limit to 10K lines
|
||
const linesToDisplay = fileLines.value.slice(0, 10000);
|
||
displayLines.value = linesToDisplay.map((content, index) => ({
|
||
lineNumber: index + 1,
|
||
content,
|
||
hasMatch: false
|
||
}));
|
||
|
||
// Extract JSON paths for filtering (in background)
|
||
jsonPaths.value = extractJsonPaths(data);
|
||
extractJsonPathsLazy(data, paths => {
|
||
jsonPaths.value = paths;
|
||
});
|
||
|
||
// Save last file preference
|
||
preferences.value.lastFile = selectedFile.value;
|
||
|
||
loading.value = false;
|
||
} catch (err) {
|
||
error.value = err.message;
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function onFileChange() {
|
||
// Clear state when file selection changes
|
||
searchQuery.value = '';
|
||
searchResults.value = [];
|
||
currentResultIndex.value = 0;
|
||
filterProperty.value = '';
|
||
filterValue.value = '';
|
||
selectedLines.value.clear();
|
||
jsonPaths.value = [];
|
||
// File loading is handled by the watch() on selectedFile
|
||
}
|
||
|
||
const onSearchInput = debounce(async () => {
|
||
if (!searchQuery.value.trim()) {
|
||
searchResults.value = [];
|
||
displayLines.value.forEach(line => (line.hasMatch = false));
|
||
return;
|
||
}
|
||
|
||
// Show progress for long searches
|
||
const searchTerm = searchQuery.value.toLowerCase();
|
||
operationProgress.value = {
|
||
active: true,
|
||
percent: 0,
|
||
message: 'Searching...',
|
||
complete: false
|
||
};
|
||
|
||
await perfMonitor('Search', async () => {
|
||
const results = [];
|
||
const device = getDevicePerformance();
|
||
const chunkSize = device.recommendedChunkSize;
|
||
|
||
// Process in chunks
|
||
for (let i = 0; i < displayLines.value.length; i += chunkSize) {
|
||
const chunk = displayLines.value.slice(i, i + chunkSize);
|
||
|
||
chunk.forEach((line, idx) => {
|
||
const actualIndex = i + idx;
|
||
const matches = line.content.toLowerCase().includes(searchTerm);
|
||
displayLines.value[actualIndex].hasMatch = matches;
|
||
|
||
if (matches) {
|
||
results.push(actualIndex);
|
||
}
|
||
});
|
||
|
||
// Update progress
|
||
operationProgress.value.percent = Math.min(
|
||
((i + chunkSize) / displayLines.value.length) * 100,
|
||
100
|
||
);
|
||
|
||
// Yield to browser
|
||
if (i % (chunkSize * 3) === 0) {
|
||
await new Promise(resolve => setTimeout(resolve, 0));
|
||
}
|
||
}
|
||
|
||
searchResults.value = results;
|
||
currentResultIndex.value = 0;
|
||
|
||
// Complete animation
|
||
operationProgress.value.complete = true;
|
||
setTimeout(() => {
|
||
operationProgress.value.active = false;
|
||
}, 500);
|
||
});
|
||
|
||
// Add to search history
|
||
searchHistory.addToHistory(searchQuery.value);
|
||
}, 300);
|
||
|
||
function clearSearch() {
|
||
searchQuery.value = '';
|
||
searchResults.value = [];
|
||
displayLines.value.forEach(line => (line.hasMatch = false));
|
||
}
|
||
|
||
function goToNextResult() {
|
||
if (searchResults.value.length === 0) return;
|
||
currentResultIndex.value =
|
||
(currentResultIndex.value + 1) % searchResults.value.length;
|
||
scrollToResult();
|
||
}
|
||
|
||
function goToPrevResult() {
|
||
if (searchResults.value.length === 0) return;
|
||
currentResultIndex.value =
|
||
(currentResultIndex.value - 1 + searchResults.value.length) %
|
||
searchResults.value.length;
|
||
scrollToResult();
|
||
}
|
||
|
||
function scrollToResult() {
|
||
const lineIndex = searchResults.value[currentResultIndex.value];
|
||
if (lineIndex === undefined) return;
|
||
|
||
const lineNumber = lineIndex + 1; // Convert to 1-based line number
|
||
|
||
// Retry logic for virtual scroller rendering
|
||
const attemptScroll = (attempt = 0) => {
|
||
const lineElement = document.querySelector(`[data-line="${lineNumber}"]`);
|
||
|
||
if (lineElement) {
|
||
// Element is rendered, scroll it into view
|
||
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
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 / displayLines.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() {
|
||
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');
|
||
|
||
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('pokemon') && filename.includes('AllForms'))
|
||
return 'allForms';
|
||
if (filename.includes('pokemon')) return 'pokemon';
|
||
if (filename.includes('moves')) return 'moves';
|
||
if (filename.includes('raw')) return 'raw';
|
||
return '';
|
||
}
|
||
|
||
function formatFileName(filename) {
|
||
if (filename.includes('pokemon') && filename.includes('AllForms'))
|
||
return 'Pokemon All Forms';
|
||
if (filename.includes('pokemon')) return 'Pokemon';
|
||
if (filename.includes('moves')) return 'Moves';
|
||
if (filename.includes('raw')) return 'Raw Gamemaster';
|
||
return filename;
|
||
}
|
||
|
||
// Lifecycle
|
||
onMounted(() => {
|
||
loadStatus();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.gamemaster-explorer {
|
||
min-height: 100vh;
|
||
padding: 2rem 1rem;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
}
|
||
|
||
.explorer-container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 2rem;
|
||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
/* Loading & Error States */
|
||
.loading-state,
|
||
.error-state,
|
||
.no-files-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 60vh;
|
||
text-align: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.error-message {
|
||
color: #c0392b;
|
||
font-weight: 600;
|
||
max-width: 500px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.spinner {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 4px solid #f3f3f3;
|
||
border-top: 4px solid #667eea;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
/* Header */
|
||
.explorer-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.explorer-header h1 {
|
||
margin: 0;
|
||
font-size: 2.5rem;
|
||
color: #333;
|
||
}
|
||
|
||
.back-button {
|
||
padding: 0.5rem 1rem;
|
||
background: #667eea;
|
||
color: white;
|
||
text-decoration: none;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
transition: all 0.3s ease;
|
||
display: inline-block;
|
||
}
|
||
|
||
.back-button:hover {
|
||
background: #5568d3;
|
||
transform: translateX(-2px);
|
||
}
|
||
|
||
.header-controls {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.btn-icon {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 8px;
|
||
border: 1px solid #e9ecef;
|
||
background: #f8f9fa;
|
||
cursor: pointer;
|
||
font-size: 1.25rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.btn-icon:hover {
|
||
background: #e9ecef;
|
||
border-color: #dee2e6;
|
||
}
|
||
|
||
/* Help & Settings Panels */
|
||
.help-panel,
|
||
.settings-panel {
|
||
background: #f8f9fa;
|
||
border: 1px solid #e9ecef;
|
||
border-radius: 8px;
|
||
padding: 1.5rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.help-panel h3,
|
||
.settings-panel h3 {
|
||
margin-top: 0;
|
||
color: #495057;
|
||
}
|
||
|
||
.help-panel ul {
|
||
list-style: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.help-panel li {
|
||
padding: 0.5rem 0;
|
||
}
|
||
|
||
.help-panel kbd {
|
||
background: #fff;
|
||
border: 1px solid #ccc;
|
||
border-radius: 4px;
|
||
padding: 2px 6px;
|
||
font-family: monospace;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.settings-panel label {
|
||
display: block;
|
||
margin: 0.75rem 0;
|
||
}
|
||
|
||
/* File Selector */
|
||
.file-selector {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.file-selector select {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
padding: 0.5rem;
|
||
font-size: 1rem;
|
||
border: 1px solid #ccc;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.file-info {
|
||
color: #666;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
/* Search Bar */
|
||
.search-bar {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.search-input-wrapper {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
padding: 0.75rem;
|
||
padding-right: 2.5rem;
|
||
font-size: 1rem;
|
||
border: 2px solid #ccc;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.search-input:focus {
|
||
outline: none;
|
||
border-color: #3498db;
|
||
}
|
||
|
||
.btn-clear {
|
||
position: absolute;
|
||
right: 0.5rem;
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.25rem;
|
||
cursor: pointer;
|
||
color: #999;
|
||
}
|
||
|
||
.search-results {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.btn-nav {
|
||
padding: 0.25rem 0.75rem;
|
||
border: 1px solid #ccc;
|
||
background: white;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn-nav:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.search-history {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.history-item {
|
||
padding: 0.25rem 0.75rem;
|
||
background: #f0f0f0;
|
||
border: 1px solid #ccc;
|
||
border-radius: 16px;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.history-item:hover {
|
||
background: #e0e0e0;
|
||
}
|
||
|
||
/* Property Filter */
|
||
.property-filter {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin-bottom: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.property-filter select,
|
||
.filter-value-input {
|
||
padding: 0.5rem;
|
||
border: 1px solid #ccc;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.filter-mode {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
/* Progress Bar */
|
||
.progress-bar-container {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.progress-bar {
|
||
height: 8px;
|
||
background: #f0f0f0;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: #3498db;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.progress-bar.complete .progress-fill {
|
||
background: #2ecc71;
|
||
animation: blink 0.5s ease-in-out;
|
||
}
|
||
|
||
@keyframes blink {
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
.progress-text {
|
||
font-size: 0.9rem;
|
||
color: #666;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
/* Content Viewer */
|
||
.content-viewer {
|
||
background: #f8f8f8;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
margin-bottom: 1rem;
|
||
max-height: 70vh;
|
||
overflow: auto;
|
||
}
|
||
|
||
.content-viewer.dark-mode {
|
||
background: #1e1e1e;
|
||
color: #d4d4d4;
|
||
border-color: #404040;
|
||
}
|
||
|
||
.scroller {
|
||
height: 70vh;
|
||
}
|
||
|
||
.lines-container {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.line {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
padding: 0.25rem 0.5rem;
|
||
cursor: pointer;
|
||
min-height: 20px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.line {
|
||
min-height: 24px;
|
||
}
|
||
}
|
||
|
||
.line:hover {
|
||
background: rgba(0, 123, 255, 0.1);
|
||
}
|
||
|
||
.line.selected {
|
||
background: rgba(0, 123, 255, 0.2);
|
||
}
|
||
|
||
.line.highlight-match {
|
||
background: rgba(255, 235, 59, 0.4);
|
||
border-left: 3px solid #ffc107;
|
||
}
|
||
|
||
.line.current-result {
|
||
background: rgba(255, 193, 7, 0.6) !important;
|
||
border-left: 5px solid #ff9800 !important;
|
||
box-shadow: inset 0 0 0 1px #ff9800;
|
||
}
|
||
|
||
.line-number {
|
||
width: 4rem;
|
||
text-align: right;
|
||
color: #999;
|
||
font-family: monospace;
|
||
margin-right: 1rem;
|
||
user-select: none;
|
||
}
|
||
|
||
.line pre {
|
||
margin: 0;
|
||
flex: 1;
|
||
white-space: pre;
|
||
overflow-x: auto;
|
||
background: transparent;
|
||
}
|
||
|
||
.line pre code {
|
||
background: transparent;
|
||
}
|
||
|
||
/* Light mode syntax highlighting */
|
||
.content-viewer:not(.dark-mode) .line pre code {
|
||
background-color: transparent;
|
||
color: #24292e;
|
||
}
|
||
|
||
/* Improve contrast for light mode - darken attribute names and values */
|
||
.content-viewer:not(.dark-mode) .hljs-attr {
|
||
color: #6f42c1;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.content-viewer:not(.dark-mode) .hljs-string {
|
||
color: #0a3622;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.content-viewer:not(.dark-mode) .hljs-number {
|
||
color: #005a9c;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.content-viewer:not(.dark-mode) .hljs-literal {
|
||
color: #d73a49;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Dark mode syntax highlighting */
|
||
.content-viewer.dark-mode .line pre code {
|
||
background-color: transparent;
|
||
color: #e1e4e8;
|
||
}
|
||
|
||
.line-wrap .line pre {
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.warning-banner {
|
||
background: #fff3cd;
|
||
border: 1px solid #ffc107;
|
||
color: #856404;
|
||
padding: 1rem;
|
||
text-align: center;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* Action Bar */
|
||
.action-bar {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.btn-action {
|
||
padding: 0.75rem 1rem;
|
||
background: #667eea;
|
||
color: #ffffff;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
min-height: 44px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.btn-action:hover {
|
||
background: #5568d3;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.btn-action:disabled {
|
||
background: #95a5a6;
|
||
color: #ffffff;
|
||
cursor: not-allowed;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
/* Toast */
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 2rem;
|
||
right: 2rem;
|
||
padding: 1rem 1.5rem;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
z-index: 1000;
|
||
animation: slideIn 0.3s ease-out;
|
||
}
|
||
|
||
.toast.success {
|
||
background: #229954;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.toast.error {
|
||
background: #c0392b;
|
||
color: #ffffff;
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 768px) {
|
||
.explorer-header h1 {
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.file-selector,
|
||
.property-filter {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.action-bar {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.btn-action {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
/* Primary button styles */
|
||
.btn-primary,
|
||
.btn-retry {
|
||
padding: 0.75rem 1.5rem;
|
||
background: #667eea;
|
||
color: white;
|
||
text-decoration: none;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
min-height: 44px;
|
||
transition: all 0.3s ease;
|
||
display: inline-block;
|
||
margin-right: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.btn-primary:hover:not(:disabled),
|
||
.btn-retry:hover:not(:disabled) {
|
||
background: #5568d3;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
</style>
|