Files
memory-infrastructure-palace/code/websites/pokedex.online/src/views/GamemasterExplorer.vue

1247 lines
28 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>{{ currentResultIndex + 1 }} / {{ searchResults.length }}</span>
<button
@click="goToPrevResult"
class="btn-nav"
:disabled="searchResults.length === 0"
>
</button>
<button
@click="goToNextResult"
class="btn-nav"
:disabled="searchResults.length === 0"
>
</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
:class="[
'line',
{ selected: selectedLines.has(item.lineNumber) },
{ 'highlight-match': item.hasMatch }
]"
@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"
:class="[
'line',
{ selected: selectedLines.has(line.lineNumber) },
{ 'highlight-match': line.hasMatch }
]"
@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 } 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'
}));
// 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();
}
}
});
// 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
searchQuery.value = '';
searchResults.value = [];
currentResultIndex.value = 0;
filterProperty.value = '';
filterValue.value = '';
selectedLines.value.clear();
jsonPaths.value = [];
loadFile();
}
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];
const lineElement = document.querySelector(`[data-line="${lineIndex + 1}"]`);
lineElement?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
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.3);
}
.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;
}
.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>