diff --git a/frontend/src/app/models/dtos/SearchResultDto.ts b/frontend/src/app/models/dtos/SearchResultDto.ts new file mode 100644 index 000000000..f8e0e0050 --- /dev/null +++ b/frontend/src/app/models/dtos/SearchResultDto.ts @@ -0,0 +1,9 @@ +export interface SearchResultDto { + name: string; + path: string; + type: 'file' | 'folder'; + size?: number; + mimeType: string | null; + lastModifiedAt: number; + score: number; +} diff --git a/frontend/src/app/pages/cloud/cloud.component.html b/frontend/src/app/pages/cloud/cloud.component.html index bf9379cb4..97b713bc4 100644 --- a/frontend/src/app/pages/cloud/cloud.component.html +++ b/frontend/src/app/pages/cloud/cloud.component.html @@ -5,7 +5,15 @@

Cloud Page

- {{ totalItemsInView }} items in this folder + + {{ totalItemsInView }} result{{ + totalItemsInView === 1 ? "" : "s" + }} + for "{{ searchQuery }}" + + + {{ totalItemsInView }} items in this folder +

@@ -56,6 +64,26 @@

Cloud Page

[appendTo]="'body'" [model]="sortMenuItems" > + + + + + Cloud Page
Cloud Page Name Size Type + Modified Actions @@ -139,6 +168,9 @@

Cloud Page

