From e0fbf23479eed6043c8e4341fe97b8c95165318b Mon Sep 17 00:00:00 2001 From: metal079 Date: Mon, 30 Sep 2024 22:06:27 -0500 Subject: [PATCH] Added favoriting feature --- src/_shared/mobians-image.interface.ts | 3 +- src/app/home/options/options.component.css | 39 +++ src/app/home/options/options.component.html | 122 +++++-- src/app/home/options/options.component.ts | 367 +++++++++++++++++++- src/modules/home.module.ts | 2 + src/styles.css | 51 +++ 6 files changed, 548 insertions(+), 36 deletions(-) diff --git a/src/_shared/mobians-image.interface.ts b/src/_shared/mobians-image.interface.ts index 5a2c49f..91020d6 100644 --- a/src/_shared/mobians-image.interface.ts +++ b/src/_shared/mobians-image.interface.ts @@ -11,7 +11,8 @@ export interface MobiansImage { promptSummary?: string; thumbnailUrl?: string; // Add this line blob?: Blob; + favorite?: boolean; } // Used just for the image history -export type MobiansImageMetadata = Pick; \ No newline at end of file +export type MobiansImageMetadata = Pick; \ No newline at end of file diff --git a/src/app/home/options/options.component.css b/src/app/home/options/options.component.css index 4a53ead..d45fcdf 100644 --- a/src/app/home/options/options.component.css +++ b/src/app/home/options/options.component.css @@ -213,6 +213,45 @@ max-height: 75vh; } +.history-item img { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.history-item img:hover { + transform: scale(1.05); /* Slight zoom */ + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); /* Add a shadow on hover */ +} + +/* Increase spacing between images */ +.history-grid { + grid-gap: 20px; /* Increase space between items */ +} + +.image-container { + position: relative; + display: inline-block; +} + +.top-left-icon { + position: absolute; + top: 8px; + left: 8px; + color: white; + cursor: pointer; + font-size: 1.5em; + border-radius: 50%; +} + +.top-right-icon { + position: absolute; + top: 8px; + right: 8px; + color: white; + cursor: pointer; + font-size: 1.5em; + border-radius: 50%; +} + @media (max-width: 768px) { .button-group { flex-direction: row; diff --git a/src/app/home/options/options.component.html b/src/app/home/options/options.component.html index f9e9cf3..9dc87ec 100644 --- a/src/app/home/options/options.component.html +++ b/src/app/home/options/options.component.html @@ -271,9 +271,9 @@
{{ loraItem.name }}
- - + +
@@ -290,37 +290,101 @@
{{ loraItem.name }}
+
-
- -
- -
-
- Generated Image -
- {{ image.timestamp | date:'short' }} - {{ image.promptSummary }} + + + +
+
-
-
- -
- No generated images available. -
- + +
+
+
+ Generated Image +
+ +
+
+ +
+
+
+ {{ image.timestamp | date:'short' }} + {{ image.promptSummary }} +
+
+
+ +
+ No generated images available. +
+ + + + + + +
+ +
+
+
+
+ Generated Image +
+ +
+
+ +
+
+
+ {{ image.timestamp | date:'short' }} + {{ image.promptSummary }} +
+
+
+ +
+ No favorite images available. +
+ +
+
+ \ No newline at end of file diff --git a/src/app/home/options/options.component.ts b/src/app/home/options/options.component.ts index 2ccc2c4..1130ae6 100644 --- a/src/app/home/options/options.component.ts +++ b/src/app/home/options/options.component.ts @@ -93,6 +93,17 @@ export class OptionsComponent implements OnInit { imageHistoryMetadata: MobiansImageMetadata[] = []; blobUrls: string[] = []; + // For Favorites tab + favoritePageImages: MobiansImage[] = []; + favoriteCurrentPageNumber: number = 1; + favoriteTotalPages: number = 1; + favoriteImagesPerPage: number = 4; + favoriteSearchQuery: string = ''; + debouncedFavoriteSearch: () => void; + + // For storing favorite images metadata + favoriteImageHistoryMetadata: MobiansImageMetadata[] = []; + // New properties for menus showOptions: boolean = false; showHistory: boolean = false; @@ -154,6 +165,10 @@ export class OptionsComponent implements OnInit { this.searchImages(); }, 300); // Wait for 300ms after the last keystroke before searching + this.debouncedFavoriteSearch = this.debounce(() => { + this.favoriteSearchImages(); + }, 300); // Wait for 300ms after the last keystroke before searching + this.blobMigrationService.progress$.subscribe( () => { this.showMigration = true; @@ -176,7 +191,7 @@ export class OptionsComponent implements OnInit { return; } - const request = indexedDB.open(this.dbName, 32); // Increment version number + const request = indexedDB.open(this.dbName, 36); // Increment version number request.onerror = (event) => { console.error("Failed to open database:", event); @@ -226,10 +241,35 @@ export class OptionsComponent implements OnInit { (store as any).createIndex('promptFullText', 'prompt', { type: 'text' }); console.log("Full-text prompt index created"); } + + // Create 'favorite' index if it doesn't exist + if (!store.indexNames.contains('favorite')) { + store.createIndex('favorite', 'favorite', { unique: false }); + console.log("Favorite index created"); + } } catch (error) { console.warn("Full-text index not supported in this browser:", error); } + // Iterate over all existing records and set 'favorite' to false if missing + try { + const allRequest = store.getAll(); + allRequest.onsuccess = () => { + const allRecords = allRequest.result as MobiansImage[]; + allRecords.forEach(record => { + if (typeof record.favorite !== 'boolean') { + record.favorite = false; + store.put(record); + } + }); + console.log("All existing records have been initialized with 'favorite' property."); + }; + allRequest.onerror = (event) => { + console.error("Error initializing 'favorite' property for existing records:", event); + }; + } catch (error) { + console.error("Error during records initialization:", error); + } } }; @@ -336,6 +376,10 @@ export class OptionsComponent implements OnInit { this.searchImages(); }, 300); // Wait for 300ms after the last keystroke before searching + this.debouncedFavoriteSearch = this.debounce(() => { + this.favoriteSearchImages(); + }, 300); // Wait for 300ms after the last keystroke before searching + try { await this.openDatabase(); console.log('Database and object stores created/updated successfully'); @@ -809,7 +853,7 @@ export class OptionsComponent implements OnInit { // Add the images to the image history metadata this.imageHistoryMetadata.unshift(...generatedImages.map((image: MobiansImage) => { - return { UUID: image.UUID, prompt: image.prompt!, promptSummary: image.promptSummary, timestamp: image.timestamp!, aspectRatio: image.aspectRatio, width: image.width }; + return { UUID: image.UUID, prompt: image.prompt!, promptSummary: image.promptSummary, timestamp: image.timestamp!, aspectRatio: image.aspectRatio, width: image.width, height: image.height, favorite: false }; })); try { @@ -1043,7 +1087,7 @@ export class OptionsComponent implements OnInit { aspectRatio: item.aspectRatio, width: item.width, height: item.height, - base64: "" + favorite: item.favorite })); console.log('Full-text search results:', projectedResults); console.log('Number of results:', projectedResults.length); @@ -1088,12 +1132,15 @@ export class OptionsComponent implements OnInit { // ... (rest of your code for sorting and pagination) this.imageHistoryMetadata = results; + this.favoriteImageHistoryMetadata = results.filter(image => image.favorite); this.currentPageNumber = 1; this.totalPages = Math.ceil(results.length / this.imagesPerPage); // Load the first page of images this.currentPageImages = await this.paginateImages(1); + this.updateFavoriteImages(); + } catch (error) { console.error("Error accessing database:", error); } finally { @@ -1253,10 +1300,13 @@ export class OptionsComponent implements OnInit { continue; // Skip the reference image } - image.base64 = ''; // Clear the base64 data + // Skip the wipe if the image is in the current favorite list page + if (this.favoriteImageHistoryMetadata.find(item => item.UUID === image.UUID)) { + continue; + } + + URL.revokeObjectURL(image.url!); image.url = ''; // Clear the URL - // image.prompt = ''; // Clear the prompt - // image.promptSummary = ''; // Clear the prompt summary } return images; } @@ -1470,4 +1520,309 @@ export class OptionsComponent implements OnInit { this.displayModal = false; this.selectedImageUrl = null; } + + //#region Favorite Images + toggleFavorite(image: MobiansImage) { + image.favorite = !image.favorite; + this.updateImageInDB(image); + this.updateFavoriteImages(); + } + + async deleteImage(image: MobiansImage) { + // Remove from current images arrays + this.currentPageImages = this.currentPageImages.filter(img => img.UUID !== image.UUID); + this.imageHistoryMetadata = this.imageHistoryMetadata.filter(img => img.UUID !== image.UUID); + + // Update total pages + this.totalPages = Math.ceil(this.imageHistoryMetadata.length / this.imagesPerPage); + + // Delete from favorites if necessary + if (image.favorite) { + this.favoritePageImages = this.favoritePageImages.filter(img => img.UUID !== image.UUID); + this.favoriteImageHistoryMetadata = this.favoriteImageHistoryMetadata.filter(img => img.UUID !== image.UUID); + this.favoriteTotalPages = Math.ceil(this.favoriteImageHistoryMetadata.length / this.favoriteImagesPerPage); + } + + // Delete the image from IndexedDB + this.deleteImageFromDB(image); + + // After deleting a single image we want to load the next image into this page to replace the deleted one if there are any + const nextImageIndex = this.imagesPerPage * (this.currentPageNumber) - 1; + if (this.imageHistoryMetadata.length > nextImageIndex) { + await this.loadImageData(this.imageHistoryMetadata[nextImageIndex]); + this.currentPageImages.push(this.imageHistoryMetadata[nextImageIndex]); + } + + // Do the same for the favorite images + const nextFavoriteImageIndex = this.favoriteImagesPerPage * (this.favoriteCurrentPageNumber) - 1; + if (this.favoriteImageHistoryMetadata.length > nextFavoriteImageIndex) { + await this.loadImageData(this.favoriteImageHistoryMetadata[nextFavoriteImageIndex]); + this.favoritePageImages.push(this.favoriteImageHistoryMetadata[nextFavoriteImageIndex]); + } + } + + async updateImageInDB(image: MobiansImage) { + try { + // Remove excess fields, we only need the favorite field + let imageMetadata: MobiansImage = { ...image }; + if (imageMetadata.blob) { + delete imageMetadata.blob; + } + if (imageMetadata.url) { + delete imageMetadata.url; + } + + const db = await this.getDatabase(); + const transaction = db.transaction(this.storeName, 'readwrite'); + const store = transaction.objectStore(this.storeName); + store.put(imageMetadata); + console.log('Image favorited:', imageMetadata); + } catch (error) { + console.error('Failed to update image in IndexedDB', error); + } + } + + async deleteImageFromDB(image: MobiansImage) { + try { + const db = await this.getDatabase(); + const transaction = db.transaction([this.storeName, 'blobStore'], 'readwrite'); + const store = transaction.objectStore(this.storeName); + const blobStore = transaction.objectStore('blobStore'); + + // Delete from metadata store + store.delete(image.UUID!); + + // Delete from blobStore + blobStore.delete(image.UUID!); + } catch (error) { + console.error('Failed to delete image from IndexedDB', error); + } + } + + updateFavoriteImages() { + this.favoriteTotalPages = Math.ceil(this.favoriteImageHistoryMetadata.length / this.favoriteImagesPerPage); + + if (this.favoriteCurrentPageNumber > this.favoriteTotalPages) { + this.favoriteCurrentPageNumber = 1; + } + + this.paginateFavoriteImages(this.favoriteCurrentPageNumber).then(images => { + this.favoritePageImages = images; + }); + } + + async paginateFavoriteImages(pageNumber: number): Promise { + try { + const queriedImages = await this.loadFavoriteImagePage(pageNumber); + return queriedImages; + } catch (error) { + console.error('Error in paginateFavoriteImages:', error); + throw error; + } + } + + async loadFavoriteImagePage(pageNumber: number) { + const images = this.favoriteImageHistoryMetadata.slice( + (pageNumber - 1) * this.favoriteImagesPerPage, + pageNumber * this.favoriteImagesPerPage + ); + const uuids = images.map(image => image.UUID); + + let intermediateImages: any[] = [...images]; + + try { + const db = await this.openDatabase(); + const transaction = db.transaction('blobStore', 'readonly'); + const store = transaction.objectStore('blobStore'); + + const requests = uuids.map((uuid, index) => { + return new Promise((resolve, reject) => { + const request = store.get(uuid); + request.onsuccess = async (event) => { + const result = request.result; + if (result) { + let blob = result.blob; + intermediateImages[index].url = URL.createObjectURL(blob); + this.blobUrls.push(intermediateImages[index].url); + } + resolve(undefined); + }; + request.onerror = () => { + reject(`Failed to load image data for UUID: ${uuid}`); + }; + }); + }); + + await Promise.all(requests); + + } catch (error) { + console.error('Failed to load image data', error); + } + + return intermediateImages; + } + + async previousFavoritePage() { + this.favoriteCurrentPageNumber--; + this.favoritePageImages = await this.paginateFavoriteImages(this.favoriteCurrentPageNumber); + } + + async nextFavoritePage() { + this.favoriteCurrentPageNumber++; + this.favoritePageImages = await this.paginateFavoriteImages(this.favoriteCurrentPageNumber); + } + + async favoriteSearchImages() { + this.isSearching = true; + try { + const db = await this.getDatabase(); + const transaction = db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + + const query = this.favoriteSearchQuery.trim().toLowerCase(); + console.log('Doing favorite Search query:', query); + + let favoritedResults: MobiansImage[] = []; + + // Helper function to project results + const projectResults = (searchResults: MobiansImage[]): MobiansImage[] => { + return searchResults.map(item => ({ + UUID: item.UUID, + prompt: item.prompt, + promptSummary: item.prompt?.slice(0, 50) + '...', // Truncate prompt summary + timestamp: item.timestamp, + aspectRatio: item.aspectRatio, + width: item.width, + height: item.height, + favorite: item.favorite + })); + }; + + // **Step 1: Fetch Favorited Images** + if (store.indexNames.contains('favorite') && 'getAll' in IDBIndex.prototype) { + try { + const favoriteIndex = store.index('favorite'); + console.log('Using favorite index:', favoriteIndex.name); + + const favRequest = favoriteIndex.getAll(IDBKeyRange.only(true)); + + favoritedResults = await new Promise((resolve, reject) => { + favRequest.onsuccess = () => { + const favResults = favRequest.result as MobiansImage[]; + const projectedFavResults = projectResults(favResults); + console.log('Favorited images:', projectedFavResults); + console.log('Number of favorited images:', projectedFavResults.length); + resolve(projectedFavResults); + }; + favRequest.onerror = (event) => { + console.error('Error fetching favorited images:', event); + reject(favRequest.error); + }; + }); + } catch (error) { + console.warn("Fetching favorited images failed:", error); + // **Fallback: Use a cursor to iterate and find favorited images** + favoritedResults = await new Promise((resolve, reject) => { + const favResults: MobiansImage[] = []; + const cursorRequest = store.openCursor(); + + cursorRequest.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result; + if (cursor) { + const image = cursor.value as MobiansImage; + if (image.favorite === true) { // Explicitly check for true + favResults.push({ + UUID: image.UUID, + prompt: image.prompt, + promptSummary: image.prompt?.slice(0, 50) + '...', + timestamp: image.timestamp, + aspectRatio: image.aspectRatio, + width: image.width, + height: image.height, + favorite: image.favorite + }); + } + cursor.continue(); + } else { + console.log('Cursor iteration complete. Favorited images:', favResults); + resolve(favResults); + } + }; + + cursorRequest.onerror = (event) => { + console.error('Error iterating with cursor:', event); + reject(cursorRequest.error); + }; + }); + } + } else { + console.log('Favorite index not available, using cursor to fetch favorited images'); + // **Fallback: Use a cursor to iterate and find favorited images** + favoritedResults = await new Promise((resolve, reject) => { + const favResults: MobiansImage[] = []; + const cursorRequest = store.openCursor(); + + cursorRequest.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result; + if (cursor) { + const image = cursor.value as MobiansImage; + if (image.favorite === true) { // Explicitly check for true + favResults.push({ + UUID: image.UUID, + prompt: image.prompt, + promptSummary: image.prompt?.slice(0, 50) + '...', + timestamp: image.timestamp, + aspectRatio: image.aspectRatio, + width: image.width, + height: image.height, + favorite: image.favorite + }); + } + cursor.continue(); + } else { + console.log('Cursor iteration complete. Favorited images:', favResults); + resolve(favResults); + } + }; + + cursorRequest.onerror = (event) => { + console.error('Error iterating with cursor:', event); + reject(cursorRequest.error); + }; + }); + } + + // **Step 2: Apply Prompt Search Filter to Favorited Images** + let filteredResults = favoritedResults.filter(image => + image.prompt && image.prompt.toLowerCase().includes(query) + ); + + // **Step 3: Sort the Results by Timestamp (Descending)** + filteredResults.sort((a, b) => { + const timestampA = a.timestamp ? a.timestamp.getTime() : 0; + const timestampB = b.timestamp ? b.timestamp.getTime() : 0; + return timestampB - timestampA; + }); + + console.log('Filtered results:', filteredResults.length); + const memoryUsage = this.memoryUsageService.roughSizeOfArrayOfObjects(filteredResults); + console.log(`Approximate memory usage: ${memoryUsage} bytes`); + + // **Update the UI or any other necessary actions** + this.favoriteImageHistoryMetadata = filteredResults; + this.updateFavoriteImages(); + + } catch (error) { + console.error("Error accessing database:", error); + // **Optional: Provide Error Feedback** + this.messageService.add({ + severity: 'error', + summary: 'Search Failed', + detail: 'Unable to perform favorite image search.' + }); + } finally { + this.isSearching = false; + } + } + //#endregion } diff --git a/src/modules/home.module.ts b/src/modules/home.module.ts index e8e6bbc..3ebf73c 100644 --- a/src/modules/home.module.ts +++ b/src/modules/home.module.ts @@ -28,6 +28,7 @@ import { DialogModule } from 'primeng/dialog'; import { TableModule } from 'primeng/table'; import { PanelModule } from 'primeng/panel'; import { ChipModule } from 'primeng/chip'; +import { TabViewModule } from 'primeng/tabview'; import { RouterModule } from '@angular/router'; @@ -60,6 +61,7 @@ import { RouterModule } from '@angular/router'; TableModule, PanelModule, ChipModule, + TabViewModule, RouterModule, ], exports: [ diff --git a/src/styles.css b/src/styles.css index 27d42d7..b2f8475 100644 --- a/src/styles.css +++ b/src/styles.css @@ -23,6 +23,57 @@ body { margin: 0 auto; } + +/* Make each tab item take equal space */ +.p-tabview .p-tabview-nav li { + flex: 1; + text-align: center; /* Center the tab text */ +} + +/* Ensure the anchor fills the entire tab */ +.p-tabview .p-tabview-nav li a { + display: block; + width: 100%; + padding: 1em 0; /* Adjust padding for vertical spacing */ + color: white; /* Optional: Change text color for better contrast */ +} + +/* Image History Panel Tabs */ +.p-tabview .p-tabview-nav { + background-color: #3880ec; + margin-bottom: 0; + padding-left: 0; + border: 1px solid deepskyblue; + border-width: 0 0 2px 0; + display: flex; +} + +.p-tabview .p-tabview-nav li a { + background-color: #2fa5e9; + color: #fff; /* Optional: Change text color for better contrast */ +} + +.p-tabview .p-tabview-nav li .p-tabview-nav-link { + border: solid deepskyblue; + border-width: 0 0 2px 0; +} + +.p-tabview .p-tabview-nav li.p-highlight .p-tabview-nav-link { + border: solid #3880ec; + border-width: 0 0 2px 0; + background: #3880ec; +} + +.p-tabview .p-tabview-panels { + background-color: #3880ec; + color:#000; +} + +.p-tabview .p-tabview-panel { + background-color: #3880ec; +} + + @media (max-width: 768px) { .page-container { padding: 1rem;