feat: add vue-i18n infrastructure and externalize all UI strings (A11Y-03)

Set up vue-i18n with English locale file containing 500+ keys organized
by view namespace. All 15 views converted to use t() calls instead of
hardcoded strings. Infrastructure ready for community translations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-11 13:45:59 +00:00
parent b9cc0a924e
commit aba7aba25f
21 changed files with 2491 additions and 640 deletions

15
neode-ui/src/i18n.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
export type MessageSchema = typeof en
const i18n = createI18n<[MessageSchema], 'en'>({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: {
en,
},
})
export default i18n

View File

@@ -0,0 +1,696 @@
{
"common": {
"cancel": "Cancel",
"save": "Save",
"close": "Close",
"copy": "Copy",
"copied": "Copied",
"copiedBang": "Copied!",
"loading": "Loading...",
"retry": "Retry",
"refresh": "Refresh",
"install": "Install",
"installing": "Installing...",
"uninstall": "Uninstall",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"launch": "Launch",
"starting": "Starting...",
"stopping": "Stopping...",
"send": "Send",
"sending": "Sending...",
"back": "Back",
"done": "Done",
"manage": "Manage",
"connect": "Connect",
"connecting": "Connecting...",
"disconnect": "Disconnect",
"running": "running",
"stopped": "stopped",
"exited": "exited",
"healthy": "Healthy",
"elevated": "Elevated",
"critical": "Critical",
"connected": "Connected",
"disconnected": "Disconnected",
"active": "Active",
"inactive": "Inactive",
"synced": "Synced",
"enabled": "Enabled",
"disabled": "Disabled",
"dismiss": "Dismiss",
"apply": "Apply",
"configure": "Configure",
"export": "Export",
"delete": "Delete",
"remove": "Remove",
"error": "Error",
"version": "Version",
"status": "Status",
"category": "Category",
"developer": "Developer",
"license": "License",
"never": "Never",
"notAvailable": "Not Available",
"goBack": "Go back",
"skipToContent": "Skip to main content",
"continue": "Continue",
"verify": "Verify",
"create": "Create",
"restore": "Restore",
"disabling": "Disabling...",
"creating": "Creating...",
"restoring": "Restoring...",
"manageUpdates": "Manage Updates",
"enableAll": "Enable All",
"networkDiagnostics": "Network Diagnostics",
"network": "Network",
"saveConfiguration": "Save Configuration",
"sendTest": "Send Test"
},
"login": {
"title": "Welcome to Archipelago",
"setupTitle": "Set Up Your Node",
"twoFactorTitle": "Two-Factor Authentication",
"password": "Password",
"confirmPassword": "Confirm Password",
"enterPasswordPlaceholder": "Enter your password",
"enterPasswordSetup": "Enter a password (min 8 characters)",
"confirmPasswordPlaceholder": "Confirm your password",
"setupButton": "Set Up Node",
"settingUp": "Setting up...",
"loginButton": "Login",
"loggingIn": "Logging in...",
"verifyButton": "Verify",
"verifying": "Verifying...",
"useAuthCode": "Use authenticator code",
"useBackupCode": "Use a backup code instead",
"totpInstruction": "Enter the 6-digit code from your authenticator app",
"totpPlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"serverStarting": "Server starting up...",
"replayIntro": "Replay Intro",
"onboarding": "Onboarding",
"resetting": "Resetting...",
"recoveryNote": "Password recovery requires SSH access to the server.",
"errorMinLength": "Password must be at least 8 characters",
"errorMismatch": "Passwords do not match",
"errorServerStarting": "Server is starting up. Please try again in a moment.",
"errorSetupFailed": "Setup failed. Please try again.",
"errorLoginFailed": "Login failed. Please check your password.",
"errorInvalidCode": "Invalid code. Please try again.",
"totpLabel": "Two-factor authentication code"
},
"home": {
"title": "Welcome Noderunner",
"subtitle": "Here's an overview of your sovereign life",
"dashboardTab": "Dashboard",
"setupTab": "Setup",
"myApps": "My Apps",
"myAppsDesc": "Manage your installed applications",
"cloud": "Cloud",
"cloudDesc": "Cloud services and storage",
"network": "Network",
"networkDesc": "Network infrastructure and Web3 services",
"web5": "Web5",
"web5Desc": "Decentralized identity and data protocols",
"system": "System",
"quickStartGoals": "Quick Start Goals",
"quickStartDesc": "Not sure where to start? Try a guided setup.",
"installed": "Installed",
"runningLabel": "Running",
"storageUsed": "Storage Used",
"folders": "Folders",
"servicesStatus": "Services Status",
"connectivity": "Connectivity",
"runningApps": "Running Apps",
"didStatus": "DID Status",
"dwnSync": "DWN Sync",
"credentials": "Credentials",
"cpu": "CPU",
"ram": "RAM",
"disk": "Disk",
"browseStore": "Browse Store",
"manageApps": "Manage Apps",
"viewFolders": "View Folders",
"uploadFiles": "Upload Files",
"manageNetwork": "Manage Network",
"manageWeb5": "Manage Web5",
"openAI": "Open AI Assistant",
"noApps": "No Apps",
"allRunning": "All Running",
"systemMonitoring": "System monitoring",
"updateAvailable": "Update Available: v{version}",
"updateNow": "Update Now",
"goToApps": "Go to Apps",
"goToCloud": "Go to Cloud",
"goToNetwork": "Go to Network",
"goToWeb5": "Go to Web5",
"goToSettings": "Go to Settings"
},
"apps": {
"title": "My Apps",
"subtitle": "Manage your installed applications",
"searchPlaceholder": "Search installed apps...",
"noAppsTitle": "No Apps Installed",
"noAppsMessage": "Get started by browsing the app store",
"browseAppStore": "Browse App Store",
"noResults": "No apps matching \"{query}\"",
"uninstallTitle": "Uninstall App?",
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
"dismissError": "Dismiss error",
"searchLabel": "Search installed apps"
},
"settings": {
"title": "Settings",
"subtitle": "Configure your Archipelago experience",
"account": "Account",
"interfaceMode": "Interface Mode",
"claudeAuth": "Claude Authentication",
"aiDataAccess": "AI Data Access",
"serverName": "Server Name",
"sessionStatus": "Session Status",
"yourDid": "Your DID",
"onionAddress": "Node .onion Address",
"loggedIn": "Currently logged in",
"didHelper": "Decentralized identifier for passwordless auth",
"onionHelper": "Onion address for node interface and peer discovery over Tor",
"changePassword": "Change Password",
"enable2fa": "Enable 2FA",
"disable2fa": "Disable 2FA",
"logout": "Logout",
"loggingOut": "Logging out...",
"twoFactorAuth": "Two-Factor Authentication",
"twoFaProtect": "Protect your account with an authenticator app",
"changePasswordTitle": "Change Password",
"changePasswordDesc": "Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmNewPassword": "Confirm New Password",
"passwordPlaceholder": "12+ chars, upper, lower, digit, special",
"updateSshCheckbox": "Also update SSH password (recommended)",
"updatePassword": "Update Password",
"updatingPassword": "Updating...",
"setup2faTitle": "Two-Factor Authentication",
"setup2faPasswordPrompt": "Enter your password to begin setup.",
"scanQrCode": "Scan QR Code",
"scanQrInstruction": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code.",
"manualEntryKey": "Manual entry key:",
"verifyAndEnable": "Verify & Enable",
"saveBackupCodes": "Save Your Backup Codes",
"backupCodesInstruction": "Store these codes safely. Each can be used once if you lose access to your authenticator app.",
"copyAllCodes": "Copy All Codes",
"disable2faTitle": "Disable Two-Factor Authentication",
"disable2faDesc": "Enter your password and a current TOTP code to disable 2FA.",
"authenticatorCode": "Authenticator Code",
"webhooks": "Webhooks",
"webhooksDesc": "Get notified when important events happen on your node",
"webhookUrl": "Webhook URL",
"webhookUrlPlaceholder": "https://example.com/webhook",
"webhookSecret": "Secret (for HMAC signing)",
"webhookSecretPlaceholder": "Optional shared secret",
"webhookEvents": "Events",
"containerCrash": "Container Crash",
"updateAvailableEvent": "Update Available",
"diskWarning": "Disk Warning",
"backupComplete": "Backup Complete",
"saveWebhook": "Save",
"savingWebhook": "Saving...",
"testWebhook": "Test",
"testingWebhook": "Testing...",
"webhookSaved": "Webhook configuration saved",
"webhookTestSent": "Test webhook sent successfully",
"systemUpdates": "System Updates",
"backup": "Backup & Restore",
"backupDesc": "Back up your node data to external storage",
"createBackup": "Create Backup",
"creatingBackup": "Creating...",
"restoreBackup": "Restore Backup",
"deleteBackup": "Delete backup",
"backupCreated": "Backup created successfully",
"sendMessage": "Send Message",
"sendMessageTitle": "Send Broadcast Message",
"messagePlaceholder": "Enter your message...",
"messageSent": "Message sent",
"claudeConnected": "Connected to Claude",
"claudeDisconnected": "Not connected",
"claudeApiKey": "API Key",
"claudeApiKeyPlaceholder": "Enter your Anthropic API key",
"claudeSave": "Save Key",
"advancedMode": "Advanced Mode",
"beginnerMode": "Beginner Mode",
"advancedModeDesc": "Show all system controls and developer tools",
"beginnerModeDesc": "Simplified interface with guided experience",
"networkSettings": "Network Settings",
"torEnabled": "Tor Enabled",
"torAddress": "Tor Address",
"interfaceModeDesc": "Choose how you want to interact with your node.",
"claudeAuthDesc": "Connect your Claude Max account to enable AI chat features.",
"connectionStatus": "Connection Status",
"notConnected": "Not connected",
"reAuthenticate": "Re-authenticate",
"loginWithClaude": "Login with Claude",
"aiDataAccessDesc": "Control what data the AI assistant can see. All categories are off by default.",
"enableAllDesc": "Grant access to all data categories at once",
"systemUpdatesDesc": "Check for and install software updates",
"webhookNotifications": "Webhook Notifications",
"webhookNotificationsDesc": "Get push notifications for critical events via webhook",
"enableWebhooks": "Enable webhooks",
"disableWebhooks": "Disable webhooks",
"webhookUrlLabel": "Webhook URL",
"webhookSecretLabel": "Secret (optional, for HMAC-SHA256 signing)",
"eventsToNotify": "Events to notify",
"containerCrashDesc": "A running container stops unexpectedly",
"updateAvailableDesc": "A new system or app update is ready",
"diskWarningDesc": "Disk usage exceeds warning threshold",
"backupCompleteDesc": "A scheduled or manual backup finishes",
"backupRestoreDesc": "Encrypted backups of your identity, settings, and data",
"loadingBackups": "Loading backups...",
"noBackups": "No backups yet. Create one to protect your node data.",
"systemBackup": "System Backup",
"createEncryptedBackup": "Create Encrypted Backup",
"encryptionPassphrase": "Encryption Passphrase",
"enterPassphrase": "Enter a strong passphrase",
"descriptionOptional": "Description (optional)",
"descriptionPlaceholder": "e.g. Before update",
"restoreBackupTitle": "Restore Backup",
"restoreWarning": "This will overwrite current node data. Make sure you have the correct passphrase.",
"enterBackupPassphrase": "Enter backup passphrase",
"networkDesc": "Network connectivity, UPnP, and diagnostics",
"webhookSecretPlaceholderFull": "Shared secret for payload signing",
"backupCreatedSuccess": "Backup created successfully",
"backupCreateFailed": "Failed to create backup",
"backupVerifiedOk": "Backup verified — integrity OK",
"backupVerifyFailed": "Verification failed: {error}",
"backupVerifyRequestFailed": "Verification request failed",
"backupRestored": "Backup restored. Restart may be needed.",
"backupRestoreFailed": "Restore failed — check passphrase",
"backupDeleted": "Backup deleted",
"backupDeleteFailed": "Failed to delete backup",
"noUsbDrives": "No mounted USB drives found. Insert and mount a USB drive first.",
"backupCopiedToUsb": "Backup copied to {path}",
"backupUsbFailed": "Failed to copy backup to USB",
"deleteBackupConfirm": "Delete this backup permanently?",
"verifyPassphrasePrompt": "Enter backup passphrase to verify:",
"webhookSaveFailed": "Failed to save webhook configuration",
"webhookTestFailed": "Test failed: webhook not sent",
"webhookSendFailed": "Failed to send test webhook",
"passwordAllFieldsRequired": "All fields are required",
"passwordMismatch": "New passwords do not match",
"passwordUpdatedSuccess": "Password updated successfully. Use the new password for login and SSH.",
"passwordChangeFailed": "Failed to change password",
"passwordMinLength": "Password must be at least 12 characters",
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
"passwordNeedLowercase": "Password must contain at least one lowercase letter",
"passwordNeedDigit": "Password must contain at least one digit",
"passwordNeedSpecial": "Password must contain at least one special character (!@#$%^&* etc.)",
"setupFailed": "Setup failed",
"verificationFailed": "Verification failed",
"disableFailed": "Failed to disable 2FA",
"copyToUsb": "Copy to USB",
"diskSpaceWarning": "Disk Space Warning",
"modeEasy": "Easy",
"modeEasyDesc": "Goal-based interface. Choose what you want to do, and the system handles the rest.",
"modePro": "Pro",
"modeProDesc": "Full control over all services. Configure everything manually with all technical details.",
"modeChat": "Chat",
"modeChatDesc": "Conversational AI interface. Manage your node through natural language. Coming soon."
},
"marketplace": {
"title": "App Store",
"subtitle": "Discover and install apps for your new sovereign life",
"curatedTab": "Curated",
"communityTab": "Community",
"filterByCategory": "Filter by Category",
"searchPlaceholder": "Search apps...",
"downloading": "Downloading...",
"alreadyInstalled": "Already Installed",
"queryingRelays": "Querying Nostr relays for apps...",
"noCommunityApps": "No community apps discovered yet.",
"noResults": "No apps found in {category} matching \"{query}\"",
"noResultsCategory": "No apps found in {category}",
"noResultsSearch": "No apps matching \"{query}\"",
"all": "All",
"community": "Community",
"commerce": "Commerce",
"money": "Money",
"data": "Data",
"homeCategory": "Home",
"auto": "Auto",
"networking": "Networking",
"other": "Other",
"searchApps": "Search apps",
"percentComplete": "{percent}% complete"
},
"dashboard": {
"mainNav": "Main navigation",
"mobileNav": "Mobile navigation"
},
"chat": {
"close": "Close",
"aiuiConnected": "AIUI connected",
"closeAssistant": "Close AI Assistant",
"loadingAssistant": "Loading AI assistant...",
"aiAssistant": "AI Assistant",
"notConfigured": "AI Assistant is not yet configured on this node.",
"deployCta": "Deploy the AIUI app from the App Store to enable this feature."
},
"web5": {
"title": "Web5",
"subtitle": "Decentralized identity and data protocols",
"profitsHelper": "Earn networking profits by hosting decentralized services",
"networkingProfits": "Networking Profits",
"didStatus": "DID Status",
"walletConnection": "Wallet Connection",
"wallet": "Wallet",
"walletSubtitle": "On-chain, Lightning & Ecash",
"nostrRelays": "Nostr Relays",
"connectedNodes": "Connected Nodes",
"bitcoinDomains": "Bitcoin Domain Names",
"domainsSubtitle": "NIP-05 verified identities",
"copyDid": "Copy DID",
"viewDidDocument": "View DID Document",
"createDid": "Create DID",
"creatingDid": "Creating...",
"manageDomains": "Manage Domains",
"relaysConnected": "{count} connected",
"peersKnown": "{count} peer(s) known",
"sendMessage": "Send Message",
"sendMessageTitle": "Send Message (over Tor)",
"to": "To",
"selectPeer": "Select a peer...",
"message": "Message",
"messagePlaceholder": "Type your message...",
"didDocument": "DID Document",
"addContent": "Add Content",
"addContentTitle": "Add Content",
"createIdentity": "Create Identity",
"createIdentityTitle": "Create Identity",
"deleteIdentity": "Delete Identity",
"deleteIdentityTitle": "Delete Identity",
"sendBitcoin": "Send Bitcoin",
"sendBitcoinTitle": "Send Bitcoin",
"receiveBitcoin": "Receive Bitcoin",
"receiveBitcoinTitle": "Receive Bitcoin",
"domains": "Domains",
"domainsTitle": "Domains",
"relays": "Relays",
"relaysTitle": "Relays",
"totalEarned": "Total Earned",
"monthlyAvg": "Monthly Avg",
"ecashBalance": "Ecash Balance",
"onChain": "On-chain",
"lightning": "Lightning",
"ecash": "Ecash",
"identityName": "Identity Name",
"identityNamePlaceholder": "Enter identity name",
"contentTitle": "Title",
"contentTitlePlaceholder": "Enter content title",
"amount": "Amount",
"amountPlaceholder": "Enter amount in sats",
"address": "Address",
"addressPlaceholder": "Enter Bitcoin address",
"deleteIdentityConfirm": "Are you sure you want to delete this identity? This action cannot be undone.",
"confirm": "Confirm",
"noRelays": "No relays connected",
"noDomains": "No domains configured",
"addRelay": "Add Relay",
"addDomain": "Add Domain",
"relayUrl": "Relay URL",
"relayUrlPlaceholder": "wss://relay.example.com",
"domainName": "Domain Name",
"domainNamePlaceholder": "user{'@'}example.com",
"peerNodesDescription": "Peer nodes discovered via Nostr. Messages sent over Tor.",
"nodeVisibility": "Node Visibility",
"nodeVisibilityDesc": "Control how other nodes can discover you",
"yourTorAddress": "Your Tor address",
"discoverableWarning": "Making your node discoverable lets other Archipelago users find and connect with you.",
"noPeers": "No peers yet. Add a peer manually or use Discover to find nodes on Nostr.",
"noMessages": "No messages yet. Messages from peers will appear here.",
"noRequests": "No pending connection requests.",
"accept": "Accept",
"reject": "Reject",
"discovering": "Discovering...",
"discoverNodes": "Discover Nodes on Nostr",
"refreshMessages": "Refresh Messages",
"refreshRequests": "Refresh Requests",
"torServices": "Tor Services",
"torServicesDesc": "Hidden services exposing your apps over Tor",
"noTorServices": "No Tor hidden services configured.",
"content": "Content",
"contentDesc": "Share and browse content with peers over Tor",
"noSharedContent": "No shared content",
"addFilesToShare": "Add files to share with connected peers.",
"browse": "Browse",
"connectingToPeer": "Connecting to peer over Tor...",
"selectPeerToBrowse": "Select a peer to browse",
"choosePeerDesc": "Choose a connected peer to see their shared content.",
"peerNoContent": "This peer has no shared content.",
"identities": "Identities",
"identitiesDesc": "Sovereign digital identities (DID:key)",
"noIdentities": "No identities yet",
"createFirstIdentity": "Create your first sovereign digital identity.",
"deleting": "Deleting...",
"decentralizedWebNode": "Decentralized Web Node",
"dwnDescription": "Personal data store with DID-based access control",
"manageDwn": "Manage DWN",
"syncing": "Syncing...",
"syncNow": "Sync Now",
"verifiableCredentials": "Verifiable Credentials",
"verifiableCredentialsDesc": "Issue and manage W3C Verifiable Credentials",
"noCredentials": "No credentials issued yet",
"messageSent": "Message sent over Tor!",
"failedToSend": "Failed to send",
"pasteInvoice": "Paste a Lightning invoice (BOLT11)",
"enterBitcoinAddress": "Enter a Bitcoin address",
"sendFailed": "Send failed",
"broadcastViaHwWallet": "Broadcast via hardware wallet",
"broadcastFailed": "Broadcast failed",
"psbtCopied": "PSBT copied!",
"enterAmount": "Enter an amount",
"pasteEcashToken": "Paste an ecash token",
"receiveFailed": "Receive failed",
"ecashTokenCopied": "Ecash token copied",
"contentAdded": "Content added",
"failedToAddContent": "Failed to add content",
"contentRemoved": "Content removed",
"failedToRemoveContent": "Failed to remove content",
"failedToUpdatePricing": "Failed to update pricing",
"failedToUpdatePrice": "Failed to update price",
"failedToConnectPeer": "Failed to connect to peer",
"onionAddressCopied": "Onion address copied",
"streamUrlCopied": "Stream URL copied",
"playerError": "Unable to load media. The content may only be accessible over Tor.",
"connectionAccepted": "Connection accepted",
"failedToAcceptRequest": "Failed to accept request",
"requestRejected": "Request rejected",
"failedToRejectRequest": "Failed to reject request",
"visibilitySetTo": "Visibility set to {level}",
"failedToUpdateVisibility": "Failed to update visibility",
"didCopied": "DID copied to clipboard",
"defaultIdentityUpdated": "Default identity updated",
"failedToSetDefault": "Failed to set default",
"identityCreated": "Identity created",
"failedToCreateIdentity": "Failed to create identity",
"identityDeleted": "Identity deleted",
"failedToDeleteIdentity": "Failed to delete identity",
"registrationFailed": "Registration failed",
"removeFailed": "Remove failed",
"failedToAddRelay": "Failed to add relay",
"failedToRemoveRelay": "Failed to remove relay",
"failedToToggleRelay": "Failed to toggle relay",
"downloadUrlCopied": "Download URL copied",
"hardwareWalletDetected": "Hardware Wallet Detected",
"namesRegistered": "Names Registered",
"expiringSoon": "Expiring Soon",
"nostrRelaysDesc": "Decentralized social networking relays",
"relaysConnectedLabel": "Relays Connected",
"totalRelays": "Total Relays",
"freeAccessDesc": "Available to all peers for free",
"peersOnlyAccessDesc": "Available only to connected peers",
"signWithHwWallet": "Sign with Hardware Wallet",
"createsPsbt": "Creates a PSBT for external signing",
"generateFreshAddress": "Generate a fresh Bitcoin address",
"registerNewName": "Register New Name",
"verifyNip05": "Verify NIP-05",
"peers": "Peers",
"messages": "Messages",
"requests": "Requests",
"myContent": "My Content",
"browsePeers": "Browse Peers",
"verified": "Verified",
"invalid": "Invalid",
"stream": "Stream",
"download": "Download"
},
"appDetails": {
"backToApps": "Back to My Apps",
"backToStore": "Back to App Store",
"screenshots": "Screenshots",
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
"about": "About {name}",
"features": "Features",
"information": "Information",
"requirements": "Requirements",
"ram": "RAM",
"ramDesc": "Minimum 512MB",
"storage": "Storage",
"storageDesc": "~100MB",
"links": "Links",
"website": "Website",
"sourceCode": "Source Code",
"documentation": "Documentation",
"services": "Services",
"guardian": "Guardian",
"gateway": "Gateway",
"access": "Access",
"lan": "LAN",
"tor": "Tor",
"requiresTor": "Requires Tor Browser",
"channels": "Channels",
"uninstallTitle": "Uninstall App?",
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
"notFoundTitle": "App Not Found",
"notFoundMessage": "The requested application could not be found",
"installed": "Installed",
"channels": "Channels"
},
"containerDetails": {
"back": "Back",
"subtitle": "Container details and management",
"containerInfo": "Container Information",
"actions": "Actions",
"logs": "Logs",
"containerId": "Container ID",
"image": "Image",
"state": "State",
"created": "Created",
"startContainer": "Start Container",
"stopContainer": "Stop Container",
"loadingLogs": "Loading logs...",
"noLogs": "No logs available"
},
"marketplaceDetails": {
"backToStore": "Back to App Store",
"screenshots": "Screenshots",
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
"about": "About {name}",
"features": "Features",
"information": "Information",
"requirements": "Requirements",
"noRequirements": "No additional dependencies required",
"installRequirements": "Install Requirements",
"links": "Links",
"downloadPackage": "Download Package",
"installed": "Installed",
"notInstalled": "Not Installed",
"open": "Open",
"loadingDetails": "Loading app details...",
"notFoundTitle": "App Not Found",
"notFoundMessage": "The requested application could not be found in the marketplace",
"installFailed": "Installation Failed",
"depRunning": "Running",
"depStopped": "Installed but stopped",
"depNotInstalled": "Not installed"
},
"goalDetail": {
"backToGoals": "Back to Goals",
"notFound": "Goal not found.",
"stepOf": "Step {current} of {total}",
"notStarted": "Not Started",
"inProgress": "In Progress",
"completed": "Completed",
"syncTitle": "Sovereignty takes a little patience",
"syncMessage": "Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else. This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.",
"installApp": "Install {name}",
"openAndConfigure": "Open & Configure",
"iveDoneThis": "I've Done This",
"complete": "Complete",
"allSet": "All Set!",
"goalReady": "{title} is ready to go.",
"viewMyServices": "View My Services"
},
"monitoring": {
"title": "Monitoring",
"subtitle": "Real-time system metrics and container resource usage",
"cpuUsage": "CPU Usage (%)",
"memoryUsage": "Memory Usage (%)",
"networkIo": "Network I/O (bytes)",
"rpcLatency": "RPC Latency (ms)",
"alertHistory": "Alert History",
"hideConfig": "Hide Config",
"noAlerts": "No alerts fired",
"containerResources": "Container Resources",
"noContainerMetrics": "No container metrics available",
"systemHealth": "System Health",
"load": "Load:",
"exportCsv": "Export CSV",
"exportJson": "Export JSON",
"diskUsage": "Disk Usage",
"ramUsage": "RAM Usage",
"containerCrash": "Container Crash",
"rpcLatencySpike": "RPC Latency Spike",
"sslCertExpiry": "SSL Cert Expiry",
"refreshFooter": "Refreshing every 5 seconds",
"wsConnections": "WS connections: {count}",
"cpu": "CPU",
"memory": "Memory",
"network": "Network"
},
"systemUpdate": {
"title": "System Update",
"subtitle": "Manage software updates for your Archipelago node",
"currentSystem": "Current System",
"updateAvailable": "Update Available",
"upToDate": "System is up to date",
"downloading": "Downloading Update...",
"applying": "Applying Update...",
"updateSchedule": "Update Schedule",
"actions": "Actions",
"lastChecked": "Last Checked",
"new": "New",
"changelog": "Changelog",
"componentsToUpdate": "{count} component(s) to update",
"manualOnly": "Manual Only",
"manualOnlyDesc": "Never check automatically. You control when to check and install updates.",
"dailyCheck": "Daily Check",
"dailyCheckDesc": "Check for updates once per day. You decide when to install.",
"autoApply": "Auto-Apply",
"autoApplyDesc": "Check daily and automatically install updates at 3 AM. Service restarts as needed.",
"downloadUpdate": "Download Update",
"applyUpdate": "Apply Update",
"checkForUpdates": "Check for Updates",
"checking": "Checking...",
"rollback": "Rollback to Previous",
"backToSettings": "Back to Settings",
"percentComplete": "{percent}% complete",
"applyWarning": "Installing components and restarting services. Do not power off.",
"applyTitle": "Apply Update?",
"applyMessage": "The backend service will restart. This may take a moment.",
"rollbackTitle": "Rollback Version?",
"rollbackMessage": "This will restore the previous version. The backend service will restart.",
"applyNow": "Apply Now",
"rollbackButton": "Rollback",
"upToDateMessage": "Your system is up to date. No updates available. Your system is running the latest version.",
"checkFailed": "Failed to check for updates. Check your internet connection.",
"downloadSuccess": "Downloaded {count} component(s) ({size}MB)",
"downloadFailed": "Download failed. Please try again.",
"applySuccess": "Update applied. The service will restart momentarily.",
"applyFailed": "Failed to apply update. You can try again or rollback.",
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
"rollbackFailed": "Rollback failed."
},
"kioskRecovery": {
"title": "Archipelago Recovery",
"subtitle": "Kiosk failsafe — no authentication required",
"serverAddress": "Server Address",
"webUi": "Web UI: http://{address}",
"scanForMobile": "Scan for mobile access",
"backend": "Backend",
"unreachable": "Unreachable",
"containers": "Containers",
"goToLogin": "Go to Login",
"lastChecked": "Last checked: {time}"
}
}