{{ entry.sizeLabel }} {{ entry.typeLabel }} + + {{ entry.lastModifiedAt | date: "short" }} +
Cloud Page - - This folder is empty. Create a new folder or file. + + + No results for "{{ searchQuery }}". + + + This folder is empty. Create a new folder or file. + diff --git a/frontend/src/app/pages/cloud/cloud.component.scss b/frontend/src/app/pages/cloud/cloud.component.scss index 1c715d584..2df52dd1f 100644 --- a/frontend/src/app/pages/cloud/cloud.component.scss +++ b/frontend/src/app/pages/cloud/cloud.component.scss @@ -44,9 +44,47 @@ .cloud-toolbar-start { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.4rem; } +.cloud-search { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.5rem; + border: 1px solid var(--color-input-border); + border-radius: 999px; + background: var(--color-input-bg); +} + +.cloud-search-icon { + color: var(--color-text-secondary); + font-size: 0.7rem; +} + +:host ::ng-deep .cloud-search .cloud-search-input.p-inputtext { + border: 0; + background: transparent; + box-shadow: none; + padding: 0.1rem 0; + width: clamp(6rem, 30vw, 9rem); +} + +.cloud-search-clear { + border: 0; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0; +} + +.cloud-search-clear:hover { + color: var(--color-text-primary); +} + :host ::ng-deep .cloud-breadcrumb { border: 0; padding: 0; diff --git a/frontend/src/app/pages/cloud/cloud.component.ts b/frontend/src/app/pages/cloud/cloud.component.ts index f371733ec..cdafafb4f 100644 --- a/frontend/src/app/pages/cloud/cloud.component.ts +++ b/frontend/src/app/pages/cloud/cloud.component.ts @@ -15,6 +15,7 @@ import { ToolbarModule } from 'primeng/toolbar'; import { FileDto } from '../../models/dtos/FileDto'; import { FolderDto } from '../../models/dtos/FolderDto'; import { FolderContentItemDto } from '../../models/dtos/FolderContentItemDto'; +import { SearchResultDto } from '../../models/dtos/SearchResultDto'; import { CloudService } from '../../services/cloud.service'; import { finalize, firstValueFrom } from 'rxjs'; import { UiToastService } from '../../core/services/ui-toast.service'; @@ -30,6 +31,7 @@ interface CloudEntry { path: string; sizeLabel: string; typeLabel: string; + lastModifiedAt: number; } type CloudSort = @@ -89,6 +91,10 @@ export class CloudComponent implements OnInit { entries: CloudEntry[] = []; downloadingPaths = new Set(); + searchQuery = ''; + searchActive = false; + searching = false; + pageSize = 50; totalElements = 0; contentFirst = 0; @@ -195,6 +201,19 @@ export class CloudComponent implements OnInit { path: item.path, sizeLabel: this.formatFileSize(item.size), typeLabel: item.directory ? 'Folder' : item.mimeType || 'Unknown', + lastModifiedAt: item.lastModifiedAt, + })); + } + + private buildSearchEntries(results: SearchResultDto[]): CloudEntry[] { + return results.map((result) => ({ + kind: result.type === 'folder' ? 'folder' : 'file', + name: result.name, + path: result.path, + sizeLabel: this.formatFileSize(result.size ?? 0), + typeLabel: + result.type === 'folder' ? 'Folder' : result.mimeType || 'Unknown', + lastModifiedAt: result.lastModifiedAt, })); } @@ -243,6 +262,53 @@ export class CloudComponent implements OnInit { setSort(sort: CloudSort) { if (this.sort === sort) return; this.sort = sort; + this.searchActive = false; + this.searchQuery = ''; + this.searching = false; + this.contentFirst = 0; + this.loading = true; + const relativePath = this.getRelativePath( + this.currentFolder?.path || this.rootPath, + ); + this.loadFolderContent(relativePath, 0); + } + + onSearch() { + const query = this.searchQuery.trim(); + if (!query) { + this.clearSearch(); + return; + } + const relativePath = this.getRelativePath( + this.currentFolder?.path || this.rootPath, + ); + const wasSearchActive = this.searchActive; + this.searching = true; + this.searchActive = true; + const requestId = ++this.contentRequestId; + this.cloudService.searchInFolder(relativePath, query, 100).subscribe({ + next: (results) => { + if (requestId !== this.contentRequestId) return; + this.entries = this.buildSearchEntries(results); + this.totalElements = this.entries.length; + this.searching = false; + }, + error: (err) => { + if (requestId !== this.contentRequestId) return; + this.searching = false; + // Restore the prior mode so a failed search doesn't strand the UI in + // "search mode" (no pagination, wrong subtitle) over folder contents. + this.searchActive = wasSearchActive; + this.toast.error('Search failed', this.getErrorMessage(err)); + }, + }); + } + + clearSearch() { + if (!this.searchActive && this.searchQuery === '') return; + this.searchQuery = ''; + this.searchActive = false; + this.searching = false; this.contentFirst = 0; this.loading = true; const relativePath = this.getRelativePath( @@ -252,9 +318,15 @@ export class CloudComponent implements OnInit { } loadRootFolder() { + this.searchActive = false; + this.searchQuery = ''; + this.searching = false; this.loading = true; this.error = undefined; this.contentFirst = 0; + // Invalidate any in-flight search/content request so a stale, fast + // response can't overwrite the root reload that's about to start. + this.contentRequestId++; this.cloudService.getRootFolder(false).subscribe({ next: (folder) => { this.currentFolder = folder; @@ -282,8 +354,14 @@ export class CloudComponent implements OnInit { } navigateToFolder(folderPath?: string) { + this.searchActive = false; + this.searchQuery = ''; + this.searching = false; this.loading = true; this.contentFirst = 0; + // Invalidate any in-flight search/content request so a stale response + // can't overwrite the entries while navigation is in progress. + this.contentRequestId++; const relativePath = this.getRelativePath(folderPath || this.rootPath); this.cloudService.getFolderByPath(relativePath, false).subscribe({ next: (folder) => { diff --git a/frontend/src/app/services/cloud.service.ts b/frontend/src/app/services/cloud.service.ts index 8cc51a2f1..c46344014 100644 --- a/frontend/src/app/services/cloud.service.ts +++ b/frontend/src/app/services/cloud.service.ts @@ -5,6 +5,7 @@ import { FolderDto } from '../models/dtos/FolderDto'; import { FolderContentItemDto } from '../models/dtos/FolderContentItemDto'; import { PageResponseDto } from '../models/dtos/PageResponseDto'; import { TrashEntryDto } from '../models/dtos/TrashEntryDto'; +import { SearchResultDto } from '../models/dtos/SearchResultDto'; import { environment } from '../../environments/environment'; @Injectable({ @@ -59,6 +60,20 @@ export class CloudService { ); } + searchInFolder( + folderPath: string, + query: string, + maxResults = 20, + ): Observable { + const params = new HttpParams() + .set('folderPath', this.normalizePath(folderPath)) + .set('query', query) + .set('maxResults', String(maxResults)); + return this.http.get(`${this.apiUrl}/folders/search`, { + params, + }); + } + getFileContent(relativePath: string): Observable { const path = this.normalizePath(relativePath); return this.http.get(`${this.apiUrl}/files/content`, {