No models downloaded yet.
'; + if (totalSizeEl) totalSizeEl.textContent = ""; + return; + } + + // Compute total size + const totalBytes = downloaded.reduce((sum, m) => sum + (m.file_size_bytes || 0), 0); + if (totalSizeEl) { + totalSizeEl.textContent = `Total: ${formatFileSize(totalBytes)}`; + } + + list.innerHTML = ""; + downloaded.forEach((model) => { + const row = document.createElement("div"); + row.className = "model-manager-row"; + row.dataset.modelName = model.name; + + const info = document.createElement("div"); + info.className = "model-manager-info"; + + const name = document.createElement("span"); + name.className = "model-manager-name"; + name.textContent = model.name; + + const meta = document.createElement("span"); + meta.className = "model-manager-meta"; + const sizeOnDisk = model.file_size_bytes + ? formatFileSize(model.file_size_bytes) + : model.size; + meta.textContent = sizeOnDisk; + + info.appendChild(name); + info.appendChild(meta); + + const deleteBtn = document.createElement("button"); + deleteBtn.className = "btn btn-danger btn-sm model-delete-btn"; + deleteBtn.textContent = "Delete"; + deleteBtn.title = `Delete ${model.name} model`; + deleteBtn.addEventListener("click", () => deleteDownloadedModel(model.name, row)); + + row.appendChild(info); + row.appendChild(deleteBtn); + list.appendChild(row); + }); + } catch (err) { + list.innerHTML = `Failed to load models: ${escapeHtml(String(err))}
`; + } +} + +async function deleteDownloadedModel(modelName, rowEl) { + // Visual confirmation + const deleteBtn = rowEl.querySelector(".model-delete-btn"); + if (deleteBtn.dataset.confirming === "true") { + // Second click — confirmed, proceed with deletion + deleteBtn.disabled = true; + deleteBtn.textContent = "Deleting..."; + rowEl.classList.add("model-row-deleting"); + try { + await invoke("delete_model", { modelName }); + // Animate removal + rowEl.style.opacity = "0"; + rowEl.style.transform = "translateX(8px)"; + rowEl.style.transition = "opacity 200ms ease, transform 200ms ease"; + setTimeout(() => { + rowEl.remove(); + // Re-render to update total size & empty state + renderDownloadedModels(); + // Refresh main model picker too + loadModels(); + }, 220); + } catch (err) { + deleteBtn.disabled = false; + deleteBtn.textContent = "Delete"; + deleteBtn.dataset.confirming = "false"; + deleteBtn.classList.remove("btn-danger-confirm"); + rowEl.classList.remove("model-row-deleting"); + const errMsg = document.createElement("span"); + errMsg.className = "model-delete-error"; + const errorText = err?.message ?? String(err); + errMsg.textContent = `Error: ${errorText}`; + rowEl.appendChild(errMsg); + setTimeout(() => errMsg.remove(), 3000); + } + } else { + // First click — ask for confirmation in-place + deleteBtn.dataset.confirming = "true"; + deleteBtn.textContent = "Confirm?"; + deleteBtn.classList.add("btn-danger-confirm"); + // Auto-reset after 10 s + setTimeout(() => { + if (deleteBtn.dataset.confirming === "true") { + deleteBtn.dataset.confirming = "false"; + deleteBtn.textContent = "Delete"; + deleteBtn.classList.remove("btn-danger-confirm"); + } + }, 10000); + } +} + +function formatFileSize(bytes) { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } else if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; + } else { + return `${(bytes / 1024).toFixed(0)} KB`; + } +} + +// Render models when navigating to Settings page +navItems.forEach((btn) => { + btn.addEventListener("click", () => { + if (btn.dataset.page === "settings") { + renderDownloadedModels(); + } + }); +}); + +document.getElementById("open-models-dir-btn")?.addEventListener("click", async () => { + try { + await invoke("open_models_dir"); + } catch (err) { + console.error("Failed to open models directory:", err); + } +}); + // ---- Update Check --------------------------------------------------------- const checkUpdateBtn = document.getElementById("check-update-btn"); @@ -540,18 +678,18 @@ async function checkForUpdates() { checkUpdateBtn.disabled = true; checkUpdateBtn.textContent = "Checking..."; updateStatus.classList.add("hidden"); - + try { const response = await fetch("https://api.github.com/repos/kylethedeveloper/OratioText/releases/latest"); if (!response.ok) throw new Error("Failed to check for updates"); const data = await response.json(); - + // Tag names typically have a 'v' prefix, e.g. 'v1.0.1'. Clean it up easily: const latestVersion = data.tag_name.replace(/^v/, ''); const currentVersion = await invoke("get_app_version"); - + const isNewer = compareVersions(latestVersion, currentVersion) > 0; - + updateStatus.classList.remove("hidden"); if (isNewer) { updateStatus.textContent = "⚠ Newer version available!"; diff --git a/src/styles.css b/src/styles.css index d6e914e..e24127f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -295,6 +295,7 @@ body { .setting-control select:focus { border-color: var(--color-primary); } + /* ========================================================================== File Picker ========================================================================== */ @@ -653,4 +654,118 @@ body { font-weight: 600; color: var(--color-text-muted); font-variant-numeric: tabular-nums; +} + +/* ========================================================================== + Downloaded Models Manager (Settings) + ========================================================================== */ + +.models-manager-card { + flex-shrink: 0; +} + +.models-manager-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); +} + +.models-total-size { + font-size: 0.75rem; + color: var(--color-text-muted); + font-weight: 500; +} + +.downloaded-models-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.model-manager-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-sm) var(--spacing-sm); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-bg); + transition: background var(--transition), border-color var(--transition); +} + +.model-manager-row:hover { + background: var(--color-surface-hover); + border-color: var(--color-border); +} + +.model-manager-row.model-row-deleting { + opacity: 0.5; + pointer-events: none; +} + +.model-manager-info { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.model-manager-name { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text); + text-transform: capitalize; + min-width: 60px; +} + +.model-manager-meta { + font-size: 0.775rem; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +.model-delete-btn { + opacity: 0.65; + transition: opacity var(--transition), background var(--transition); +} + +.model-delete-btn:hover:not(:disabled) { + opacity: 0.65; +} + +.btn-danger-confirm { + opacity: 1 !important; + background: rgba(248, 113, 113, 0.3) !important; + border-color: var(--color-error) !important; + color: var(--color-error) !important; + animation: pulse-danger 0.6s ease infinite alternate; +} + +@keyframes pulse-danger { + from { + box-shadow: 0 0 0 0 rgba(248, 113, 113, 0); + } + + to { + box-shadow: 0 0 6px 2px rgba(248, 113, 113, 0.3); + } +} + +.model-delete-error { + font-size: 0.75rem; + color: var(--color-error); + margin-left: var(--spacing-sm); + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } } \ No newline at end of file