View File

@@ -3,11 +3,13 @@ import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(i18n)
app.mount('#app')

View File

@@ -60,7 +60,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Channels
{{ t('appDetails.channels') }}
</button>
<button
v-if="canLaunch"
@@ -70,7 +70,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Launch
{{ t('common.launch') }}
</button>
<button
v-if="pkg.state === 'stopped'"
@@ -80,7 +80,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
Start
{{ t('common.start') }}
</button>
<button
@click="restartApp"
@@ -89,7 +89,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Restart
{{ t('common.restart') }}
</button>
<button
v-if="pkg.state === 'running'"
@@ -100,7 +100,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
Stop
{{ t('common.stop') }}
</button>
<button
@click="uninstallApp"
@@ -109,7 +109,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Uninstall
{{ t('common.uninstall') }}
</button>
</div>
</div>
@@ -146,7 +146,7 @@
<button
@click="uninstallApp"
class="flex-shrink-0 w-10 h-10 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
title="Uninstall"
:title="t('common.uninstall')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@@ -164,7 +164,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Launch
{{ t('common.launch') }}
</button>
<button
v-if="pkg.state === 'stopped'"
@@ -174,7 +174,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
Start
{{ t('common.start') }}
</button>
<button
v-if="pkg.state === 'running'"
@@ -185,7 +185,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
Stop
{{ t('common.stop') }}
</button>
<button
@click="restartApp"
@@ -195,7 +195,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Restart
{{ t('common.restart') }}
</button>
</div>
</div>
@@ -206,7 +206,7 @@
<div class="lg:col-span-2 space-y-6">
<!-- Screenshots Gallery -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">Screenshots</h2>
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="i in 4"
@@ -218,12 +218,12 @@
</svg>
</div>
</div>
<p class="text-white/60 text-sm mt-3 text-center">Screenshot placeholders - images coming soon</p>
<p class="text-white/60 text-sm mt-3 text-center">{{ t('appDetails.screenshotPlaceholder') }}</p>
</div>
<!-- Description -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">About {{ pkg.manifest.title }}</h2>
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.about', { name: pkg.manifest.title }) }}</h2>
<p class="text-white/80 leading-relaxed whitespace-pre-line">
{{ pkg.manifest.description.long }}
</p>
@@ -231,7 +231,7 @@
<!-- Features (if available) -->
<div v-if="features.length > 0" class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">Features</h2>
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.features') }}</h2>
<ul class="space-y-3">
<li
v-for="(feature, index) in features"
@@ -251,26 +251,26 @@
<div class="space-y-6">
<!-- App Info Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Information</h3>
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.information') }}</h3>
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">Version</span>
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.version }}</span>
</div>
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">Developer</span>
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.author }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">Status</span>
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
<span class="text-white font-medium capitalize">{{ pkg.state }}</span>
</div>
<div v-if="pkg.manifest.license" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">License</span>
<span class="text-white/60 text-sm">{{ t('common.license') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.license }}</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="text-white/60 text-sm">Category</span>
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
<span class="text-white font-medium">App</span>
</div>
</div>
@@ -278,19 +278,19 @@
<!-- Fedimint Services Card -->
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Services</h3>
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 py-2 border-b border-white/10">
<span class="w-2 h-2 rounded-full" :class="pkg.state === 'running' ? 'bg-green-400' : 'bg-yellow-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">Guardian</p>
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.guardian') }}</p>
<p class="text-white/50 text-xs capitalize">{{ pkg.state }}</p>
</div>
</div>
<div class="flex items-center gap-3 py-2">
<span class="w-2 h-2 rounded-full" :class="gatewayState === 'running' ? 'bg-green-400' : gatewayState === 'stopped' ? 'bg-yellow-400' : 'bg-red-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">Gateway</p>
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.gateway') }}</p>
<p class="text-white/50 text-xs capitalize">{{ gatewayState }}</p>
</div>
</div>
@@ -299,14 +299,14 @@
<!-- Access (LAN + Tor) Card -->
<div v-if="interfaceAddresses" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Access</h3>
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.access') }}</h3>
<div class="space-y-3">
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-white/80 font-medium">LAN</p>
<p class="text-white/80 font-medium">{{ t('appDetails.lan') }}</p>
<a
:href="lanUrl"
target="_blank"
@@ -322,9 +322,9 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-white/80 font-medium">Tor</p>
<p class="text-white/80 font-medium">{{ t('appDetails.tor') }}</p>
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
<p class="text-white/50 text-xs mt-1">Requires Tor Browser</p>
<p class="text-white/50 text-xs mt-1">{{ t('appDetails.requiresTor') }}</p>
</div>
</div>
</div>
@@ -332,15 +332,15 @@
<!-- Requirements Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
<div class="space-y-3">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">RAM</p>
<p class="text-white/60 text-sm">Minimum 512MB</p>
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
</div>
</div>
<div class="flex items-start gap-3">
@@ -348,8 +348,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">Storage</p>
<p class="text-white/60 text-sm">~100MB</p>
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
</div>
</div>
</div>
@@ -357,7 +357,7 @@
<!-- Links Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Links</h3>
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
<div class="space-y-2">
<a
v-if="pkg.manifest.website"
@@ -369,7 +369,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
Website
{{ t('appDetails.website') }}
</a>
<a
href="#"
@@ -378,7 +378,7 @@
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Source Code
{{ t('appDetails.sourceCode') }}
</a>
<a
href="#"
@@ -387,7 +387,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Documentation
{{ t('appDetails.documentation') }}
</a>
</div>
</div>
@@ -400,8 +400,8 @@
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
<p class="text-white/70">The requested application could not be found</p>
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('appDetails.notFoundTitle') }}</h3>
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
</div>
<!-- Uninstall Confirmation Modal -->
@@ -424,10 +424,9 @@
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
<p class="text-white/70 text-sm">
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
This will remove the app and stop its container.
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
</p>
</div>
</div>
@@ -437,13 +436,13 @@
@click="closeUninstallModal()"
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
>
Cancel
{{ t('common.cancel') }}
</button>
<button
@click="confirmUninstall"
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
>
Uninstall
{{ t('common.uninstall') }}
</button>
</div>
</div>
@@ -455,6 +454,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import { PackageState } from '../types/api'
@@ -464,6 +464,7 @@ import { dummyApps } from '../utils/dummyApps'
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const { t } = useI18n()
const appId = computed(() => route.params.id as string)
@@ -594,11 +595,11 @@ useModalKeyboard(
const backButtonText = computed(() => {
// Check if we came from marketplace via query parameter
if (route.query.from === 'marketplace') {
return 'Back to App Store'
return t('appDetails.backToStore')
}
// Default to My Apps
return 'Back to My Apps'
return t('appDetails.backToApps')
})
// Check if app has a UI interface and is running
@@ -827,7 +828,7 @@ async function confirmUninstall() {
router.push('/dashboard/apps').catch(() => {})
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
alert('Failed to uninstall app')
alert(t('common.error'))
}
}

