feat: add savegame export/import via JSON file and clipboard in executive profile modal
This commit is contained in:
parent
c566e294bc
commit
3d20bc315b
5 changed files with 621 additions and 21 deletions
|
|
@ -287,3 +287,216 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Profile & Savegame Management Styles */
|
||||||
|
.modal-profile-card {
|
||||||
|
max-width: 480px;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-modal-close {
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-modal-close:hover {
|
||||||
|
background: var(--bg-surface-sub);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-status-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-surface-sub);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--primary-blue-light);
|
||||||
|
color: var(--primary-blue);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pill-soft {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pill-soft.badge-online {
|
||||||
|
background: var(--emerald-light);
|
||||||
|
color: var(--emerald-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-sub-desc {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-sub-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-save-section {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-section-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-section-title iconify-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-section-sub {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.save-actions-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save-action {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-surface-sub);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save-action iconify-icon {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save-action:hover {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-color: var(--primary-blue);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload-label {
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-text-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg-surface-sub);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-textarea:focus {
|
||||||
|
border-color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-text-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,17 +141,38 @@
|
||||||
<!-- Toast Notifications Container -->
|
<!-- Toast Notifications Container -->
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
<!-- Authentication & Cloud Sync Modal -->
|
<!-- Authentication, Profile & Save Management Modal -->
|
||||||
<div id="auth-modal" class="modal-overlay">
|
<div id="auth-modal" class="modal-overlay">
|
||||||
<div class="modal-card">
|
<div class="modal-card modal-profile-card">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<iconify-icon icon="lucide:shield-check" class="modal-icon"></iconify-icon>
|
<iconify-icon icon="lucide:user-circle" class="modal-icon"></iconify-icon>
|
||||||
<div>
|
<div class="modal-header-text">
|
||||||
<h3 class="modal-title">Executive Cloud Terminal</h3>
|
<h3 class="modal-title" id="auth-modal-title">Executive Profile & Data</h3>
|
||||||
<p class="modal-subtitle">Save your production empire and sync across devices.</p>
|
<p class="modal-subtitle" id="auth-modal-subtitle">Manage cloud synchronization and local savegames.</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-close-auth-x" class="btn-icon-soft btn-modal-close" title="Close">
|
||||||
|
<iconify-icon icon="lucide:x"></iconify-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Info Banner (Status indicator) -->
|
||||||
|
<div id="profile-status-banner" class="profile-status-box">
|
||||||
|
<div class="profile-avatar-icon">
|
||||||
|
<iconify-icon icon="lucide:shield" id="profile-status-icon"></iconify-icon>
|
||||||
|
</div>
|
||||||
|
<div class="profile-info-content">
|
||||||
|
<div class="profile-name-row">
|
||||||
|
<strong id="profile-display-name">Executive Guest</strong>
|
||||||
|
<span id="profile-badge-pill" class="badge-pill-soft">Offline Guest</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-sub-desc" id="profile-sync-desc">
|
||||||
|
Progress is saved locally in this browser.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Forms (When Logged Out: Login/Register) -->
|
||||||
|
<div id="auth-guest-section" class="profile-sub-section">
|
||||||
<div class="input-field-group">
|
<div class="input-field-group">
|
||||||
<label>Executive Username</label>
|
<label>Executive Username</label>
|
||||||
<input type="text" id="auth-username" class="modal-input" placeholder="e.g. ChiefExecutive" autocomplete="username">
|
<input type="text" id="auth-username" class="modal-input" placeholder="e.g. ChiefExecutive" autocomplete="username">
|
||||||
|
|
@ -162,20 +183,81 @@
|
||||||
<input type="password" id="auth-password" class="modal-input" placeholder="••••••••" autocomplete="current-password">
|
<input type="password" id="auth-password" class="modal-input" placeholder="••••••••" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- In-modal Error Feedback (Replacing alert()) -->
|
|
||||||
<div id="auth-error-msg" class="modal-error-box" style="display: none;"></div>
|
|
||||||
|
|
||||||
<!-- Honeypot field for bot protection -->
|
<!-- Honeypot field for bot protection -->
|
||||||
<div style="display: none;" aria-hidden="true">
|
<div style="display: none;" aria-hidden="true">
|
||||||
<input type="text" id="auth-website-hp" name="website_hp" tabindex="-1" autocomplete="off">
|
<input type="text" id="auth-website-hp" name="website_hp" tabindex="-1" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions-row">
|
<div class="modal-actions-row">
|
||||||
<button id="btn-login-submit" class="btn-primary-corp">Login</button>
|
<button id="btn-login-submit" class="btn-primary-corp">
|
||||||
<button id="btn-register-submit" class="btn-corp-secondary">Register New</button>
|
<iconify-icon icon="lucide:log-in"></iconify-icon>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button id="btn-register-submit" class="btn-corp-secondary">
|
||||||
|
<iconify-icon icon="lucide:user-plus"></iconify-icon>
|
||||||
|
Register New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="btn-close-auth" class="btn-text-close">Continue as Guest</button>
|
<!-- Account Actions (When Logged In: Cloud Sync, Logout) -->
|
||||||
|
<div id="auth-user-section" class="profile-sub-section" style="display: none;">
|
||||||
|
<div class="modal-actions-row">
|
||||||
|
<button id="btn-cloud-sync-now" class="btn-corp-secondary">
|
||||||
|
<iconify-icon icon="lucide:refresh-cw"></iconify-icon>
|
||||||
|
Sync Cloud Now
|
||||||
|
</button>
|
||||||
|
<button id="btn-logout-submit" class="btn-corp-secondary text-warning">
|
||||||
|
<iconify-icon icon="lucide:log-out"></iconify-icon>
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- In-modal Error Feedback (Replacing alert()) -->
|
||||||
|
<div id="auth-error-msg" class="modal-error-box" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Save Management Section: Export & Import (Available for both Guests & Registered Users) -->
|
||||||
|
<div class="profile-save-section">
|
||||||
|
<div class="save-section-header">
|
||||||
|
<div class="save-section-title">
|
||||||
|
<iconify-icon icon="lucide:hard-drive"></iconify-icon>
|
||||||
|
<span>Savegame Management (Backup & Restore)</span>
|
||||||
|
</div>
|
||||||
|
<span class="save-section-sub">Export to JSON file or clipboard, or restore from a previous save.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="save-actions-grid">
|
||||||
|
<button id="btn-export-file" class="btn-save-action" title="Download backup savegame as a JSON file">
|
||||||
|
<iconify-icon icon="lucide:download"></iconify-icon>
|
||||||
|
<span>Export File</span>
|
||||||
|
</button>
|
||||||
|
<button id="btn-export-clipboard" class="btn-save-action" title="Copy raw save string to system clipboard">
|
||||||
|
<iconify-icon icon="lucide:copy"></iconify-icon>
|
||||||
|
<span>Copy Save</span>
|
||||||
|
</button>
|
||||||
|
<label for="import-file-input" class="btn-save-action btn-upload-label" title="Upload and load a backup savegame JSON file">
|
||||||
|
<iconify-icon icon="lucide:upload"></iconify-icon>
|
||||||
|
<span>Import File</span>
|
||||||
|
</label>
|
||||||
|
<input type="file" id="import-file-input" accept=".json,text/plain" style="display: none;">
|
||||||
|
<button id="btn-import-clipboard" class="btn-save-action" title="Paste raw save text to restore your enterprise">
|
||||||
|
<iconify-icon icon="lucide:clipboard-paste"></iconify-icon>
|
||||||
|
<span>Paste Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Textarea for manual paste import (hidden by default, toggled via Paste Save) -->
|
||||||
|
<div id="import-text-box" class="import-text-container" style="display: none;">
|
||||||
|
<textarea id="import-raw-input" class="modal-textarea" rows="3" placeholder="Paste your exported savegame JSON string here..."></textarea>
|
||||||
|
<div class="import-text-actions">
|
||||||
|
<button id="btn-import-text-apply" class="btn-primary-corp btn-sm">Load & Restore</button>
|
||||||
|
<button id="btn-import-text-cancel" class="btn-corp-secondary btn-sm">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-close-auth" class="btn-text-close">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,15 +140,43 @@ class ProductionApp {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth Modal Close & Submit
|
// Auth & Profile Modal Event Listeners
|
||||||
const closeAuth = document.getElementById('btn-close-auth');
|
const closeAuth = document.getElementById('btn-close-auth');
|
||||||
if (closeAuth) closeAuth.onclick = () => this.closeAuthModal();
|
if (closeAuth) closeAuth.onclick = () => this.closeAuthModal();
|
||||||
|
|
||||||
|
const closeAuthX = document.getElementById('btn-close-auth-x');
|
||||||
|
if (closeAuthX) closeAuthX.onclick = () => this.closeAuthModal();
|
||||||
|
|
||||||
const loginBtn = document.getElementById('btn-login-submit');
|
const loginBtn = document.getElementById('btn-login-submit');
|
||||||
if (loginBtn) loginBtn.onclick = () => this.handleLogin();
|
if (loginBtn) loginBtn.onclick = () => this.handleLogin();
|
||||||
|
|
||||||
const registerBtn = document.getElementById('btn-register-submit');
|
const registerBtn = document.getElementById('btn-register-submit');
|
||||||
if (registerBtn) registerBtn.onclick = () => this.handleRegister();
|
if (registerBtn) registerBtn.onclick = () => this.handleRegister();
|
||||||
|
|
||||||
|
const logoutBtn = document.getElementById('btn-logout-submit');
|
||||||
|
if (logoutBtn) logoutBtn.onclick = () => this.handleLogout();
|
||||||
|
|
||||||
|
const cloudSyncBtn = document.getElementById('btn-cloud-sync-now');
|
||||||
|
if (cloudSyncBtn) cloudSyncBtn.onclick = () => this.handleCloudSyncNow();
|
||||||
|
|
||||||
|
// Savegame Management: Export & Import
|
||||||
|
const exportFileBtn = document.getElementById('btn-export-file');
|
||||||
|
if (exportFileBtn) exportFileBtn.onclick = () => this.handleExportSaveFile();
|
||||||
|
|
||||||
|
const exportCopyBtn = document.getElementById('btn-export-clipboard');
|
||||||
|
if (exportCopyBtn) exportCopyBtn.onclick = () => this.handleExportSaveClipboard();
|
||||||
|
|
||||||
|
const importFileInput = document.getElementById('import-file-input');
|
||||||
|
if (importFileInput) importFileInput.onchange = (e) => this.handleImportSaveFile(e);
|
||||||
|
|
||||||
|
const importClipBtn = document.getElementById('btn-import-clipboard');
|
||||||
|
if (importClipBtn) importClipBtn.onclick = () => this.toggleImportTextBox();
|
||||||
|
|
||||||
|
const importApplyBtn = document.getElementById('btn-import-text-apply');
|
||||||
|
if (importApplyBtn) importApplyBtn.onclick = () => this.handleApplyImportText();
|
||||||
|
|
||||||
|
const importCancelBtn = document.getElementById('btn-import-text-cancel');
|
||||||
|
if (importCancelBtn) importCancelBtn.onclick = () => this.toggleImportTextBox(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
initTheme() {
|
initTheme() {
|
||||||
|
|
@ -685,6 +713,42 @@ class ProductionApp {
|
||||||
errBox.style.display = 'none';
|
errBox.style.display = 'none';
|
||||||
errBox.textContent = '';
|
errBox.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle guest vs user view
|
||||||
|
const isLoggedIn = this.sync.isLoggedIn();
|
||||||
|
const guestSec = document.getElementById('auth-guest-section');
|
||||||
|
const userSec = document.getElementById('auth-user-section');
|
||||||
|
const nameEl = document.getElementById('profile-display-name');
|
||||||
|
const badgeEl = document.getElementById('profile-badge-pill');
|
||||||
|
const descEl = document.getElementById('profile-sync-desc');
|
||||||
|
const iconEl = document.getElementById('profile-status-icon');
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
if (guestSec) guestSec.style.display = 'none';
|
||||||
|
if (userSec) userSec.style.display = 'block';
|
||||||
|
if (nameEl) nameEl.textContent = this.sync.getUsername();
|
||||||
|
if (badgeEl) {
|
||||||
|
badgeEl.textContent = 'Cloud Synced';
|
||||||
|
badgeEl.className = 'badge-pill-soft badge-online';
|
||||||
|
}
|
||||||
|
if (descEl) descEl.textContent = 'Account active. Enterprise data synchronizes automatically with the cloud server.';
|
||||||
|
if (iconEl) iconEl.setAttribute('icon', 'lucide:shield-check');
|
||||||
|
} else {
|
||||||
|
if (guestSec) guestSec.style.display = 'flex';
|
||||||
|
if (userSec) userSec.style.display = 'none';
|
||||||
|
if (nameEl) nameEl.textContent = 'Executive Guest';
|
||||||
|
if (badgeEl) {
|
||||||
|
badgeEl.textContent = 'Offline Guest';
|
||||||
|
badgeEl.className = 'badge-pill-soft';
|
||||||
|
}
|
||||||
|
if (descEl) descEl.textContent = 'Progress is saved locally in this browser. Register or login to sync across devices.';
|
||||||
|
if (iconEl) iconEl.setAttribute('icon', 'lucide:shield');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide raw import box if open
|
||||||
|
const importBox = document.getElementById('import-text-box');
|
||||||
|
if (importBox) importBox.style.display = 'none';
|
||||||
|
|
||||||
if (modal) modal.classList.add('open');
|
if (modal) modal.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -711,8 +775,20 @@ class ProductionApp {
|
||||||
try {
|
try {
|
||||||
await this.sync.login(u, p);
|
await this.sync.login(u, p);
|
||||||
this.showToast(`Logged in as ${u}`, 'success');
|
this.showToast(`Logged in as ${u}`, 'success');
|
||||||
|
|
||||||
|
// Check for cloud state upon login
|
||||||
|
const cloud = await this.sync.fetchCloudState();
|
||||||
|
if (cloud && cloud.lastTick > this.sm.state.lastTick) {
|
||||||
|
this.sm.importState(cloud);
|
||||||
|
this.showToast('Restored latest game state from cloud!', 'info');
|
||||||
|
} else {
|
||||||
|
// If local save is newer or first login, push local state to cloud
|
||||||
|
await this.sync.saveCloudState(this.sm.state);
|
||||||
|
}
|
||||||
|
|
||||||
this.closeAuthModal();
|
this.closeAuthModal();
|
||||||
this.updateHud();
|
this.updateHud();
|
||||||
|
this.mountActiveView();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showAuthError(err.message || 'Login failed');
|
this.showAuthError(err.message || 'Login failed');
|
||||||
}
|
}
|
||||||
|
|
@ -727,6 +803,8 @@ class ProductionApp {
|
||||||
try {
|
try {
|
||||||
await this.sync.register(u, p, hp);
|
await this.sync.register(u, p, hp);
|
||||||
this.showToast(`Account created! Welcome ${u}`, 'success');
|
this.showToast(`Account created! Welcome ${u}`, 'success');
|
||||||
|
// Back up current game state to new account
|
||||||
|
await this.sync.saveCloudState(this.sm.state);
|
||||||
this.closeAuthModal();
|
this.closeAuthModal();
|
||||||
this.updateHud();
|
this.updateHud();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -734,6 +812,151 @@ class ProductionApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleLogout() {
|
||||||
|
this.sync.logout();
|
||||||
|
this.showToast('Logged out. Playing as Guest.', 'info');
|
||||||
|
this.openAuthModal(); // Refresh modal to show guest state
|
||||||
|
this.updateHud();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCloudSyncNow() {
|
||||||
|
if (!this.sync.isLoggedIn()) {
|
||||||
|
return this.showToast('You must be logged in to sync to the cloud', 'warning');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ok = await this.sync.saveCloudState(this.sm.state);
|
||||||
|
if (ok) {
|
||||||
|
this.showToast('Empire successfully synced to cloud server!', 'success');
|
||||||
|
} else {
|
||||||
|
this.showToast('Cloud sync failed. Please try again.', 'warning');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast('Sync error: ' + err.message, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Savegame File Export
|
||||||
|
handleExportSaveFile() {
|
||||||
|
try {
|
||||||
|
const jsonStr = this.sm.exportState();
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
const fileName = `production_save_${timestamp}.json`;
|
||||||
|
const blob = new Blob([jsonStr], { 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();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
this.showToast(`Savegame downloaded: ${fileName}`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export error:', err);
|
||||||
|
this.showToast('Failed to export save: ' + err.message, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Savegame Clipboard Copy
|
||||||
|
async handleExportSaveClipboard() {
|
||||||
|
try {
|
||||||
|
const jsonStr = this.sm.exportState();
|
||||||
|
await navigator.clipboard.writeText(jsonStr);
|
||||||
|
this.showToast('Savegame copied to clipboard!', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback for browsers with restricted clipboard
|
||||||
|
const promptOk = window.prompt('Copy your savegame JSON string below:', this.sm.exportState());
|
||||||
|
if (promptOk !== null) {
|
||||||
|
this.showToast('Savegame copied!', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Paste Save container
|
||||||
|
toggleImportTextBox(show = null) {
|
||||||
|
const box = document.getElementById('import-text-box');
|
||||||
|
const area = document.getElementById('import-raw-input');
|
||||||
|
if (!box) return;
|
||||||
|
const willShow = show !== null ? show : (box.style.display === 'none' || !box.style.display);
|
||||||
|
box.style.display = willShow ? 'flex' : 'none';
|
||||||
|
if (willShow && area) {
|
||||||
|
area.value = '';
|
||||||
|
area.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import from uploaded file
|
||||||
|
handleImportSaveFile(e) {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
const text = event.target.result;
|
||||||
|
await this.executeSaveImport(text, `file "${file.name}"`);
|
||||||
|
e.target.value = ''; // Reset input to allow selecting same file again
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
this.showToast('Could not read the selected file.', 'warning');
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import from pasted text
|
||||||
|
async handleApplyImportText() {
|
||||||
|
const raw = document.getElementById('import-raw-input')?.value.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return this.showToast('Please paste a savegame JSON string first.', 'warning');
|
||||||
|
}
|
||||||
|
await this.executeSaveImport(raw, 'clipboard text');
|
||||||
|
this.toggleImportTextBox(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common confirmation & restore executor
|
||||||
|
async executeSaveImport(rawJson, sourceLabel = 'input') {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawJson);
|
||||||
|
} catch (err) {
|
||||||
|
return this.showToast('Invalid JSON savegame: syntax error.', 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
const compName = parsed.companyName || 'Unknown Enterprise';
|
||||||
|
const cashAmt = parsed.cash !== undefined ? fmtCurrency(parsed.cash) : '$0.00';
|
||||||
|
const ipoCount = parsed.ipoCount || 0;
|
||||||
|
|
||||||
|
const confirmed = await this.showConfirmModal({
|
||||||
|
title: 'Restore Savegame',
|
||||||
|
subtitle: 'Data Override Confirmation',
|
||||||
|
icon: 'lucide:download',
|
||||||
|
text: `Are you sure you want to load savegame from <strong>${sourceLabel}</strong>?<br><br>` +
|
||||||
|
`<strong>Company:</strong> ${compName}<br>` +
|
||||||
|
`<strong>Capital:</strong> ${cashAmt}<br>` +
|
||||||
|
`<strong>IPO Prestige:</strong> ${ipoCount} iterations<br><br>` +
|
||||||
|
`<span style="color: var(--amber-dark);">Warning: This will replace your current game state immediately!</span>`,
|
||||||
|
okText: 'Restore & Load',
|
||||||
|
cancelText: 'Cancel'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
const res = this.sm.importState(parsed);
|
||||||
|
if (res.success) {
|
||||||
|
// If logged in, also sync the newly restored state to the cloud
|
||||||
|
if (this.sync.isLoggedIn()) {
|
||||||
|
this.sync.saveCloudState(this.sm.state);
|
||||||
|
}
|
||||||
|
this.closeAuthModal();
|
||||||
|
this.mountActiveView();
|
||||||
|
this.mountInspectorPane();
|
||||||
|
this.updateHud();
|
||||||
|
this.showToast(`Savegame successfully restored: ${res.companyName}!`, 'success');
|
||||||
|
} else {
|
||||||
|
this.showToast(`Failed to restore save: ${res.error}`, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toggleSettings() {
|
toggleSettings() {
|
||||||
const current = getNotation();
|
const current = getNotation();
|
||||||
const next = current === 'standard' ? 'scientific' : 'standard';
|
const next = current === 'standard' ? 'scientific' : 'standard';
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,70 @@ export class StateManager {
|
||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exportState() {
|
||||||
|
return JSON.stringify(this.state, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
importState(jsonStringOrObject) {
|
||||||
|
try {
|
||||||
|
const parsed = typeof jsonStringOrObject === 'string' ? JSON.parse(jsonStringOrObject) : jsonStringOrObject;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
throw new Error('Invalid savegame format: must be an object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = getInitialState();
|
||||||
|
// Validate & sanitize incoming state against schema
|
||||||
|
const validated = { ...fresh, ...parsed };
|
||||||
|
validated.version = fresh.version;
|
||||||
|
validated.cash = typeof parsed.cash === 'number' && !isNaN(parsed.cash) ? Math.max(0, parsed.cash) : fresh.cash;
|
||||||
|
validated.lifetimeRevenue = typeof parsed.lifetimeRevenue === 'number' && !isNaN(parsed.lifetimeRevenue) ? Math.max(0, parsed.lifetimeRevenue) : fresh.lifetimeRevenue;
|
||||||
|
validated.valuation = typeof parsed.valuation === 'number' && !isNaN(parsed.valuation) ? Math.max(0, parsed.valuation) : fresh.valuation;
|
||||||
|
validated.ventureShares = typeof parsed.ventureShares === 'number' && !isNaN(parsed.ventureShares) ? Math.max(0, parsed.ventureShares) : fresh.ventureShares;
|
||||||
|
validated.totalVentureShares = typeof parsed.totalVentureShares === 'number' && !isNaN(parsed.totalVentureShares) ? Math.max(0, parsed.totalVentureShares) : fresh.totalVentureShares;
|
||||||
|
validated.ipoCount = typeof parsed.ipoCount === 'number' ? Math.max(0, parsed.ipoCount) : 0;
|
||||||
|
validated.reputation = typeof parsed.reputation === 'number' ? Math.max(0, parsed.reputation) : 0;
|
||||||
|
validated.lastTick = Date.now();
|
||||||
|
|
||||||
|
validated.products = { ...fresh.products };
|
||||||
|
if (parsed.products && typeof parsed.products === 'object') {
|
||||||
|
for (const pid of Object.keys(fresh.products)) {
|
||||||
|
if (parsed.products[pid]) {
|
||||||
|
const pp = parsed.products[pid];
|
||||||
|
validated.products[pid] = {
|
||||||
|
...fresh.products[pid],
|
||||||
|
unlocked: Boolean(pp.unlocked),
|
||||||
|
stock: typeof pp.stock === 'number' && !isNaN(pp.stock) ? Math.max(0, pp.stock) : 0,
|
||||||
|
lines: typeof pp.lines === 'number' && !isNaN(pp.lines) ? Math.max(0, pp.lines) : 0,
|
||||||
|
manager: Boolean(pp.manager),
|
||||||
|
logistics: typeof pp.logistics === 'number' ? Math.max(0, pp.logistics) : 0,
|
||||||
|
logisticsEnabled: pp.logisticsEnabled !== false,
|
||||||
|
supplyDownstreamEnabled: pp.supplyDownstreamEnabled !== false,
|
||||||
|
productionTarget: typeof pp.productionTarget === 'number' ? Math.max(0, Math.min(1.0, pp.productionTarget)) : 1.0,
|
||||||
|
cycleProgress: 0.0,
|
||||||
|
isBottlenecked: false,
|
||||||
|
totalProduced: typeof pp.totalProduced === 'number' ? Math.max(0, pp.totalProduced) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validated.boardroomPerks = { ...fresh.boardroomPerks, ...(parsed.boardroomPerks || {}) };
|
||||||
|
validated.achievements = { ...fresh.achievements, ...(parsed.achievements || {}) };
|
||||||
|
validated.stats = { ...fresh.stats, ...(parsed.stats || {}) };
|
||||||
|
if (!Array.isArray(validated.activeContracts) || validated.activeContracts.length === 0) {
|
||||||
|
validated.activeContracts = fresh.activeContracts;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = validated;
|
||||||
|
this.save();
|
||||||
|
this.notify();
|
||||||
|
return { success: true, companyName: validated.companyName || 'Apex Production Industries' };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save import failed:', err);
|
||||||
|
return { success: false, error: err.message || 'Unknown save format' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
performIPO() {
|
performIPO() {
|
||||||
const earned = calculateSharesEarned(this.state.lifetimeRevenue);
|
const earned = calculateSharesEarned(this.state.lifetimeRevenue);
|
||||||
if (earned <= 0) return false;
|
if (earned <= 0) return false;
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,24 @@ export class SyncManager {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async saveCloudState(saveData) {
|
||||||
|
if (!this.token) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/game/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ save_data: saveData })
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving cloud state:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async register(username, password, website_hp = '') {
|
async register(username, password, website_hp = '') {
|
||||||
const res = await fetch('/api/auth/register', {
|
const res = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue