diff --git a/css/style.css b/css/style.css index 66a2db9..7642cb7 100644 --- a/css/style.css +++ b/css/style.css @@ -741,11 +741,11 @@ input[type="range"]::-webkit-slider-thumb { .user-menu { position: relative; } .user-chip { - display: inline-flex; align-items: center; gap: 6px; - padding: 6px 12px; + display: inline-flex; align-items: center; gap: 8px; + padding: 5px 12px 5px 6px; background: var(--bg-elev); border: 1px solid var(--border); - border-radius: 16px; + border-radius: 20px; color: var(--text); font-size: 12px; transition: all var(--t-fast); @@ -756,10 +756,10 @@ input[type="range"]::-webkit-slider-thumb { Falls back to a background-image when a custom picture is set. */ .user-avatar { display: inline-flex; align-items: center; justify-content: center; - width: 22px; height: 22px; - margin-left: -4px; + width: 30px; height: 30px; + margin-left: 0; border-radius: 50%; - font-size: 11px; font-weight: 600; + font-size: 13px; font-weight: 600; color: #fff; text-transform: uppercase; background-size: cover; background-position: center; @@ -2178,7 +2178,7 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before { display: none; } .settings-search-clear svg { width: 12px; height: 12px; } -.settings-search-clear:hover { color: var(--text); background: var(--bg-elev-3); } +.settings-search-clear:hover { color: var(--text); background: var(--bg-elev-2); } .settings-search-wrap.has-query .settings-search-clear { display: inline-flex; } /* Hidden section + per-row + no-results banner driven by JS. */ .slideover-field.is-search-hidden, @@ -2191,6 +2191,33 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before { display: none; } .settings-no-results.is-visible { display: block; } +/* Global busy overlay — spinner + text for long ops (export, big saves). */ +.busy-overlay { + position: fixed; inset: 0; + z-index: 200; + display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); +} +.busy-overlay.is-hidden { display: none; } +.busy-box { + display: flex; flex-direction: column; align-items: center; gap: 14px; + padding: 24px 32px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); +} +.busy-spinner { + width: 34px; height: 34px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: busy-spin 0.7s linear infinite; +} +@keyframes busy-spin { to { transform: rotate(360deg); } } +.busy-text { font-size: 13px; color: var(--text-dim); text-align: center; } .slideover-field-label { font-size: 11px; font-weight: 500; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } .slideover-field-value { display: flex; align-items: center; gap: 6px; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 04a2128..d95b34e 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -74,6 +74,9 @@ type private FServer: TPMHTTPServer; FBridge: TPMBridge; + // Accumulates base64 chunks for large file saves (attachments too big + // to fit a single cmd:// URL). Keyed by reqId, flushed on save-commit. + FFileSaveChunks: TDictionary; FPendingURL: string; FNavTimer: TTimer; // Set once WebView2 fires OnInitialized. The nav timer only consumes @@ -108,6 +111,10 @@ type // space between PanelTop (top) and PanelLog/Splitter (bottom). WebBrowser: TWebBrowserClass; procedure AutofillTimerTick(Sender: TObject); + // Shared by cmd://file/save (single-shot) and file/save-commit + // (chunked): decode the base64, show the Save dialog, write, and fire + // Bridge.onFileSaveResult back to JS. + procedure SaveDecodedFile(const AName, AB64, AReqId: string); procedure LogLine(const AMsg: string); procedure UpdateButtons; procedure NavigateToVault; @@ -174,6 +181,7 @@ begin FServer.OnLog := LogLine; FBridge := TPMBridge.Create(Self); + FFileSaveChunks := TDictionary.Create; FBridge.OnSystemLock := BridgeSystemLock; FBridge.OnTrayRestore := BridgeTrayRestore; FBridge.OnLockRequest := BridgeLockRequest; @@ -243,6 +251,11 @@ end; procedure TMainForm.FormDestroy(Sender: TObject); begin + if Assigned(FFileSaveChunks) then + begin + for var LSB in FFileSaveChunks.Values do LSB.Free; + FFileSaveChunks.Free; + end; FBridge.Free; FServer.Free; end; @@ -440,6 +453,61 @@ begin Result := Copy(Result, 1, TokenPos + 4) + '***'; end; +procedure TMainForm.SaveDecodedFile(const AName, AB64, AReqId: string); +var + LOk: Boolean; + LPath, LErr: string; +begin + LOk := False; + LPath := ''; + LErr := ''; + try + var LBytes := TNetEncoding.Base64.DecodeStringToBytes(AB64); + var LDlg := TSaveDialog.Create(nil); + try + LDlg.FileName := AName; + var LExt := ExtractFileExt(AName); + if LExt = '.json' then LDlg.Filter := 'JSON file (*.json)|*.json|All files (*.*)|*.*' + else if LExt = '.csv' then LDlg.Filter := 'CSV file (*.csv)|*.csv|All files (*.*)|*.*' + else LDlg.Filter := 'All files (*.*)|*.*'; + LDlg.DefaultExt := LExt.TrimLeft(['.']); + LDlg.Options := LDlg.Options + [TOpenOption.ofOverwritePrompt]; + if LDlg.Execute then + begin + LPath := LDlg.FileName; + var LStream := TFileStream.Create(LPath, fmCreate); + try + if Length(LBytes) > 0 then + LStream.WriteBuffer(LBytes[0], Length(LBytes)); + finally + LStream.Free; + end; + LOk := True; + LogLine(Format('File saved: %s (%d bytes)', [LPath, Length(LBytes)])); + end + else + LogLine('File save cancelled by user'); + finally + LDlg.Free; + end; + except + on E: Exception do + begin + LErr := E.Message; + LogLine('File save FAILED: ' + LErr); + end; + end; + var LEscReq := StringReplace(AReqId, '"', '\"', [rfReplaceAll]); + var LEscPath := StringReplace(LPath, '\', '\\', [rfReplaceAll]); + LEscPath := StringReplace(LEscPath, '"', '\"', [rfReplaceAll]); + var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]); + LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onFileSaveResult)' + + 'Bridge.onFileSaveResult("' + LEscReq + '",' + + BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")'); +end; + procedure TMainForm.NavigateToVault; begin FPendingURL := 'http://127.0.0.1:' + FServer.BoundPort.ToString + '/index.html'; @@ -1107,58 +1175,48 @@ begin // Bridge.onFileSaveResult(reqId, ok, path). All synchronous on the UI // thread — payloads are small (a vault JSON export is well under 1 MB). else if ACmd = 'file/save' then + // Single-shot: small payload fits in one cmd:// URL. + SaveDecodedFile(GetParam('name'), GetParam('data'), GetParam('reqId')) + + // Chunked large-file transfer: accumulate base64 pieces keyed by reqId, + // ack each so JS can send the next (see Bridge.saveFile chunk path). + else if ACmd = 'file/chunk' then begin - var LName := GetParam('name'); - var LData := GetParam('data'); var LReqId := GetParam('reqId'); - var LOk := False; - var LPath := ''; - var LErr := ''; - try - var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData); - var LDlg := TSaveDialog.Create(nil); - try - LDlg.FileName := LName; - var LExt := ExtractFileExt(LName); - if LExt = '.json' then LDlg.Filter := 'JSON file (*.json)|*.json|All files (*.*)|*.*' - else if LExt = '.csv' then LDlg.Filter := 'CSV file (*.csv)|*.csv|All files (*.*)|*.*' - else LDlg.Filter := 'All files (*.*)|*.*'; - LDlg.DefaultExt := LExt.TrimLeft(['.']); - LDlg.Options := LDlg.Options + [TOpenOption.ofOverwritePrompt]; - if LDlg.Execute then - begin - LPath := LDlg.FileName; - var LStream := TFileStream.Create(LPath, fmCreate); - try - if Length(LBytes) > 0 then - LStream.WriteBuffer(LBytes[0], Length(LBytes)); - finally - LStream.Free; - end; - LOk := True; - LogLine(Format('File saved: %s (%d bytes)', [LPath, Length(LBytes)])); - end - else - LogLine('File save cancelled by user'); - finally - LDlg.Free; - end; - except - on E: Exception do - begin - LErr := E.Message; - LogLine('File save FAILED: ' + LErr); - end; + var LData := GetParam('data'); + var LSB: TStringBuilder; + if not FFileSaveChunks.TryGetValue(LReqId, LSB) then + begin + LSB := TStringBuilder.Create; + FFileSaveChunks.Add(LReqId, LSB); end; + LSB.Append(LData); var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); - var LEscPath := StringReplace(LPath, '\', '\\', [rfReplaceAll]); - LEscPath := StringReplace(LEscPath, '"', '\"', [rfReplaceAll]); - var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]); - LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]); WebBrowser.ExecuteJavaScript( - 'if(window.Bridge&&Bridge.onFileSaveResult)' + - 'Bridge.onFileSaveResult("' + LEscReq + '",' + - BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")'); + 'if(window.Bridge&&Bridge.onFileChunkAck)' + + 'Bridge.onFileChunkAck("' + LEscReq + '")'); + end + + // Commit the accumulated chunks: reconstruct the full base64, run the + // shared save routine, then discard the buffer. + else if ACmd = 'file/save-commit' then + begin + var LReqId := GetParam('reqId'); + var LSB: TStringBuilder; + if FFileSaveChunks.TryGetValue(LReqId, LSB) then + begin + var LFull := LSB.ToString; + LSB.Free; + FFileSaveChunks.Remove(LReqId); + SaveDecodedFile(GetParam('name'), LFull, LReqId); + end + else + begin + var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onFileSaveResult)' + + 'Bridge.onFileSaveResult("' + LEscReq + '",false,"","no chunks buffered")'); + end; end // ---- Auto-backup: folder picker (modal Win32 dialog) ----------------- diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index cbe7dd2..ede4dc4 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/index.html b/index.html index 5b5ed65..938deb0 100644 --- a/index.html +++ b/index.html @@ -1130,8 +1130,13 @@

Are you sure?

-