View File

@@ -1,8 +1,8 @@
<template>
<div class="pb-6">
<div class="hidden md:block mb-8">
<h1 class="text-3xl font-bold text-white mb-2">My Apps</h1>
<p class="text-white/70">Manage your installed applications</p>
<h1 class="text-3xl font-bold text-white mb-2">{{ t('apps.title') }}</h1>
<p class="text-white/70">{{ t('apps.subtitle') }}</p>
</div>
<!-- Search Bar -->
@@ -10,8 +10,8 @@
<input
v-model="searchQuery"
type="text"
placeholder="Search installed apps..."
aria-label="Search installed apps"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
@@ -22,20 +22,20 @@
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 class="text-xl font-semibold text-white mb-2">No Apps Installed</h3>
<p class="text-white/70 mb-6">Get started by browsing the app store</p>
<h3 class="text-xl font-semibold text-white mb-2">{{ t('apps.noAppsTitle') }}</h3>
<p class="text-white/70 mb-6">{{ t('apps.noAppsMessage') }}</p>
<RouterLink
to="/dashboard/marketplace"
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
>
Browse App Store
{{ t('apps.browseAppStore') }}
</RouterLink>
</div>
</div>
<!-- No Results -->
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
<p class="text-white/70">No apps matching "{{ searchQuery }}"</p>
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
</div>
<!-- Apps Grid (alphabetically by title, stable across run state) -->
@@ -56,8 +56,8 @@
<button
@click.stop="showUninstallModal(id as string, pkg)"
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
:aria-label="`Uninstall ${pkg.manifest?.title || id}`"
title="Uninstall"
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
:title="t('common.uninstall')"
>
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@@ -100,7 +100,7 @@
@click.stop="launchApp(id as string)"
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
Launch
{{ t('common.launch') }}
</button>
<button
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@@ -119,7 +119,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ loadingActions[id as string] ? 'Starting...' : 'Start' }}</span>
<span>{{ loadingActions[id as string] ? t('common.starting') : t('common.start') }}</span>
</button>
<button
v-if="pkg.state === 'running' || pkg.state === 'starting'"
@@ -138,7 +138,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ loadingActions[id as string] ? 'Stopping...' : 'Stop' }}</span>
<span>{{ loadingActions[id as string] ? t('common.stopping') : t('common.stop') }}</span>
</button>
</div>
</div>
@@ -167,10 +167,9 @@
</svg>
</div>
<div class="flex-1">
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
<p class="text-white/70">
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
This will remove the app and stop its container.
{{ t('apps.uninstallConfirm', { name: uninstallModal.appTitle }) }}
</p>
</div>
</div>
@@ -180,13 +179,13 @@
@click="closeUninstallModal()"
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
>
Cancel
{{ t('common.cancel') }}
</button>
<button
@click="confirmUninstall"
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
>
Uninstall
{{ t('common.uninstall') }}
</button>
</div>
</div>
@@ -198,7 +197,7 @@
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span>
<button @click="actionError = ''" aria-label="Dismiss error" class="text-red-300 hover:text-white shrink-0">&times;</button>
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
</div>
</Transition>
@@ -208,7 +207,10 @@
<script setup lang="ts">
import { computed, ref, onBeforeUnmount } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
const { t } = useI18n()
import { useAppLauncherStore } from '../stores/appLauncher'
import { PackageState, type PackageDataEntry } from '../types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'

View File

@@ -2,21 +2,21 @@
<div class="chat-fullscreen">
<!-- Close button + connection indicator (desktop: top-right pill) -->
<div class="chat-mode-pill hidden md:flex">
<button class="chat-close-btn" aria-label="Close AI Assistant" @click="closeChat">
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="text-xs font-medium">Close</span>
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
</button>
<div
v-if="aiuiConnected"
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
title="AIUI connected"
:title="t('chat.aiuiConnected')"
/>
</div>
<!-- Mobile back button -->
<button class="chat-mobile-back md:hidden" aria-label="Go back" @click="closeChat">
<button class="chat-mobile-back md:hidden" :aria-label="t('common.goBack')" @click="closeChat">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
@@ -27,7 +27,7 @@
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
<div class="glass-card p-8 flex flex-col items-center gap-4">
<div class="chat-loading-spinner" aria-hidden="true" />
<p class="text-sm text-white/60">Loading AI assistant...</p>
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
</div>
</div>
</Transition>
@@ -37,7 +37,7 @@
v-if="aiuiUrl"
ref="aiuiFrame"
:src="aiuiUrl"
title="AI Assistant"
:title="t('chat.aiAssistant')"
class="chat-iframe chat-iframe-mobile"
sandbox="allow-scripts allow-same-origin allow-forms"
allow="microphone"
@@ -52,12 +52,12 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h2 class="text-2xl font-semibold text-white mb-2">AI Assistant</h2>
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('chat.aiAssistant') }}</h2>
<p class="text-white/60 mb-4 leading-relaxed">
AI Assistant is not yet configured on this node.
{{ t('chat.notConfigured') }}
</p>
<p class="text-xs text-white/30">
Deploy the AIUI app from the App Store to enable this feature.
{{ t('chat.deployCta') }}
</p>
</div>
</div>
@@ -68,8 +68,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ContextBroker } from '@/services/contextBroker'
const { t } = useI18n()
const router = useRouter()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)

View File

@@ -8,18 +8,18 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back
{{ t('containerDetails.back') }}
</button>
<div class="flex items-start justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">{{ appName }}</h1>
<p class="text-white/70">Container details and management</p>
<p class="text-white/70">{{ t('containerDetails.subtitle') }}</p>
</div>
<ContainerStatus
v-if="container"
:state="container.state as any"
:health="healthStatus as any"
:state="container.state as ContainerStateValue"
:health="healthStatus as HealthStatusValue"
/>
</div>
</div>
@@ -41,22 +41,22 @@
<div v-else-if="container" key="content" class="space-y-6">
<!-- Container Info Card -->
<div class="glass-card p-6">
<h2 class="text-xl font-semibold text-white mb-4">Container Information</h2>
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.containerInfo') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span class="text-sm text-white/60">Container ID</span>
<span class="text-sm text-white/60">{{ t('containerDetails.containerId') }}</span>
<p class="text-white/90 font-mono text-sm mt-1">{{ container.id }}</p>
</div>
<div>
<span class="text-sm text-white/60">Image</span>
<span class="text-sm text-white/60">{{ t('containerDetails.image') }}</span>
<p class="text-white/90 text-sm mt-1">{{ container.image }}</p>
</div>
<div>
<span class="text-sm text-white/60">State</span>
<span class="text-sm text-white/60">{{ t('containerDetails.state') }}</span>
<p class="text-white/90 text-sm mt-1 capitalize">{{ container.state }}</p>
</div>
<div>
<span class="text-sm text-white/60">Created</span>
<span class="text-sm text-white/60">{{ t('containerDetails.created') }}</span>
<p class="text-white/90 text-sm mt-1">{{ formatDate(container.created) }}</p>
</div>
</div>
@@ -64,7 +64,7 @@
<!-- Actions Card -->
<div class="glass-card p-6">
<h2 class="text-xl font-semibold text-white mb-4">Actions</h2>
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.actions') }}</h2>
<div class="flex gap-4">
<button
v-if="container.state !== 'running'"
@@ -72,7 +72,7 @@
:disabled="actionLoading"
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
Start Container
{{ t('containerDetails.startContainer') }}
</button>
<button
v-else
@@ -80,21 +80,21 @@
:disabled="actionLoading"
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
Stop Container
{{ t('containerDetails.stopContainer') }}
</button>
<button
@click="handleRestart"
:disabled="actionLoading || container.state !== 'running'"
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
Restart
{{ t('common.restart') }}
</button>
<button
@click="handleRemove"
:disabled="actionLoading"
class="px-6 py-3 glass-button rounded-lg font-medium text-red-400/90 hover:text-red-400 transition-colors disabled:opacity-50"
>
Remove
{{ t('common.remove') }}
</button>
</div>
</div>
@@ -102,21 +102,21 @@
<!-- Logs Card -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-white">Logs</h2>
<h2 class="text-xl font-semibold text-white">{{ t('containerDetails.logs') }}</h2>
<button
@click="refreshLogs"
:disabled="logsLoading"
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
Refresh
{{ t('common.refresh') }}
</button>
</div>
<div class="bg-black/40 rounded-lg p-4 font-mono text-sm text-white/80 max-h-96 overflow-y-auto">
<div v-if="logsLoading" class="text-center py-4 text-white/60">
Loading logs...
{{ t('containerDetails.loadingLogs') }}
</div>
<div v-else-if="logs.length === 0" class="text-center py-4 text-white/60">
No logs available
{{ t('containerDetails.noLogs') }}
</div>
<div v-else>
<div v-for="(log, index) in logs" :key="index" class="mb-1">
@@ -133,12 +133,18 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useContainerStore } from '@/stores/container'
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
import ContainerStatus from '@/components/ContainerStatus.vue'
type ContainerStateValue = 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
type HealthStatusValue = 'healthy' | 'unhealthy' | 'unknown' | 'starting'
const route = useRoute()
const router = useRouter()
const store = useContainerStore()
const { t } = useI18n()
const appId = computed(() => route.params.id as string)
const appName = computed(() => {
@@ -148,7 +154,7 @@ const appName = computed(() => {
.join(' ')
})
const container = ref<any>(null)
const container = ref<ContainerStatusData | null>(null)
const logs = ref<string[]>([])
const loading = ref(false)
const logsLoading = ref(false)
@@ -169,7 +175,7 @@ async function loadContainer() {
const status = await store.getContainerStatus(appId.value)
container.value = status
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load container'
error.value = e instanceof Error ? e.message : t('common.error')
} finally {
loading.value = false
}
@@ -180,7 +186,7 @@ async function loadLogs() {
try {
logs.value = await store.getContainerLogs(appId.value, 100)
} catch (e) {
console.error('Failed to load logs:', e)
if (import.meta.env.DEV) console.error('Failed to load logs:', e)
} finally {
logsLoading.value = false
}
@@ -202,7 +208,7 @@ async function handleStart() {
await loadContainer()
await loadHealthStatus()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to start container'
error.value = e instanceof Error ? e.message : t('common.error')
} finally {
actionLoading.value = false
}
@@ -214,7 +220,7 @@ async function handleStop() {
await store.stopContainer(appId.value)
await loadContainer()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to stop container'
error.value = e instanceof Error ? e.message : t('common.error')
} finally {
actionLoading.value = false
}
@@ -229,14 +235,14 @@ async function handleRestart() {
await loadContainer()
await loadHealthStatus()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to restart container'
error.value = e instanceof Error ? e.message : t('common.error')
} finally {
actionLoading.value = false
}
}
async function handleRemove() {
if (!confirm(`Are you sure you want to remove ${appName.value}? This will delete the container and all its data.`)) {
if (!confirm(t('apps.uninstallConfirm', { name: appName.value }))) {
return
}
@@ -245,7 +251,7 @@ async function handleRemove() {
await store.removeContainer(appId.value)
router.push('/dashboard/apps')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to remove container'
error.value = e instanceof Error ? e.message : t('common.error')
} finally {
actionLoading.value = false
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
<!-- Skip to main content link for keyboard users -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<a href="#main-content" class="skip-to-content">{{ t('common.skipToContent') }}</a>
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
<div class="bg-perspective-container">
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
@@ -76,7 +76,7 @@
</div>
</div>
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" aria-label="Main navigation">
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" :aria-label="t('dashboard.mainNav')">
<RouterLink
v-for="(item, idx) in desktopNavItems"
:key="item.path"
@@ -296,7 +296,7 @@
<nav
ref="mobileTabBar"
data-mobile-tab-bar
aria-label="Mobile navigation"
:aria-label="t('dashboard.mobileNav')"
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
:class="{ 'glass-throw-tabbar': showZoomIn }"
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding-bottom: env(safe-area-inset-bottom, 0px);"
@@ -381,7 +381,10 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { RouterLink, RouterView, useRouter, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
const { t } = useI18n()
import { useLoginTransitionStore } from '../stores/loginTransition'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import OnlineStatusPill from '@/components/OnlineStatusPill.vue'

View File

@@ -5,12 +5,12 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Back to Goals</span>
<span>{{ t('goalDetail.backToGoals') }}</span>
</button>
<!-- Goal not found -->
<div v-if="!goal" class="glass-card p-12 text-center">
<p class="text-white/70">Goal not found.</p>
<p class="text-white/70">{{ t('goalDetail.notFound') }}</p>
</div>
<!-- Goal wizard -->
@@ -23,7 +23,7 @@
<!-- Progress bar -->
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-white/60">Step {{ currentStepDisplay }} of {{ goal.steps.length }}</span>
<span class="text-sm text-white/60">{{ t('goalDetail.stepOf', { current: currentStepDisplay, total: goal.steps.length }) }}</span>
<span class="goal-status-badge" :class="statusBadgeClass">{{ statusLabel }}</span>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
@@ -40,10 +40,9 @@
v-if="showSyncMessage"
class="glass-card p-6 mb-6 border-l-4 border-orange-400"
>
<h3 class="text-lg font-semibold text-white mb-1">Sovereignty takes a little patience</h3>
<h3 class="text-lg font-semibold text-white mb-1">{{ t('goalDetail.syncTitle') }}</h3>
<p class="text-white/60 text-sm leading-relaxed">
Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else.
This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.
{{ t('goalDetail.syncMessage') }}
</p>
</div>
@@ -94,28 +93,28 @@
:disabled="isInstalling"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
{{ isInstalling ? 'Installing...' : `Install ${step.title.replace('Install ', '')}` }}
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
</button>
<button
v-else-if="step.action === 'configure'"
@click="openConfigureStep(step)"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
Open &amp; Configure
{{ t('goalDetail.openAndConfigure') }}
</button>
<button
v-else-if="step.action === 'info'"
@click="completeInfoStep(step)"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
I've Done This
{{ t('goalDetail.iveDoneThis') }}
</button>
<button
v-else-if="isStepCompleted(step) || isAppInstalled(step.appId || '')"
disabled
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium opacity-50"
>
Complete
{{ t('goalDetail.complete') }}
</button>
</div>
</div>
@@ -131,10 +130,10 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-xl font-semibold text-white mb-2">All Set!</h2>
<p class="text-white/60 mb-6">{{ goal.title }} is ready to go.</p>
<h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2>
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
<RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
View My Services
{{ t('goalDetail.viewMyServices') }}
</RouterLink>
</div>
</template>
@@ -144,11 +143,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useGoalStore } from '@/stores/goals'
import { getGoalById } from '@/data/goals'
import type { GoalStep } from '@/types/goals'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
@@ -193,9 +194,9 @@ const progressPercent = computed(() => {
})
const statusLabel = computed(() => {
if (overallStatus.value === 'completed') return 'Completed'
if (overallStatus.value === 'in-progress') return 'In Progress'
return 'Not Started'
if (overallStatus.value === 'completed') return t('goalDetail.completed')
if (overallStatus.value === 'in-progress') return t('goalDetail.inProgress')
return t('goalDetail.notStarted')
})
const statusBadgeClass = computed(() => {
@@ -251,7 +252,7 @@ async function installApp(step: GoalStep) {
await appStore.installPackage(step.appId, '', 'latest')
goalStore.completeStep(goalId.value, step.id)
} catch (err) {
console.error('[GoalDetail] Install failed:', err)
if (import.meta.env.DEV) console.error('[GoalDetail] Install failed:', err)
} finally {
isInstalling.value = false
}

View File

@@ -15,8 +15,8 @@
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">Dashboard</button>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">Setup</button>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
</div>
</div>
@@ -32,13 +32,13 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Update Available: v{{ updateVersion }}</p>
<p class="text-sm font-medium text-white">{{ t('home.updateAvailable', { version: updateVersion }) }}</p>
<p v-if="updateChangelog" class="text-xs text-white/60 truncate">{{ updateChangelog }}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<RouterLink to="/dashboard/settings/update" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
Update Now
{{ t('home.updateNow') }}
</RouterLink>
<button @click="dismissUpdate" aria-label="Dismiss update notification" class="text-white/40 hover:text-white/80 transition-colors p-1" title="Dismiss">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -56,8 +56,8 @@
role="tablist"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">Dashboard</button>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">Setup</button>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
</div>
<!-- Setup tab: goal-based cards -->
@@ -85,10 +85,10 @@
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">My Apps</h2>
<p class="text-sm text-white/70">Manage your installed applications</p>
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.myApps') }}</h2>
<p class="text-sm text-white/70">{{ t('home.myAppsDesc') }}</p>
</div>
<RouterLink to="/dashboard/apps" aria-label="Go to My Apps" class="text-white/60 hover:text-white transition-colors">
<RouterLink to="/dashboard/apps" :aria-label="t('home.goToApps')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@@ -96,20 +96,20 @@
</div>
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Installed</p>
<p class="text-xs text-white/60 mb-1">{{ t('home.installed') }}</p>
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Running</p>
<p class="text-xs text-white/60 mb-1">{{ t('home.runningLabel') }}</p>
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Browse Store
{{ t('home.browseStore') }}
</RouterLink>
<RouterLink to="/dashboard/apps" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Manage Apps
{{ t('home.manageApps') }}
</RouterLink>
</div>
</div>
@@ -128,10 +128,10 @@
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
<p class="text-sm text-white/70">Cloud services and storage</p>
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.cloud') }}</h2>
<p class="text-sm text-white/70">{{ t('home.cloudDesc') }}</p>
</div>
<RouterLink to="/dashboard/cloud" aria-label="Go to Cloud" class="text-white/60 hover:text-white transition-colors">
<RouterLink to="/dashboard/cloud" :aria-label="t('home.goToCloud')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@@ -139,20 +139,20 @@
</div>
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Storage Used</p>
<p class="text-xs text-white/60 mb-1">{{ t('home.storageUsed') }}</p>
<p class="text-2xl font-bold text-white">{{ cloudStorageDisplay }}</p>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Folders</p>
<p class="text-xs text-white/60 mb-1">{{ t('home.folders') }}</p>
<p class="text-2xl font-bold text-white">{{ cloudFolderDisplay }}</p>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/cloud" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
View Folders
{{ t('home.viewFolders') }}
</RouterLink>
<button @click="uploadFiles" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Upload Files
{{ t('home.uploadFiles') }}
</button>
</div>
</div>
@@ -171,10 +171,10 @@
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
<p class="text-sm text-white/70">Network infrastructure and Web3 services</p>
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.network') }}</h2>
<p class="text-sm text-white/70">{{ t('home.networkDesc') }}</p>
</div>
<RouterLink to="/dashboard/server" aria-label="Go to Network" class="text-white/60 hover:text-white transition-colors">
<RouterLink to="/dashboard/server" :aria-label="t('home.goToNetwork')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@@ -184,28 +184,28 @@
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="servicesDotColor"></div>
<span class="text-sm text-white/80">Services Status</span>
<span class="text-sm text-white/80">{{ t('home.servicesStatus') }}</span>
</div>
<span class="text-sm font-medium" :class="servicesStatusColor">{{ servicesStatusText }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="connectivityDotColor"></div>
<span class="text-sm text-white/80">Connectivity</span>
<span class="text-sm text-white/80">{{ t('home.connectivity') }}</span>
</div>
<span class="text-sm font-medium" :class="connectivityColor">{{ connectivityText }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
<span class="text-sm text-white/80">Running Apps</span>
<span class="text-sm text-white/80">{{ t('home.runningApps') }}</span>
</div>
<span class="text-sm text-white/80 font-medium">{{ runningCount }}</span>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/server" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Manage Network
{{ t('home.manageNetwork') }}
</RouterLink>
</div>
</div>
@@ -224,10 +224,10 @@
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">Web5</h2>
<p class="text-sm text-white/70">Decentralized identity and data protocols</p>
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.web5') }}</h2>
<p class="text-sm text-white/70">{{ t('home.web5Desc') }}</p>
</div>
<RouterLink to="/dashboard/web5" aria-label="Go to Web5" class="text-white/60 hover:text-white transition-colors">
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@@ -237,28 +237,28 @@
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="web5DidStatus === 'Active' ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm text-white/80">DID Status</span>
<span class="text-sm text-white/80">{{ t('home.didStatus') }}</span>
</div>
<span class="text-sm font-medium" :class="web5DidStatus === 'Active' ? 'text-green-400' : 'text-white/50'">{{ web5DidStatus }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="web5DwnStatus === 'Synced' ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm text-white/80">DWN Sync</span>
<span class="text-sm text-white/80">{{ t('home.dwnSync') }}</span>
</div>
<span class="text-sm font-medium" :class="web5DwnStatus === 'Synced' ? 'text-green-400' : 'text-white/50'">{{ web5DwnStatus }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-white/30"></div>
<span class="text-sm text-white/80">Credentials</span>
<span class="text-sm text-white/80">{{ t('home.credentials') }}</span>
</div>
<span class="text-sm text-white/50 font-medium">{{ web5CredentialCount }}</span>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/web5" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Manage Web5
{{ t('home.manageWeb5') }}
</RouterLink>
</div>
</div>
@@ -276,10 +276,10 @@
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">System</h2>
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
<p class="text-sm text-white/70">{{ systemUptimeDisplay }}</p>
</div>
<RouterLink to="/dashboard/server" aria-label="Go to System" class="text-white/60 hover:text-white transition-colors">
<RouterLink to="/dashboard/server" :aria-label="t('home.goToSettings')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@@ -298,7 +298,7 @@
<template v-else>
<div class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/60">CPU</p>
<p class="text-xs text-white/60">{{ t('home.cpu') }}</p>
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.cpuPercent)">{{ systemStats.cpuPercent.toFixed(0) }}%</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
@@ -307,7 +307,7 @@
</div>
<div class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/60">RAM</p>
<p class="text-xs text-white/60">{{ t('home.ram') }}</p>
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.memPercent)">{{ formatBytes(systemStats.memUsed) }} / {{ formatBytes(systemStats.memTotal) }}</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
@@ -316,7 +316,7 @@
</div>
<div class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/60">Disk</p>
<p class="text-xs text-white/60">{{ t('home.disk') }}</p>
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.diskPercent)">{{ formatBytes(systemStats.diskUsed) }} / {{ formatBytes(systemStats.diskTotal) }}</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
@@ -341,8 +341,8 @@
<div class="home-card-inner px-6 py-6">
<div class="flex items-start justify-between mb-2">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">Quick Start Goals</h2>
<p class="text-sm text-white/60 mb-4">Not sure where to start? Try a guided setup.</p>
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
</div>
<button
@click="dismissQuickStart"
@@ -374,7 +374,7 @@
<!-- Chat Mode: redirect to Chat view -->
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">
Open AI Assistant
{{ t('home.openAI') }}
</RouterLink>
</div>
</div>
@@ -383,7 +383,10 @@
<script setup lang="ts">
import { computed, reactive, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
const { t } = useI18n()
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useLoginTransitionStore } from '../stores/loginTransition'
import { useUIModeStore } from '@/stores/uiMode'
@@ -407,8 +410,8 @@ const QUICK_START_RESHOW_LOGINS = 5
const store = useAppStore()
const loginTransition = useLoginTransitionStore()
const LINE1 = "Welcome Noderunner"
const LINE2 = "Here's an overview of your sovereign life"
const LINE1 = t('home.title')
const LINE2 = t('home.subtitle')
const MS_PER_CHAR = 55
const displayLine1 = ref('')
@@ -481,8 +484,8 @@ const servicesAllRunning = computed(() =>
appCount.value > 0 && runningCount.value === appCount.value
)
const servicesStatusText = computed(() => {
if (appCount.value === 0) return 'No Apps'
return servicesAllRunning.value ? 'All Running' : `${runningCount.value}/${appCount.value} Running`
if (appCount.value === 0) return t('home.noApps')
return servicesAllRunning.value ? t('home.allRunning') : `${runningCount.value}/${appCount.value} ${t('home.runningLabel')}`
})
const servicesStatusColor = computed(() =>
appCount.value === 0 ? 'text-white/60' : servicesAllRunning.value ? 'text-green-400' : 'text-yellow-400'
@@ -490,7 +493,7 @@ const servicesStatusColor = computed(() =>
const servicesDotColor = computed(() =>
appCount.value === 0 ? 'bg-white/40' : servicesAllRunning.value ? 'bg-green-400' : 'bg-yellow-400'
)
const connectivityText = computed(() => store.isConnected ? 'Connected' : 'Disconnected')
const connectivityText = computed(() => store.isConnected ? t('common.connected') : t('common.disconnected'))
const connectivityColor = computed(() => store.isConnected ? 'text-green-400' : 'text-red-400')
const connectivityDotColor = computed(() => store.isConnected ? 'bg-green-400' : 'bg-red-400')
@@ -648,7 +651,7 @@ const systemStats = reactive({
})
const systemUptimeDisplay = computed(() => {
if (systemStats.uptimeSecs === 0) return 'System monitoring'
if (systemStats.uptimeSecs === 0) return t('home.systemMonitoring')
const days = Math.floor(systemStats.uptimeSecs / 86400)
const hours = Math.floor((systemStats.uptimeSecs % 86400) / 3600)
if (days > 0) return `Uptime: ${days}d ${hours}h`

View File

@@ -0,0 +1,131 @@
<template>
<div class="min-h-screen bg-black flex items-center justify-center p-6">
<div class="glass-card p-8 w-full max-w-lg">
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-white mb-1">{{ t('kioskRecovery.title') }}</h1>
<p class="text-sm text-white/50">{{ t('kioskRecovery.subtitle') }}</p>
</div>
<!-- Server IP -->
<div class="bg-white/5 rounded-lg p-4 mb-4">
<div class="text-xs text-white/50 mb-1">{{ t('kioskRecovery.serverAddress') }}</div>
<div class="text-lg font-mono text-white font-medium">{{ serverIp || t('common.loading') }}</div>
<div v-if="serverIp" class="text-xs text-white/40 mt-1">{{ t('kioskRecovery.webUi', { address: serverIp }) }}</div>
</div>
<!-- QR Code -->
<div v-if="serverIp" class="bg-white/5 rounded-lg p-4 mb-4 flex flex-col items-center">
<div class="text-xs text-white/50 mb-2">{{ t('kioskRecovery.scanForMobile') }}</div>
<div class="bg-white p-3 rounded-lg inline-block">
<img :src="qrCodeUrl" alt="QR Code" class="w-32 h-32" />
</div>
</div>
<!-- Diagnostics -->
<div class="space-y-2 mb-6">
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
<span class="text-sm text-white/70">{{ t('kioskRecovery.backend') }}</span>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="backendHealthy ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm" :class="backendHealthy ? 'text-green-400' : 'text-red-400'">
{{ backendHealthy ? t('common.healthy') : t('kioskRecovery.unreachable') }}
</span>
</div>
</div>
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
<span class="text-sm text-white/70">{{ t('kioskRecovery.containers') }}</span>
<span class="text-sm text-white font-medium">{{ containerCount }}</span>
</div>
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
<span class="text-sm text-white/70">{{ t('monitoring.diskUsage') }}</span>
<span class="text-sm text-white font-medium">{{ diskUsage }}</span>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">
{{ t('common.refresh') }}
</button>
<button @click="goToLogin" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30">
{{ t('kioskRecovery.goToLogin') }}
</button>
</div>
<div class="text-center mt-4">
<p class="text-xs text-white/30">{{ t('kioskRecovery.lastChecked', { time: lastChecked }) }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
const serverIp = ref('')
const backendHealthy = ref(false)
const containerCount = ref('—')
const diskUsage = ref('—')
const lastChecked = ref('—')
const qrCodeUrl = computed(() => {
if (!serverIp.value) return ''
const url = `http://${serverIp.value}`
return `https://api.qrserver.com/v1/create-qr-code/?size=128x128&data=${encodeURIComponent(url)}`
})
async function refreshDiagnostics() {
lastChecked.value = new Date().toLocaleTimeString()
// Detect server IP from window location
serverIp.value = window.location.hostname !== 'localhost'
? window.location.hostname
: '127.0.0.1'
// Check backend health
try {
const res = await fetch('/health', { signal: AbortSignal.timeout(5000) })
backendHealthy.value = res.ok
} catch {
backendHealthy.value = false
}
// Get system stats (unauthenticated won't work for RPC, but try health)
if (backendHealthy.value) {
try {
const statsRes = await fetch('/rpc/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'system.stats' }),
signal: AbortSignal.timeout(5000),
})
const data = await statsRes.json()
if (data.result) {
const disk = data.result.disk
if (disk) {
const usedPct = ((disk.used / disk.total) * 100).toFixed(0)
diskUsage.value = `${usedPct}% used`
}
containerCount.value = String(data.result.containers?.running ?? '—')
}
} catch {
// Stats require auth — show defaults
containerCount.value = '—'
diskUsage.value = '—'
}
}
}
function goToLogin() {
router.push('/login')
}
onMounted(() => {
refreshDiagnostics()
})
</script>

View File

@@ -15,8 +15,8 @@
<!-- Title -->
<h1 class="text-2xl font-semibold text-white/96 text-center mb-8 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
<span v-if="isSetupMode && !isSetup">Set Up Your Node</span>
<span v-else>Welcome to Archipelago</span>
<span v-if="isSetupMode && !isSetup">{{ t('login.setupTitle') }}</span>
<span v-else>{{ t('login.title') }}</span>
</h1>
<!-- Server Startup Progress -->
@@ -26,7 +26,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm text-white/60">Server starting up...</span>
<span class="text-sm text-white/60">{{ t('login.serverStarting') }}</span>
</div>
<div class="startup-progress-track">
<div class="startup-progress-bar" :style="{ width: startupProgress + '%' }"></div>
@@ -47,14 +47,14 @@
<div class="mb-4">
<label for="setup-password" class="block text-sm font-medium text-white/80 mb-2">
Password
{{ t('login.password') }}
</label>
<input
id="setup-password"
v-model="password"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
placeholder="Enter a password (min 8 characters)"
:placeholder="t('login.enterPasswordSetup')"
@keyup.enter="handleSetupWithSound"
:disabled="loading || formDisabled"
/>
@@ -62,14 +62,14 @@
<div class="mb-6">
<label for="setup-confirm-password" class="block text-sm font-medium text-white/80 mb-2">
Confirm Password
{{ t('login.confirmPassword') }}
</label>
<input
id="setup-confirm-password"
v-model="confirmPassword"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
placeholder="Confirm your password"
:placeholder="t('login.confirmPasswordPlaceholder')"
@keyup.enter="handleSetupWithSound"
:disabled="loading || formDisabled"
/>
@@ -80,13 +80,13 @@
:disabled="loading || formDisabled || !password || password.length < 8 || password !== confirmPassword"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="!loading">Set Up Node</span>
<span v-if="!loading">{{ t('login.setupButton') }}</span>
<span v-else class="flex items-center justify-center">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Setting up...
{{ t('login.settingUp') }}
</span>
</button>
</template>
@@ -97,8 +97,8 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto mb-3 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<p class="text-white/80 text-sm mb-1">Two-Factor Authentication</p>
<p class="text-white/50 text-xs">Enter the 6-digit code from your authenticator app</p>
<p class="text-white/80 text-sm mb-1">{{ t('login.twoFactorTitle') }}</p>
<p class="text-white/50 text-xs">{{ t('login.totpInstruction') }}</p>
</div>
<div class="mb-4">
@@ -110,7 +110,7 @@
pattern="[0-9]*"
maxlength="8"
autocomplete="one-time-code"
aria-label="Two-factor authentication code"
:aria-label="t('login.totpLabel')"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
@keyup.enter="handleTotpVerify"
@@ -123,13 +123,13 @@
:disabled="loading || !totpCode"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed mb-3"
>
<span v-if="!loading">Verify</span>
<span v-if="!loading">{{ t('login.verifyButton') }}</span>
<span v-else class="flex items-center justify-center">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verifying...
{{ t('login.verifying') }}
</span>
</button>
@@ -137,7 +137,7 @@
@click="useBackupCode = !useBackupCode; totpCode = ''"
class="w-full text-white/50 text-sm hover:text-white/70 transition-colors py-2"
>
{{ useBackupCode ? 'Use authenticator code' : 'Use a backup code instead' }}
{{ useBackupCode ? t('login.useAuthCode') : t('login.useBackupCode') }}
</button>
</template>
@@ -145,14 +145,14 @@
<template v-else>
<div class="mb-6">
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
Password
{{ t('login.password') }}
</label>
<input
id="login-password"
v-model="password"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
placeholder="Enter your password"
:placeholder="t('login.enterPasswordPlaceholder')"
@keyup.enter="handleLoginWithSound"
:disabled="loading || formDisabled"
/>
@@ -163,20 +163,20 @@
:disabled="loading || formDisabled || !password"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="!loading">Login</span>
<span v-if="!loading">{{ t('login.loginButton') }}</span>
<span v-else class="flex items-center justify-center">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Logging in...
{{ t('login.loggingIn') }}
</span>
</button>
</template>
<!-- Footer Links -->
<div class="mt-6 text-center text-sm text-white/40">
Password recovery requires SSH access to the server.
{{ t('login.recoveryNote') }}
</div>
</div>
@@ -186,7 +186,7 @@
@click="replayIntro"
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline"
>
Replay Intro
{{ t('login.replayIntro') }}
</button>
<span class="text-white/30">|</span>
<button
@@ -194,7 +194,7 @@
:disabled="isResettingOnboarding"
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isResettingOnboarding ? 'Resetting...' : 'Onboarding' }}
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
</button>
</div>
</div>
@@ -204,8 +204,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { useAppStore } from '../stores/app'
const { t } = useI18n()
import { useLoginTransitionStore } from '../stores/loginTransition'
import { rpcClient } from '../api/rpc-client'
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
@@ -350,12 +353,12 @@ function handleSetupWithSound() {
async function handleSetup() {
if (!password.value || password.value.length < 8) {
error.value = 'Password must be at least 8 characters'
error.value = t('login.errorMinLength')
return
}
if (password.value !== confirmPassword.value) {
error.value = 'Passwords do not match'
error.value = t('login.errorMismatch')
return
}
@@ -381,9 +384,9 @@ async function handleSetup() {
whooshAway.value = false
const msg = err instanceof Error ? err.message : ''
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
error.value = 'Server is starting up. Please try again in a moment.'
error.value = t('login.errorServerStarting')
} else {
error.value = msg || 'Setup failed. Please try again.'
error.value = msg || t('login.errorSetupFailed')
}
startSynthwave()
} finally {
@@ -425,9 +428,9 @@ async function handleLogin() {
whooshAway.value = false
const msg = err instanceof Error ? err.message : ''
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
error.value = 'Server is starting up. Please try again in a moment.'
error.value = t('login.errorServerStarting')
} else {
error.value = msg || 'Login failed. Please check your password.'
error.value = msg || t('login.errorLoginFailed')
}
startSynthwave()
} finally {
@@ -464,7 +467,7 @@ async function handleTotpVerify() {
totpCode.value = ''
error.value = msg
} else {
error.value = msg || 'Invalid code. Please try again.'
error.value = msg || t('login.errorInvalidCode')
}
totpCode.value = ''
} finally {

View File

@@ -76,8 +76,8 @@
<div class="hidden md:flex mb-8 items-start justify-between">
<div>
<h1 class="text-4xl font-bold text-white mb-2">App Store</h1>
<p class="text-white/70">Discover and install apps for your new sovereign life</p>
<h1 class="text-4xl font-bold text-white mb-2">{{ t('marketplace.title') }}</h1>
<p class="text-white/70">{{ t('marketplace.subtitle') }}</p>
</div>
</div>
@@ -95,7 +95,7 @@
: 'text-white/60 hover:text-white/80 border border-transparent'
]"
>
Curated
{{ t('marketplace.curatedTab') }}
</button>
<button
@click="marketplaceSource = 'community'; loadNostrMarketplace()"
@@ -108,7 +108,7 @@
: 'text-white/60 hover:text-white/80 border border-transparent'
]"
>
Community
{{ t('marketplace.communityTab') }}
<span v-if="nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">{{ nostrApps.length }}</span>
</button>
</div>
@@ -133,8 +133,8 @@
<input
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="flex-shrink-0 w-64 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
@@ -159,8 +159,8 @@
<input
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
@@ -227,7 +227,7 @@
disabled
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium cursor-not-allowed"
>
Already Installed
{{ t('marketplace.alreadyInstalled') }}
</button>
<button
v-else-if="app.source === 'local' || app.dockerImage"
@@ -241,16 +241,16 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ installingApps.get(app.id)?.message || 'Installing...' }}
{{ installingApps.get(app.id)?.message || t('common.installing') }}
</span>
<span v-else>Install</span>
<span v-else>{{ t('common.install') }}</span>
</button>
<button
v-else
disabled
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
>
Not Available
{{ t('common.notAvailable') }}
</button>
</div>
</div>
@@ -263,14 +263,14 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">{{ marketplaceSource === 'community' ? 'Querying Nostr relays for apps...' : 'Loading apps...' }}</p>
<p class="text-white/70">{{ marketplaceSource === 'community' ? t('marketplace.queryingRelays') : t('common.loading') }}</p>
</div>
<div v-else-if="nostrError && marketplaceSource === 'community'" class="flex flex-col items-center gap-4">
<p class="text-white/70">No community apps discovered yet.</p>
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
<p class="text-white/40 text-sm">{{ nostrError }}</p>
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
</div>
<p v-else class="text-white/70">No apps found in {{ categories.find(c => c.id === selectedCategory)?.name }}{{ searchQuery ? ` matching "${searchQuery}"` : '' }}</p>
<p v-else class="text-white/70">{{ searchQuery && selectedCategory !== 'all' ? t('marketplace.noResults', { category: categories.find(c => c.id === selectedCategory)?.name, query: searchQuery }) : searchQuery ? t('marketplace.noResultsSearch', { query: searchQuery }) : t('marketplace.noResultsCategory', { category: categories.find(c => c.id === selectedCategory)?.name }) }}</p>
</div>
</div>
<!-- End Scrollable Apps Section -->
@@ -298,7 +298,7 @@
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">Filter by Category</h2>
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
<button
@click="closeFilterModal()"
class="text-white/60 hover:text-white transition-colors"
@@ -372,6 +372,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '@/composables/useMarketplaceApp'
@@ -381,22 +382,23 @@ type MarketplaceApp = Partial<MarketplaceAppInfo> & { id: string; trustScore?: n
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const { setCurrentApp } = useMarketplaceApp()
// Category state
const selectedCategory = ref('all')
const categories = [
{ id: 'all', name: 'All' },
{ id: 'community', name: 'Community' },
{ id: 'commerce', name: 'Commerce' },
{ id: 'money', name: 'Money' },
{ id: 'data', name: 'Data' },
{ id: 'home', name: 'Home' },
{ id: 'car', name: 'Auto' },
{ id: 'networking', name: 'Networking' },
{ id: 'other', name: 'Other' }
]
const categories = computed(() => [
{ id: 'all', name: t('marketplace.all') },
{ id: 'community', name: t('marketplace.community') },
{ id: 'commerce', name: t('marketplace.commerce') },
{ id: 'money', name: t('marketplace.money') },
{ id: 'data', name: t('marketplace.data') },
{ id: 'home', name: t('marketplace.homeCategory') },
{ id: 'car', name: t('marketplace.auto') },
{ id: 'networking', name: t('marketplace.networking') },
{ id: 'other', name: t('marketplace.other') }
])
// Installation state - support multiple concurrent installations
interface InstallProgress {
@@ -425,7 +427,7 @@ watch(() => store.packages, (packages) => {
...current,
status: 'downloading',
progress: Math.min(pct, 95),
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : 'Downloading...',
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
})
}
}
@@ -606,7 +608,7 @@ const allApps = computed(() => {
// Only show categories that have at least one app
const categoriesWithApps = computed(() => {
const apps = allApps.value
return categories.filter(cat => {
return categories.value.filter(cat => {
if (cat.id === 'all') return apps.length > 0
return apps.some(app => app.category === cat.id)
})

View File

@@ -5,11 +5,11 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to App Store
{{ t('marketplaceDetails.backToStore') }}
</button>
<!-- Mobile Full-Width Back Button -->
<button
<button
@click="goBack"
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
:style="{
@@ -20,7 +20,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Back to App Store</span>
<span>{{ t('marketplaceDetails.backToStore') }}</span>
</button>
<Transition name="content-fade" mode="out-in">
@@ -30,7 +30,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">Loading app details...</p>
<p class="text-white/70">{{ t('marketplaceDetails.loadingDetails') }}</p>
</div>
<!-- App Details -->
@@ -63,12 +63,12 @@
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
>
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
Installed
{{ t('marketplaceDetails.installed') }}
</span>
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<button
@@ -79,7 +79,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open
{{ t('marketplaceDetails.open') }}
</button>
<button
v-else
@@ -94,7 +94,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ installing ? 'Installing...' : 'Install' }}
{{ installing ? t('common.installing') : t('common.install') }}
</button>
</div>
</div>
@@ -127,7 +127,7 @@
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
>
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
Installed
{{ t('marketplaceDetails.installed') }}
</span>
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
</div>
@@ -144,7 +144,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open
{{ t('marketplaceDetails.open') }}
</button>
<button
v-else
@@ -159,7 +159,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ installing ? 'Installing...' : 'Install' }}
{{ installing ? t('common.installing') : t('common.install') }}
</button>
</div>
@@ -170,7 +170,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-red-200 font-medium text-sm">Installation Failed</p>
<p class="text-red-200 font-medium text-sm">{{ t('marketplaceDetails.installFailed') }}</p>
<p class="text-red-300 text-xs mt-1">{{ installError }}</p>
</div>
</div>
@@ -184,7 +184,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-red-200 font-medium">Installation Failed</p>
<p class="text-red-200 font-medium">{{ t('marketplaceDetails.installFailed') }}</p>
<p class="text-red-300 text-sm mt-1">{{ installError }}</p>
</div>
</div>
@@ -196,7 +196,7 @@
<div class="lg:col-span-2 space-y-6">
<!-- Screenshots Gallery -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">Screenshots</h2>
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="i in 4"
@@ -208,12 +208,12 @@
</svg>
</div>
</div>
<p class="text-white/60 text-sm mt-3 text-center">Screenshot placeholders - images coming soon</p>
<p class="text-white/60 text-sm mt-3 text-center">{{ t('marketplaceDetails.screenshotPlaceholder') }}</p>
</div>
<!-- Description -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">About {{ app.title }}</h2>
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.about', { name: app.title }) }}</h2>
<p class="text-white/80 leading-relaxed whitespace-pre-line">
{{ longDescription }}
</p>
@@ -221,7 +221,7 @@
<!-- Features -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">Features</h2>
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.features') }}</h2>
<ul class="space-y-3">
<li
v-for="(feature, index) in features"
@@ -241,22 +241,22 @@
<div class="space-y-6">
<!-- App Info Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Information</h3>
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.information') }}</h3>
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">Version</span>
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
<span class="text-white font-medium">{{ app.version || 'latest' }}</span>
</div>
<div v-if="app.author" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">Developer</span>
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
<span class="text-white font-medium">{{ app.author }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">Status</span>
<span class="text-white font-medium">{{ isInstalled ? 'Installed' : 'Not Installed' }}</span>
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
<span class="text-white font-medium">{{ isInstalled ? t('marketplaceDetails.installed') : t('marketplaceDetails.notInstalled') }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">Category</span>
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
<span class="text-white font-medium capitalize">{{ app.category || 'App' }}</span>
</div>
<div v-if="app.manifestUrl" class="flex items-center justify-between py-2">
@@ -268,7 +268,7 @@
<!-- Requirements Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.requirements') }}</h3>
<div class="space-y-3">
<!-- App Dependencies -->
<div v-if="dependencies.length > 0" class="space-y-2 mb-4">
@@ -290,7 +290,7 @@
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">{{ dep.title }}</p>
<p class="text-white/50 text-xs">
{{ dep.status === 'running' ? 'Running' : dep.status === 'stopped' ? 'Installed but stopped' : 'Not installed' }}
{{ dep.status === 'running' ? t('marketplaceDetails.depRunning') : dep.status === 'stopped' ? t('marketplaceDetails.depStopped') : t('marketplaceDetails.depNotInstalled') }}
</p>
</div>
</div>
@@ -305,19 +305,19 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ installingDeps ? 'Installing...' : 'Install Requirements' }}
{{ installingDeps ? t('common.installing') : t('marketplaceDetails.installRequirements') }}
</button>
</div>
<div v-else class="py-2 border-b border-white/10">
<p class="text-white/60 text-sm">No additional dependencies required</p>
<p class="text-white/60 text-sm">{{ t('marketplaceDetails.noRequirements') }}</p>
</div>
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">RAM</p>
<p class="text-white/60 text-sm">Minimum 512MB</p>
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
</div>
</div>
<div class="flex items-start gap-3">
@@ -325,8 +325,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">Storage</p>
<p class="text-white/60 text-sm">~100MB</p>
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
</div>
</div>
</div>
@@ -334,7 +334,7 @@
<!-- Links Card (no GitHub - repo link removed per product) -->
<div v-if="app.manifestUrl" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Links</h3>
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.links') }}</h3>
<div class="space-y-2">
<a
:href="app.manifestUrl"
@@ -345,7 +345,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Download Package
{{ t('marketplaceDetails.downloadPackage') }}
</a>
</div>
</div>
@@ -358,8 +358,8 @@
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
<p class="text-white/70">The requested application could not be found in the marketplace</p>
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('marketplaceDetails.notFoundTitle') }}</h3>
<p class="text-white/70">{{ t('marketplaceDetails.notFoundMessage') }}</p>
</div>
</Transition>
@@ -369,11 +369,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { rpcClient } from '../api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
import { useMobileBackButton } from '../composables/useMobileBackButton'
const { t } = useI18n()
const { bottomPosition } = useMobileBackButton()
const router = useRouter()
@@ -404,7 +406,7 @@ const shortDescription = computed(() => {
}
return desc || ''
} catch (e) {
console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
return ''
}
})
@@ -418,7 +420,7 @@ const longDescription = computed(() => {
}
return desc || ''
} catch (e) {
console.error('[MarketplaceAppDetails] Error in longDescription:', e)
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in longDescription:', e)
return ''
}
})
@@ -479,7 +481,7 @@ onMounted(() => {
}, 500)
}
} catch (e) {
console.error('[MarketplaceAppDetails] Error loading app data:', e)
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error loading app data:', e)
loading.value = false
pendingRedirect = setTimeout(() => {
router.push('/dashboard/marketplace').catch(() => {})
@@ -530,8 +532,8 @@ async function installDependencies() {
await new Promise(resolve => setTimeout(resolve, 2000))
}
} catch (err: unknown) {
installError.value = err instanceof Error ? err.message : 'Failed to install dependencies.'
console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
} finally {
installingDeps.value = false
}
@@ -540,7 +542,7 @@ async function installDependencies() {
async function installApp() {
if (installing.value || !app.value) return
if (!app.value.manifestUrl && !app.value.dockerImage) {
console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
return
}
@@ -577,8 +579,8 @@ async function installApp() {
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
} catch (err: unknown) {
installError.value = err instanceof Error ? err.message : 'Installation failed. Please try again.'
console.error('[MarketplaceAppDetails] Failed to install app:', err)
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install app:', err)
} finally {
installing.value = false
}

View File

@@ -3,15 +3,15 @@
<div class="hidden md:block mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Monitoring</h1>
<p class="text-white/70">Real-time system metrics and container resource usage</p>
<h1 class="text-3xl font-bold text-white mb-2">{{ t('monitoring.title') }}</h1>
<p class="text-white/70">{{ t('monitoring.subtitle') }}</p>
</div>
<div class="flex gap-2">
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('csv')">
Export CSV
{{ t('monitoring.exportCsv') }}
</button>
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('json')">
Export JSON
{{ t('monitoring.exportJson') }}
</button>
</div>
</div>
@@ -20,22 +20,22 @@
<!-- Summary Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">CPU</p>
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.cpu') }}</p>
<p class="text-2xl font-bold text-white">{{ current?.system.cpu_percent.toFixed(1) ?? '--' }}%</p>
<p class="text-xs text-white/40">Load: {{ current?.system.load_avg_1.toFixed(2) ?? '--' }}</p>
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ current?.system.load_avg_1.toFixed(2) ?? '--' }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Memory</p>
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.memory') }}</p>
<p class="text-2xl font-bold text-white">{{ memPercent }}%</p>
<p class="text-xs text-white/40">{{ formatBytes(current?.system.mem_used_bytes ?? 0) }} / {{ formatBytes(current?.system.mem_total_bytes ?? 0) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Disk</p>
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p>
<p class="text-2xl font-bold text-white">{{ diskPercent }}%</p>
<p class="text-xs text-white/40">{{ formatBytes(current?.system.disk_used_bytes ?? 0) }} / {{ formatBytes(current?.system.disk_total_bytes ?? 0) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Network</p>
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p>
<p class="text-2xl font-bold text-white">{{ formatBytes(current?.system.net_rx_bytes ?? 0) }}</p>
<p class="text-xs text-white/40">TX: {{ formatBytes(current?.system.net_tx_bytes ?? 0) }}</p>
</div>
@@ -44,7 +44,7 @@
<!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="glass-card p-5">
<h3 class="text-sm font-medium text-white/80 mb-3">CPU Usage (%)</h3>
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.cpuUsage') }}</h3>
<LineChart
:datasets="cpuDatasets"
:labels="timeLabels"
@@ -54,7 +54,7 @@
/>
</div>
<div class="glass-card p-5">
<h3 class="text-sm font-medium text-white/80 mb-3">Memory Usage (%)</h3>
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.memoryUsage') }}</h3>
<LineChart
:datasets="memDatasets"
:labels="timeLabels"
@@ -64,7 +64,7 @@
/>
</div>
<div class="glass-card p-5">
<h3 class="text-sm font-medium text-white/80 mb-3">Network I/O (bytes)</h3>
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.networkIo') }}</h3>
<LineChart
:datasets="netDatasets"
:labels="timeLabels"
@@ -73,7 +73,7 @@
/>
</div>
<div class="glass-card p-5">
<h3 class="text-sm font-medium text-white/80 mb-3">RPC Latency (ms)</h3>
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.rpcLatency') }}</h3>
<LineChart
:datasets="latencyDatasets"
:labels="timeLabels"
@@ -86,12 +86,12 @@
<!-- Alert History -->
<div class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white/80">Alert History</h3>
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.alertHistory') }}</h3>
<button
class="glass-button text-xs px-3 py-1"
@click="showAlertConfig = !showAlertConfig"
>
{{ showAlertConfig ? 'Hide Config' : 'Configure' }}
{{ showAlertConfig ? t('monitoring.hideConfig') : t('common.configure') }}
</button>
</div>
@@ -128,7 +128,7 @@
<!-- Fired Alerts List -->
<div v-if="!alerts.length" class="text-white/40 text-sm py-4 text-center">
No alerts fired
{{ t('monitoring.noAlerts') }}
</div>
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
<div
@@ -150,7 +150,7 @@
class="text-xs text-white/40 hover:text-white/70 flex-shrink-0"
@click="acknowledgeAlert(alert.id)"
>
Dismiss
{{ t('common.dismiss') }}
</button>
</div>
</div>
@@ -158,9 +158,9 @@
<!-- Container Resource Breakdown -->
<div class="glass-card p-5 mb-6">
<h3 class="text-sm font-medium text-white/80 mb-4">Container Resources</h3>
<h3 class="text-sm font-medium text-white/80 mb-4">{{ t('monitoring.containerResources') }}</h3>
<div v-if="!containers.length" class="text-white/40 text-sm py-4 text-center">
No container metrics available
{{ t('monitoring.noContainerMetrics') }}
</div>
<div v-else class="space-y-3">
<div
@@ -190,11 +190,11 @@
<!-- System Health Timeline -->
<div class="glass-card p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white/80">System Health</h3>
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.systemHealth') }}</h3>
<div class="flex items-center gap-2 text-xs text-white/40">
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span> Healthy
<span class="inline-block w-2 h-2 rounded-full bg-orange-400 ml-2"></span> Elevated
<span class="inline-block w-2 h-2 rounded-full bg-red-400 ml-2"></span> Critical
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span> {{ t('common.healthy') }}
<span class="inline-block w-2 h-2 rounded-full bg-orange-400 ml-2"></span> {{ t('common.elevated') }}
<span class="inline-block w-2 h-2 rounded-full bg-red-400 ml-2"></span> {{ t('common.critical') }}
</div>
</div>
<div class="flex gap-0.5 h-6">
@@ -213,13 +213,14 @@
</div>
<p class="text-xs text-white/30 mt-4 text-center">
Refreshing every 5 seconds &middot; WS connections: {{ current?.ws_connections ?? 0 }}
{{ t('monitoring.refreshFooter') }} &middot; {{ t('monitoring.wsConnections', { count: current?.ws_connections ?? 0 }) }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import LineChart from '@/components/LineChart.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
@@ -279,6 +280,8 @@ interface FiredAlert {
acknowledged: boolean
}
const { t } = useI18n()
const current = ref<MetricSnapshot | null>(null)
const history = ref<MetricSnapshot[]>([])
const containers = ref<ContainerMetrics[]>([])
@@ -382,11 +385,11 @@ function formatBytes(bytes: number): string {
function ruleLabel(kind: string): string {
const labels: Record<string, string> = {
disk_usage: 'Disk Usage',
ram_usage: 'RAM Usage',
container_crash: 'Container Crash',
backend_error_spike: 'RPC Latency Spike',
ssl_cert_expiry: 'SSL Cert Expiry',
disk_usage: t('monitoring.diskUsage'),
ram_usage: t('monitoring.ramUsage'),
container_crash: t('monitoring.containerCrash'),
backend_error_spike: t('monitoring.rpcLatencySpike'),
ssl_cert_expiry: t('monitoring.sslCertExpiry'),
}
return labels[kind] ?? kind
}

View File

@@ -2,8 +2,8 @@
<div>
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
<p class="text-white/80">Configure your Archipelago experience</p>
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ t('settings.title') }}</h1>
<p class="text-white/80">{{ t('settings.subtitle') }}</p>
</div>
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
<div class="md:hidden">
@@ -13,7 +13,7 @@
<!-- Account Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-6">Account</h2>
<h2 class="text-xl font-semibold text-white/96 mb-6">{{ t('settings.account') }}</h2>
<!-- Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
@@ -23,7 +23,7 @@
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Server Name</p>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.serverName') }}</p>
</div>
<p class="text-lg font-semibold text-white/95">{{ serverName }}</p>
</div>
@@ -34,7 +34,7 @@
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Version</p>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('common.version') }}</p>
</div>
<p class="text-lg font-semibold text-white/95">{{ version }}</p>
</div>
@@ -45,9 +45,9 @@
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Session Status</p>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.sessionStatus') }}</p>
</div>
<p class="text-base font-medium text-white/90">Currently logged in</p>
<p class="text-base font-medium text-white/90">{{ t('settings.loggedIn') }}</p>
</div>
<!-- Identity Card: DID + Tor Address (onion below DID, with copy) -->
@@ -59,7 +59,7 @@
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Your DID</p>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.yourDid') }}</p>
</div>
<button
@click="copyDid"
@@ -68,12 +68,12 @@
<svg v-if="!copiedDid" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span v-else class="text-green-400 text-xs">Copied</span>
<span v-if="!copiedDid">Copy</span>
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
<span v-if="!copiedDid">{{ t('common.copy') }}</span>
</button>
</div>
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
<p class="text-xs text-white/50 mt-1">Decentralized identifier for passwordless auth</p>
<p class="text-xs text-white/50 mt-1">{{ t('settings.didHelper') }}</p>
</div>
<!-- Tor / Onion Address (below DID, with copy button) -->
@@ -83,7 +83,7 @@
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Node .onion Address</p>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.onionAddress') }}</p>
</div>
<button
@click="copyOnionAddress"
@@ -92,12 +92,12 @@
<svg v-if="!copiedOnion" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span v-else class="text-green-400 text-xs">Copied</span>
<span v-if="!copiedOnion">Copy</span>
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
<span v-if="!copiedOnion">{{ t('common.copy') }}</span>
</button>
</div>
<p class="text-sm font-mono text-amber-400/90 break-all" :title="serverTorAddress">{{ serverTorAddress }}</p>
<p class="text-xs text-white/50 mt-1">Onion address for node interface and peer discovery over Tor</p>
<p class="text-xs text-white/50 mt-1">{{ t('settings.onionHelper') }}</p>
</div>
</div>
</div>
@@ -111,7 +111,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span>Change Password</span>
<span>{{ t('settings.changePassword') }}</span>
</button>
</div>
@@ -123,45 +123,45 @@
@click.self="closeChangePasswordModal()"
>
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-4">Change Password</h3>
<p class="text-white/70 text-sm mb-4">Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).</p>
<h3 class="text-lg font-semibold text-white mb-4">{{ t('settings.changePasswordTitle') }}</h3>
<p class="text-white/70 text-sm mb-4">{{ t('settings.changePasswordDesc') }}</p>
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">Current Password</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.currentPassword') }}</label>
<input
v-model="changePasswordForm.currentPassword"
type="password"
required
autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="Enter current password"
:placeholder="t('login.enterPasswordPlaceholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">New Password</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.newPassword') }}</label>
<input
v-model="changePasswordForm.newPassword"
type="password"
required
autocomplete="new-password"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="12+ chars, upper, lower, digit, special"
:placeholder="t('settings.passwordPlaceholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">Confirm New Password</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.confirmNewPassword') }}</label>
<input
v-model="changePasswordForm.confirmPassword"
type="password"
required
autocomplete="new-password"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="Re-enter new password"
:placeholder="t('settings.confirmNewPassword')"
/>
</div>
<label class="flex items-center gap-2 text-sm text-white/80">
<input v-model="changePasswordForm.alsoChangeSsh" type="checkbox" class="rounded border-white/30" />
Also update SSH password (recommended)
{{ t('settings.updateSshCheckbox') }}
</label>
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
@@ -171,14 +171,14 @@
:disabled="changingPassword"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ changingPassword ? 'Updating...' : 'Update Password' }}
{{ changingPassword ? t('settings.updatingPassword') : t('settings.updatePassword') }}
</button>
<button
type="button"
@click="closeChangePasswordModal"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
Cancel
{{ t('common.cancel') }}
</button>
</div>
</form>
@@ -194,15 +194,15 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div>
<p class="text-sm font-medium text-white/90">Two-Factor Authentication</p>
<p class="text-xs text-white/50">Protect your account with an authenticator app</p>
<p class="text-sm font-medium text-white/90">{{ t('settings.twoFactorAuth') }}</p>
<p class="text-xs text-white/50">{{ t('settings.twoFaProtect') }}</p>
</div>
</div>
<span
class="text-xs font-semibold px-2 py-1 rounded-full"
:class="totpEnabled ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-white/50'"
>
{{ totpEnabled ? 'Enabled' : 'Disabled' }}
{{ totpEnabled ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
<button
@@ -213,7 +213,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Enable 2FA</span>
<span>{{ t('settings.enable2fa') }}</span>
</button>
<button
v-else
@@ -223,7 +223,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
<span>Disable 2FA</span>
<span>{{ t('settings.disable2fa') }}</span>
</button>
</div>
@@ -238,8 +238,8 @@
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-setup-title">
<!-- Step 1: Enter password -->
<template v-if="totpSetupStep === 1">
<h3 id="totp-setup-title" class="text-lg font-semibold text-white mb-2">Enable Two-Factor Authentication</h3>
<p class="text-white/60 text-sm mb-4">Enter your password to begin setup.</p>
<h3 id="totp-setup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.setup2faTitle') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.setup2faPasswordPrompt') }}</p>
<form @submit.prevent="beginTotpSetup" class="space-y-4">
<input
v-model="totpSetupPassword"
@@ -247,7 +247,7 @@
required
autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="Enter your password"
:placeholder="t('login.enterPasswordPlaceholder')"
/>
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
<div class="flex gap-3">
@@ -256,20 +256,20 @@
:disabled="totpSetupLoading"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ totpSetupLoading ? 'Loading...' : 'Continue' }}
{{ totpSetupLoading ? t('common.loading') : t('common.continue') }}
</button>
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
</div>
</form>
</template>
<!-- Step 2: Scan QR + verify code -->
<template v-else-if="totpSetupStep === 2">
<h3 class="text-lg font-semibold text-white mb-2">Scan QR Code</h3>
<p class="text-white/60 text-sm mb-4">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code.</p>
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.scanQrCode') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.scanQrInstruction') }}</p>
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="totpQrSvg" />
<div class="bg-black/30 rounded-lg px-3 py-2 mb-4">
<p class="text-xs text-white/50 mb-1">Manual entry key:</p>
<p class="text-xs text-white/50 mb-1">{{ t('settings.manualEntryKey') }}</p>
<p class="text-sm font-mono text-orange-400 break-all select-all">{{ totpSecretBase32 }}</p>
</div>
<form @submit.prevent="confirmTotpSetup" class="space-y-4">
@@ -282,7 +282,7 @@
required
autocomplete="one-time-code"
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
placeholder="000000"
:placeholder="t('login.totpPlaceholder')"
/>
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
<div class="flex gap-3">
@@ -291,17 +291,17 @@
:disabled="totpSetupLoading || totpSetupCode.length !== 6"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ totpSetupLoading ? 'Verifying...' : 'Verify & Enable' }}
{{ totpSetupLoading ? t('login.verifying') : t('settings.verifyAndEnable') }}
</button>
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
</div>
</form>
</template>
<!-- Step 3: Show backup codes -->
<template v-else-if="totpSetupStep === 3">
<h3 class="text-lg font-semibold text-white mb-2">Save Your Backup Codes</h3>
<p class="text-white/60 text-sm mb-4">Store these codes safely. Each can be used once if you lose access to your authenticator app.</p>
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.saveBackupCodes') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.backupCodesInstruction') }}</p>
<div class="bg-black/30 rounded-xl p-4 mb-4">
<div class="grid grid-cols-2 gap-2">
<div
@@ -320,13 +320,13 @@
<svg v-if="!backupCodesCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>{{ backupCodesCopied ? 'Copied!' : 'Copy All Codes' }}</span>
<span>{{ backupCodesCopied ? t('common.copiedBang') : t('settings.copyAllCodes') }}</span>
</button>
<button
@click="closeTotpSetup"
class="w-full px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 transition-colors"
>
Done
{{ t('common.done') }}
</button>
</template>
</div>
@@ -342,22 +342,22 @@
@keydown.escape="closeTotpDisable"
>
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-disable-title">
<h3 id="totp-disable-title" class="text-lg font-semibold text-white mb-2">Disable Two-Factor Authentication</h3>
<p class="text-white/60 text-sm mb-4">Enter your password and a current TOTP code to disable 2FA.</p>
<h3 id="totp-disable-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.disable2faTitle') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.disable2faDesc') }}</p>
<form @submit.prevent="disableTotp" class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">Password</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('login.password') }}</label>
<input
v-model="totpDisablePassword"
type="password"
required
autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="Enter your password"
:placeholder="t('login.enterPasswordPlaceholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">Authenticator Code</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.authenticatorCode') }}</label>
<input
v-model="totpDisableCode"
type="text"
@@ -367,7 +367,7 @@
required
autocomplete="one-time-code"
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
placeholder="000000"
:placeholder="t('login.totpPlaceholder')"
/>
</div>
<p v-if="totpDisableError" class="text-sm text-red-400">{{ totpDisableError }}</p>
@@ -377,9 +377,9 @@
:disabled="totpDisableLoading"
class="flex-1 px-4 py-2 rounded-lg bg-red-500 text-white font-medium hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ totpDisableLoading ? 'Disabling...' : 'Disable 2FA' }}
{{ totpDisableLoading ? t('common.disabling') : t('settings.disable2fa') }}
</button>
<button type="button" @click="closeTotpDisable" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
<button type="button" @click="closeTotpDisable" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
</div>
</form>
</div>
@@ -394,14 +394,14 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Logout</span>
<span>{{ t('settings.logout') }}</span>
</button>
</div>
<!-- Interface Mode Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">Interface Mode</h2>
<p class="text-sm text-white/60 mb-6">Choose how you want to interact with your node.</p>
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.interfaceMode') }}</h2>
<p class="text-sm text-white/60 mb-6">{{ t('settings.interfaceModeDesc') }}</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
@@ -431,8 +431,8 @@
<!-- Claude Authentication Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">Claude Authentication</h2>
<p class="text-sm text-white/60 mb-6">Connect your Claude Max account to enable AI chat features.</p>
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2>
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
<!-- Status -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4">
@@ -441,10 +441,10 @@
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Connection Status</p>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.connectionStatus') }}</p>
</div>
<p class="text-base font-medium" :class="claudeConnected ? 'text-green-400' : 'text-white/50'">
{{ claudeConnected ? 'Connected' : 'Not connected' }}
{{ claudeConnected ? t('common.connected') : t('settings.notConnected') }}
</p>
</div>
@@ -458,7 +458,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
<span>{{ claudeConnected ? 'Re-authenticate' : 'Login with Claude' }}</span>
<span>{{ claudeConnected ? t('settings.reAuthenticate') : t('settings.loginWithClaude') }}</span>
</button>
</div>
@@ -471,7 +471,7 @@
>
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h3 class="text-sm font-semibold text-white/80">Claude Authentication</h3>
<h3 class="text-sm font-semibold text-white/80">{{ t('settings.claudeAuth') }}</h3>
<button @click="showClaudeLoginModal = false" class="text-white/50 hover:text-white/80 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -491,9 +491,9 @@
<!-- AI Data Access Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="mb-2">
<h2 class="text-xl font-semibold text-white/96">AI Data Access</h2>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.aiDataAccess') }}</h2>
</div>
<p class="text-sm text-white/60 mb-6">Control what data the AI assistant can see. All categories are off by default.</p>
<p class="text-sm text-white/60 mb-6">{{ t('settings.aiDataAccessDesc') }}</p>
<!-- Enable All toggle -->
<button
@@ -507,8 +507,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">Enable All</p>
<p class="text-xs text-white/50 mt-0.5">Grant access to all data categories at once</p>
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p>
</div>
<div
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
@@ -560,14 +560,14 @@
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">System Updates</h2>
<p class="text-sm text-white/60 mt-1">Check for and install software updates</p>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.systemUpdates') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.systemUpdatesDesc') }}</p>
</div>
<RouterLink to="/dashboard/settings/update" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Manage Updates
{{ t('common.manageUpdates') }}
</RouterLink>
</div>
</div>
@@ -576,18 +576,18 @@
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white/96">Webhook Notifications</h2>
<p class="text-sm text-white/60 mt-1">Get push notifications for critical events via webhook</p>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p>
</div>
<div class="flex items-center gap-3">
<button
@click="toggleWebhookEnabled"
role="switch"
:aria-checked="webhookConfig.enabled"
:aria-label="webhookConfig.enabled ? 'Disable webhooks' : 'Enable webhooks'"
:aria-label="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="webhookConfig.enabled ? 'bg-orange-500' : 'bg-white/15'"
:title="webhookConfig.enabled ? 'Disable webhooks' : 'Enable webhooks'"
:title="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
@@ -600,29 +600,29 @@
<div class="space-y-4">
<!-- Webhook URL -->
<div>
<label class="text-xs text-white/50 block mb-1">Webhook URL</label>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookUrlLabel') }}</label>
<input
v-model="webhookConfig.url"
type="url"
placeholder="https://example.com/webhook"
:placeholder="t('settings.webhookUrlPlaceholder')"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
/>
</div>
<!-- Secret (optional) -->
<div>
<label class="text-xs text-white/50 block mb-1">Secret (optional, for HMAC-SHA256 signing)</label>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookSecretLabel') }}</label>
<input
v-model="webhookConfig.secret"
type="password"
placeholder="Shared secret for payload signing"
:placeholder="t('settings.webhookSecretPlaceholderFull')"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
/>
</div>
<!-- Event Types -->
<div>
<label class="text-xs text-white/50 block mb-2">Events to notify</label>
<label class="text-xs text-white/50 block mb-2">{{ t('settings.eventsToNotify') }}</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
v-for="evt in webhookEventTypes"
@@ -661,14 +661,14 @@
:disabled="savingWebhook"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
>
{{ savingWebhook ? 'Saving...' : 'Save Configuration' }}
{{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }}
</button>
<button
@click="testWebhook"
:disabled="testingWebhook || !webhookConfig.url"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
>
{{ testingWebhook ? 'Sending...' : 'Send Test' }}
{{ testingWebhook ? t('common.sending') : t('common.sendTest') }}
</button>
</div>
</div>
@@ -683,37 +683,37 @@
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white/96">Backup & Restore</h2>
<p class="text-sm text-white/60 mt-1">Encrypted backups of your identity, settings, and data</p>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.backup') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.backupRestoreDesc') }}</p>
</div>
<button @click="showCreateBackupModal = true" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Create Backup
{{ t('settings.createBackup') }}
</button>
</div>
<!-- Backup List -->
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">Loading backups...</div>
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">No backups yet. Create one to protect your node data.</div>
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</div>
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.noBackups') }}</div>
<div v-else class="space-y-2">
<div v-for="b in backupList" :key="b.id" class="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 bg-white/5 rounded-lg gap-2">
<div class="min-w-0">
<div class="text-sm text-white font-medium">{{ b.description || 'System Backup' }}</div>
<div class="text-sm text-white font-medium">{{ b.description || t('settings.systemBackup') }}</div>
<div class="text-xs text-white/50">{{ new Date(b.created_at).toLocaleString() }} &middot; {{ formatBackupSize(b.size_bytes) }}</div>
</div>
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<button @click="verifyBackup(b.id)" :disabled="verifyingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs disabled:opacity-50" title="Verify">
{{ verifyingBackupId === b.id ? '...' : 'Verify' }}
<button @click="verifyBackup(b.id)" :disabled="verifyingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs disabled:opacity-50" :title="t('common.verify')">
{{ verifyingBackupId === b.id ? '...' : t('common.verify') }}
</button>
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" title="Copy to USB">
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" :title="t('settings.copyToUsb')">
{{ usbCopyingId === b.id ? '...' : 'USB' }}
</button>
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" title="Restore">
Restore
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" :title="t('common.restore')">
{{ t('common.restore') }}
</button>
<button @click="deleteBackup(b.id)" :disabled="deletingBackupId === b.id" aria-label="Delete backup" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-red-400 disabled:opacity-50" title="Delete">
<button @click="deleteBackup(b.id)" :disabled="deletingBackupId === b.id" :aria-label="t('settings.deleteBackup')" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-red-400 disabled:opacity-50" :title="t('common.delete')">
&times;
</button>
</div>
@@ -730,21 +730,21 @@
<Teleport to="body">
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showCreateBackupModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="create-backup-title">
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">Create Encrypted Backup</h3>
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-white/50 block mb-1">Encryption Passphrase</label>
<input v-model="backupPassphrase" type="password" placeholder="Enter a strong passphrase" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
<input v-model="backupPassphrase" type="password" :placeholder="t('settings.enterPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
<div>
<label class="text-xs text-white/50 block mb-1">Description (optional)</label>
<input v-model="backupDescription" type="text" placeholder="e.g. Before update" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
<label class="text-xs text-white/50 block mb-1">{{ t('settings.descriptionOptional') }}</label>
<input v-model="backupDescription" type="text" :placeholder="t('settings.descriptionPlaceholder')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
</div>
<div class="flex gap-3 mt-5">
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">Cancel</button>
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
{{ creatingBackup ? 'Creating...' : 'Create Backup' }}
{{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }}
</button>
</div>
</div>
@@ -755,16 +755,16 @@
<Teleport to="body">
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showRestoreModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="restore-backup-title">
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">Restore Backup</h3>
<p class="text-sm text-red-400/80 mb-4">This will overwrite current node data. Make sure you have the correct passphrase.</p>
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.restoreBackupTitle') }}</h3>
<p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>
<div>
<label class="text-xs text-white/50 block mb-1">Encryption Passphrase</label>
<input v-model="restorePassphrase" type="password" placeholder="Enter backup passphrase" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
<input v-model="restorePassphrase" type="password" :placeholder="t('settings.enterBackupPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
<div class="flex gap-3 mt-5">
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">Cancel</button>
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-red-500/20 border-red-500/30 disabled:opacity-50">
{{ restoringBackup ? 'Restoring...' : 'Restore' }}
{{ restoringBackup ? t('common.restoring') : t('common.restore') }}
</button>
</div>
</div>
@@ -775,14 +775,14 @@
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">Network</h2>
<p class="text-sm text-white/60 mt-1">Network connectivity, UPnP, and diagnostics</p>
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.networkDesc') }}</p>
</div>
<button @click="router.push('/dashboard/server')" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
Network Diagnostics
{{ t('common.networkDiagnostics') }}
</button>
</div>
</div>
@@ -792,6 +792,7 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { useUIModeStore } from '@/stores/uiMode'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
@@ -801,6 +802,7 @@ import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { UIMode } from '@/types/api'
const router = useRouter()
const { t } = useI18n()
const store = useAppStore()
const uiMode = useUIModeStore()
const aiPermissions = useAIPermissionsStore()
@@ -817,26 +819,26 @@ const aiCategoryGroups = computed(() => {
return groups
})
const interfaceModes: { id: UIMode; label: string; description: string; iconPaths: string[] }[] = [
const interfaceModes = computed<{ id: UIMode; label: string; description: string; iconPaths: string[] }[]>(() => [
{
id: 'easy',
label: 'Easy',
description: 'Goal-based interface. Choose what you want to do, and the system handles the rest.',
label: t('settings.modeEasy'),
description: t('settings.modeEasyDesc'),
iconPaths: ['M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
},
{
id: 'gamer',
label: 'Pro',
description: 'Full control over all services. Configure everything manually with all technical details.',
label: t('settings.modePro'),
description: t('settings.modeProDesc'),
iconPaths: ['M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z', 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'],
},
{
id: 'chat',
label: 'Chat',
description: 'Conversational AI interface. Manage your node through natural language. Coming soon.',
label: t('settings.modeChat'),
description: t('settings.modeChatDesc'),
iconPaths: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
},
]
])
const serverName = computed(() => store.serverName)
const version = computed(() => store.serverInfo?.version || '0.0.0')
@@ -928,7 +930,7 @@ async function beginTotpSetup() {
totpPendingToken.value = res.pending_token
totpSetupStep.value = 2
} catch (e) {
totpSetupError.value = e instanceof Error ? e.message : 'Setup failed'
totpSetupError.value = e instanceof Error ? e.message : t('settings.setupFailed')
} finally {
totpSetupLoading.value = false
}
@@ -947,7 +949,7 @@ async function confirmTotpSetup() {
totpEnabled.value = true
totpSetupStep.value = 3
} catch (e) {
totpSetupError.value = e instanceof Error ? e.message : 'Verification failed'
totpSetupError.value = e instanceof Error ? e.message : t('settings.verificationFailed')
} finally {
totpSetupLoading.value = false
}
@@ -974,7 +976,7 @@ async function disableTotp() {
totpEnabled.value = false
closeTotpDisable()
} catch (e) {
totpDisableError.value = e instanceof Error ? e.message : 'Failed to disable 2FA'
totpDisableError.value = e instanceof Error ? e.message : t('settings.disableFailed')
} finally {
totpDisableLoading.value = false
}
@@ -1022,11 +1024,11 @@ const changePasswordForm = ref({
})
function validatePasswordStrength(pw: string): string | null {
if (pw.length < 12) return 'Password must be at least 12 characters'
if (!/[A-Z]/.test(pw)) return 'Password must contain at least one uppercase letter'
if (!/[a-z]/.test(pw)) return 'Password must contain at least one lowercase letter'
if (!/\d/.test(pw)) return 'Password must contain at least one digit'
if (!/[^A-Za-z0-9]/.test(pw)) return 'Password must contain at least one special character (!@#$%^&* etc.)'
if (pw.length < 12) return t('settings.passwordMinLength')
if (!/[A-Z]/.test(pw)) return t('settings.passwordNeedUppercase')
if (!/[a-z]/.test(pw)) return t('settings.passwordNeedLowercase')
if (!/\d/.test(pw)) return t('settings.passwordNeedDigit')
if (!/[^A-Za-z0-9]/.test(pw)) return t('settings.passwordNeedSpecial')
return null
}
@@ -1035,11 +1037,11 @@ async function handleChangePassword() {
changePasswordSuccess.value = ''
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
if (!currentPassword || !newPassword || !confirmPassword) {
changePasswordError.value = 'All fields are required'
changePasswordError.value = t('settings.passwordAllFieldsRequired')
return
}
if (newPassword !== confirmPassword) {
changePasswordError.value = 'New passwords do not match'
changePasswordError.value = t('settings.passwordMismatch')
return
}
const strengthError = validatePasswordStrength(newPassword)
@@ -1054,13 +1056,13 @@ async function handleChangePassword() {
newPassword,
alsoChangeSsh,
})
changePasswordSuccess.value = 'Password updated successfully. Use the new password for login and SSH.'
changePasswordSuccess.value = t('settings.passwordUpdatedSuccess')
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
setTimeout(() => {
closeChangePasswordModal()
}, 2000)
} catch (e) {
changePasswordError.value = e instanceof Error ? e.message : 'Failed to change password'
changePasswordError.value = e instanceof Error ? e.message : t('settings.passwordChangeFailed')
} finally {
changingPassword.value = false
}
@@ -1190,28 +1192,28 @@ async function createBackup() {
showCreateBackupModal.value = false
backupPassphrase.value = ''
backupDescription.value = ''
showBackupStatus('Backup created successfully', 'success')
showBackupStatus(t('settings.backupCreatedSuccess'), 'success')
await loadBackups()
} catch {
showBackupStatus('Failed to create backup', 'error')
showBackupStatus(t('settings.backupCreateFailed'), 'error')
} finally {
creatingBackup.value = false
}
}
async function verifyBackup(id: string) {
const passphrase = prompt('Enter backup passphrase to verify:')
const passphrase = prompt(t('settings.verifyPassphrasePrompt'))
if (!passphrase) return
verifyingBackupId.value = id
try {
const res = await rpcClient.call<{ valid: boolean; error: string | null }>({ method: 'backup.verify', params: { id, passphrase } })
if (res.valid) {
showBackupStatus('Backup verified — integrity OK', 'success')
showBackupStatus(t('settings.backupVerifiedOk'), 'success')
} else {
showBackupStatus(`Verification failed: ${res.error || 'Unknown error'}`, 'error')
showBackupStatus(t('settings.backupVerifyFailed', { error: res.error || 'Unknown error' }), 'error')
}
} catch {
showBackupStatus('Verification request failed', 'error')
showBackupStatus(t('settings.backupVerifyRequestFailed'), 'error')
} finally {
verifyingBackupId.value = null
}
@@ -1229,23 +1231,23 @@ async function restoreBackup() {
try {
await rpcClient.call({ method: 'backup.restore', params: { id: restoreBackupId.value, passphrase: restorePassphrase.value } })
showRestoreModal.value = false
showBackupStatus('Backup restored. Restart may be needed.', 'success')
showBackupStatus(t('settings.backupRestored'), 'success')
} catch {
showBackupStatus('Restore failed — check passphrase', 'error')
showBackupStatus(t('settings.backupRestoreFailed'), 'error')
} finally {
restoringBackup.value = false
}
}
async function deleteBackup(id: string) {
if (!confirm('Delete this backup permanently?')) return
if (!confirm(t('settings.deleteBackupConfirm'))) return
deletingBackupId.value = id
try {
await rpcClient.call({ method: 'backup.delete', params: { id } })
showBackupStatus('Backup deleted', 'success')
showBackupStatus(t('settings.backupDeleted'), 'success')
await loadBackups()
} catch {
showBackupStatus('Failed to delete backup', 'error')
showBackupStatus(t('settings.backupDeleteFailed'), 'error')
} finally {
deletingBackupId.value = null
}
@@ -1269,12 +1271,12 @@ const testingWebhook = ref(false)
const webhookStatusMsg = ref('')
const webhookStatusType = ref<'success' | 'error'>('success')
const webhookEventTypes = [
{ id: 'container_crash', label: 'Container Crash', description: 'A running container stops unexpectedly' },
{ id: 'update_available', label: 'Update Available', description: 'A new system or app update is ready' },
{ id: 'disk_warning', label: 'Disk Space Warning', description: 'Disk usage exceeds warning threshold' },
{ id: 'backup_complete', label: 'Backup Complete', description: 'A scheduled or manual backup finishes' },
]
const webhookEventTypes = computed(() => [
{ id: 'container_crash', label: t('settings.containerCrash'), description: t('settings.containerCrashDesc') },
{ id: 'update_available', label: t('settings.updateAvailableEvent'), description: t('settings.updateAvailableDesc') },
{ id: 'disk_warning', label: t('settings.diskSpaceWarning'), description: t('settings.diskWarningDesc') },
{ id: 'backup_complete', label: t('settings.backupComplete'), description: t('settings.backupCompleteDesc') },
])
function showWebhookStatus(msg: string, type: 'success' | 'error') {
webhookStatusMsg.value = msg
@@ -1319,9 +1321,9 @@ async function saveWebhookConfig() {
events: webhookConfig.value.events,
},
})
showWebhookStatus('Webhook configuration saved', 'success')
showWebhookStatus(t('settings.webhookSaved'), 'success')
} catch {
showWebhookStatus('Failed to save webhook configuration', 'error')
showWebhookStatus(t('settings.webhookSaveFailed'), 'error')
} finally {
savingWebhook.value = false
}
@@ -1332,12 +1334,12 @@ async function testWebhook() {
try {
const res = await rpcClient.call<{ sent: boolean; url: string }>({ method: 'webhook.test' })
if (res.sent) {
showWebhookStatus('Test webhook sent successfully', 'success')
showWebhookStatus(t('settings.webhookTestSent'), 'success')
} else {
showWebhookStatus('Test failed: webhook not sent', 'error')
showWebhookStatus(t('settings.webhookTestFailed'), 'error')
}
} catch {
showWebhookStatus('Failed to send test webhook', 'error')
showWebhookStatus(t('settings.webhookSendFailed'), 'error')
} finally {
testingWebhook.value = false
}
@@ -1361,15 +1363,15 @@ async function backupToUsb(backupId: string) {
const mounted = drives.filter(d => d.mount_point)
const target = mounted[0]
if (!target?.mount_point) {
showBackupStatus('No mounted USB drives found. Insert and mount a USB drive first.', 'error')
showBackupStatus(t('settings.noUsbDrives'), 'error')
return
}
const label = target.label || target.device
if (!confirm(`Copy backup to USB drive "${label}" at ${target.mount_point}?`)) return
await rpcClient.call({ method: 'backup.to-usb', params: { id: backupId, mount_point: target.mount_point } })
showBackupStatus(`Backup copied to ${target.mount_point}`, 'success')
showBackupStatus(t('settings.backupCopiedToUsb', { path: target.mount_point }), 'success')
} catch {
showBackupStatus('Failed to copy backup to USB', 'error')
showBackupStatus(t('settings.backupUsbFailed'), 'error')
} finally {
usbCopyingId.value = null
}

View File

@@ -0,0 +1,422 @@
<template>
<div>
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
</div>
<!-- Status message -->
<div
v-if="statusMessage"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMessage }}
</div>
<!-- Current Version -->
<div class="glass-card p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.currentSystem') }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">{{ t('common.version') }}</p>
<p class="text-xl font-bold text-white">v{{ currentVersion }}</p>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">{{ t('systemUpdate.lastChecked') }}</p>
<p class="text-sm font-medium text-white">{{ lastCheckDisplay }}</p>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">{{ t('common.status') }}</p>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="statusDotColor"></div>
<p class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</p>
</div>
</div>
</div>
</div>
<!-- Available Update -->
<div v-if="updateInfo" class="glass-card p-6 mb-6 border border-orange-400/30">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
<p class="text-sm text-white/60">Version {{ updateInfo.version }} &mdash; {{ updateInfo.release_date }}</p>
</div>
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
</div>
<!-- Changelog -->
<div v-if="updateInfo.changelog.length" class="mb-4">
<h3 class="text-sm font-medium text-white/80 mb-2">{{ t('systemUpdate.changelog') }}</h3>
<ul class="space-y-1">
<li v-for="(entry, i) in updateInfo.changelog" :key="i" class="text-sm text-white/60 flex gap-2">
<span class="text-orange-400 shrink-0">&bull;</span>
<span>{{ entry }}</span>
</li>
</ul>
</div>
<!-- Components -->
<div v-if="updateInfo.components > 0" class="mb-4 p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/60">{{ t('systemUpdate.componentsToUpdate', { count: updateInfo.components }) }}</p>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button
v-if="!downloading && !applying"
@click="downloadUpdate"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium"
>
{{ t('systemUpdate.downloadUpdate') }}
</button>
<button
v-if="downloaded && !applying"
@click="requestApply"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
>
{{ t('systemUpdate.applyUpdate') }}
</button>
</div>
</div>
<!-- No update available -->
<div v-else-if="!loading" class="glass-card p-6 mb-6">
<div class="flex items-center gap-3 mb-4">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.upToDate') }}</h2>
</div>
<p class="text-sm text-white/60">{{ t('systemUpdate.upToDateMessage') }}</p>
</div>
<!-- Download Progress -->
<div v-if="downloading" class="glass-card p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
<div
class="h-full bg-orange-400 rounded-full transition-all duration-500"
:style="{ width: downloadPercent + '%' }"
></div>
</div>
<p class="text-xs text-white/60">{{ t('systemUpdate.percentComplete', { percent: downloadPercent }) }}</p>
</div>
<!-- Applying -->
<div v-if="applying" class="glass-card p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.applying') }}</h2>
<div class="flex items-center gap-3">
<div class="w-5 h-5 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"></div>
<p class="text-sm text-white/70">{{ t('systemUpdate.applyWarning') }}</p>
</div>
</div>
<!-- Update Schedule -->
<div class="glass-card p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-2">{{ t('systemUpdate.updateSchedule') }}</h2>
<p class="text-sm text-white/60 mb-4">{{ t('systemUpdate.subtitle') }}</p>
<div class="space-y-3">
<label
v-for="opt in scheduleOptions"
:key="opt.value"
class="flex items-start gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
:class="{ 'ring-1 ring-orange-400/50 bg-orange-500/10': schedule === opt.value }"
>
<input
type="radio"
name="update-schedule"
:value="opt.value"
:checked="schedule === opt.value"
@change="setSchedule(opt.value)"
class="mt-1 accent-orange-400"
/>
<div>
<p class="text-sm font-medium text-white">{{ opt.label }}</p>
<p class="text-xs text-white/50">{{ opt.description }}</p>
</div>
</label>
</div>
</div>
<!-- Actions row -->
<div class="glass-card p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
<div class="flex flex-wrap gap-3">
<button
@click="checkForUpdates"
:disabled="loading"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
>
{{ loading ? t('systemUpdate.checking') : t('systemUpdate.checkForUpdates') }}
</button>
<button
v-if="rollbackAvailable"
@click="requestRollback"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20"
>
{{ t('systemUpdate.rollback') }}
</button>
<RouterLink to="/dashboard/settings" class="glass-button rounded-lg px-5 py-2 text-sm font-medium text-center">
{{ t('systemUpdate.backToSettings') }}
</RouterLink>
</div>
</div>
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="cancelConfirm">
<div class="glass-card p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-3">
{{ confirmAction === 'apply' ? t('systemUpdate.applyTitle') : t('systemUpdate.rollbackTitle') }}
</h3>
<p class="text-sm text-white/70 mb-6">
{{ confirmAction === 'apply'
? t('systemUpdate.applyMessage')
: t('systemUpdate.rollbackMessage') }}
</p>
<div class="flex gap-3 justify-end">
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
{{ t('common.cancel') }}
</button>
<button
@click="executeConfirm"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
>
{{ confirmAction === 'apply' ? t('systemUpdate.applyNow') : t('systemUpdate.rollbackButton') }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
interface UpdateDetail {
version: string
release_date: string
changelog: string[]
components: number
}
type ScheduleValue = 'manual' | 'daily_check' | 'auto_apply'
const { t } = useI18n()
const scheduleOptions = computed<{ value: ScheduleValue; label: string; description: string }[]>(() => [
{ value: 'manual', label: t('systemUpdate.manualOnly'), description: t('systemUpdate.manualOnlyDesc') },
{ value: 'daily_check', label: t('systemUpdate.dailyCheck'), description: t('systemUpdate.dailyCheckDesc') },
{ value: 'auto_apply', label: t('systemUpdate.autoApply'), description: t('systemUpdate.autoApplyDesc') },
])
const schedule = ref<ScheduleValue>('daily_check')
const loading = ref(false)
const downloading = ref(false)
const downloaded = ref(false)
const applying = ref(false)
const confirmAction = ref<'apply' | 'rollback' | null>(null)
const currentVersion = ref('0.0.0')
const lastCheck = ref<string | null>(null)
const updateInfo = ref<UpdateDetail | null>(null)
const rollbackAvailable = ref(false)
const updateInProgress = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const downloadPercent = ref(0)
const lastCheckDisplay = computed(() => {
if (!lastCheck.value) return t('common.never')
try {
const d = new Date(lastCheck.value)
return d.toLocaleString()
} catch {
return lastCheck.value
}
})
const statusLabel = computed(() => {
if (applying.value) return t('systemUpdate.applying')
if (downloading.value) return t('systemUpdate.downloading')
if (updateInProgress.value) return t('systemUpdate.applying')
if (updateInfo.value) return t('systemUpdate.updateAvailable')
if (rollbackAvailable.value) return t('systemUpdate.rollback')
return t('systemUpdate.upToDate')
})
const statusDotColor = computed(() => {
if (applying.value || downloading.value) return 'bg-orange-400 animate-pulse'
if (updateInfo.value || updateInProgress.value) return 'bg-orange-400'
return 'bg-green-400'
})
const statusTextColor = computed(() => {
if (applying.value || downloading.value || updateInfo.value || updateInProgress.value) return 'text-orange-400'
return 'text-green-400'
})
function showStatus(msg: string, isError = false) {
statusMessage.value = msg
statusIsError.value = isError
setTimeout(() => { statusMessage.value = '' }, 8000)
}
async function loadStatus() {
try {
const res = await rpcClient.call<{
current_version: string
last_check: string | null
update_available: boolean
update_in_progress: boolean
rollback_available: boolean
}>({ method: 'update.status' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInProgress.value = res.update_in_progress
rollbackAvailable.value = res.rollback_available
if (res.update_in_progress) {
downloaded.value = true
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load update status', e)
}
}
async function checkForUpdates() {
loading.value = true
statusMessage.value = ''
try {
const res = await rpcClient.call<{
current_version: string
last_check: string | null
update_available: boolean
update: UpdateDetail | null
}>({ method: 'update.check' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInfo.value = res.update
if (!res.update_available) {
showStatus(t('systemUpdate.upToDateMessage'))
}
} catch (e) {
showStatus(t('systemUpdate.checkFailed'), true)
if (import.meta.env.DEV) console.warn('Update check failed', e)
} finally {
loading.value = false
}
}
async function downloadUpdate() {
downloading.value = true
downloadPercent.value = 0
statusMessage.value = ''
// Simulate incremental progress while waiting for the RPC
const progressInterval = setInterval(() => {
if (downloadPercent.value < 90) {
downloadPercent.value += Math.random() * 15
}
}, 500)
try {
const res = await rpcClient.call<{
total_bytes: number
downloaded_bytes: number
components_downloaded: number
}>({ method: 'update.download' })
downloadPercent.value = 100
downloaded.value = true
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
showStatus(t('systemUpdate.downloadSuccess', { count: res.components_downloaded, size: sizeMB }))
} catch (e) {
showStatus(t('systemUpdate.downloadFailed'), true)
if (import.meta.env.DEV) console.warn('Download failed', e)
} finally {
clearInterval(progressInterval)
downloading.value = false
}
}
function requestApply() {
confirmAction.value = 'apply'
}
function requestRollback() {
confirmAction.value = 'rollback'
}
function cancelConfirm() {
confirmAction.value = null
}
async function executeConfirm() {
if (confirmAction.value === 'apply') {
confirmAction.value = null
await applyUpdate()
} else if (confirmAction.value === 'rollback') {
confirmAction.value = null
await rollbackUpdate()
}
}
async function applyUpdate() {
applying.value = true
statusMessage.value = ''
try {
await rpcClient.call({ method: 'update.apply' })
showStatus(t('systemUpdate.applySuccess'))
updateInfo.value = null
downloaded.value = false
await loadStatus()
} catch (e) {
showStatus(t('systemUpdate.applyFailed'), true)
if (import.meta.env.DEV) console.warn('Apply failed', e)
} finally {
applying.value = false
}
}
async function rollbackUpdate() {
try {
await rpcClient.call({ method: 'update.rollback' })
showStatus(t('systemUpdate.rollbackSuccess'))
rollbackAvailable.value = false
await loadStatus()
} catch (e) {
showStatus(t('systemUpdate.rollbackFailed'), true)
if (import.meta.env.DEV) console.warn('Rollback failed', e)
}
}
async function loadSchedule() {
try {
const res = await rpcClient.call<{ schedule: ScheduleValue }>({ method: 'update.get-schedule' })
schedule.value = res.schedule
} catch {
if (import.meta.env.DEV) console.warn('Failed to load update schedule')
}
}
async function setSchedule(value: ScheduleValue) {
schedule.value = value
try {
await rpcClient.call({ method: 'update.set-schedule', params: { schedule: value } })
showStatus(`Schedule set to ${scheduleOptions.value.find(o => o.value === value)?.label}`)
} catch (e) {
showStatus('Failed to save schedule', true)
if (import.meta.env.DEV) console.warn('Set schedule failed', e)
}
}
onMounted(() => {
Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
})
</script>

File diff suppressed because it is too large Load Diff