From a0c54cdc0e7fcdaf52dd8e3f08759bf1699fe40f Mon Sep 17 00:00:00 2001
From: Kentai Radiquum <kentai.waah@gmail.com>
Date: Sun, 9 Feb 2025 23:30:11 +0500
Subject: [PATCH] feat: add image favorite client-side

---
 src/build.tsx                                 |  20 +++
 src/icons.css                                 |  24 +++
 src/static/Placeholders.js                    |  62 ++++++++
 src/static/imagePageUtils.js                  |  63 +++++++-
 src/static/populateFavorites.js               |  40 +++++
 src/static/populateImages.js                  |  18 +--
 src/static/populateIndex.js                   |  38 ++++-
 src/static/populateVideos.js                  |  18 ---
 src/static/tailwind.css                       |  92 +++++++++++
 src/static/utils.js                           | 150 +++++++++++++-----
 .../Components/ImagePageNavigation.tsx        |  84 +++++-----
 src/templates/Components/PageNavigation.tsx   |   4 +-
 src/templates/Components/PlaceHolder.tsx      |  32 ++--
 src/templates/favorites.tsx                   |  14 ++
 src/templates/index.tsx                       |   9 +-
 15 files changed, 541 insertions(+), 127 deletions(-)
 create mode 100644 src/static/Placeholders.js
 create mode 100644 src/static/populateFavorites.js
 create mode 100644 src/templates/favorites.tsx

diff --git a/src/build.tsx b/src/build.tsx
index 2546205..a37fbc0 100644
--- a/src/build.tsx
+++ b/src/build.tsx
@@ -22,6 +22,7 @@ import type { ReactNode } from "react";
 import ImagesPage from "./templates/images";
 import VideosPage from "./templates/videos";
 import ImagePage from "./templates/viewImage";
+import FavoritesPage from "./templates/favorites";
 
 const S3 = new S3Client({
   region: "auto",
@@ -202,6 +203,7 @@ generateHTMLFile(
     videos.length
   } Videos | ${images.length + videos.length} Total`,
   [
+    environment == "dev" ? "/static/Placeholders.js" : "/static/Placeholders.min.js",
     environment == "dev" ? "/static/utils.js" : "/static/utils.min.js",
     environment == "dev"
       ? "/static/populateIndex.js"
@@ -216,6 +218,7 @@ generateHTMLFile(
   "/images/",
   `Image page of Wah-Collection | ${images.length} Images`,
   [
+    environment == "dev" ? "/static/Placeholders.js" : "/static/Placeholders.min.js",
     environment == "dev" ? "/static/utils.js" : "/static/utils.min.js",
     environment == "dev"
       ? "/static/populateImages.js"
@@ -241,6 +244,7 @@ generateHTMLFile(
   "/videos/",
   `Video page of Wah-Collection | ${videos.length} Videos`,
   [
+    environment == "dev" ? "/static/Placeholders.js" : "/static/Placeholders.min.js",
     environment == "dev" ? "/static/utils.js" : "/static/utils.min.js",
     environment == "dev"
       ? "/static/populateVideos.js"
@@ -249,3 +253,19 @@ generateHTMLFile(
   <VideosPage />,
   "out/videos/index.html"
 );
+
+generateHTMLFile(
+  "Wah-Collection/Favorites",
+  "/favorites/",
+  `Your favorites of Wah-Collection`,
+  [
+    environment == "dev" ? "/static/Placeholders.js" : "/static/Placeholders.min.js",
+    environment == "dev" ? "/static/utils.js" : "/static/utils.min.js",
+    environment == "dev"
+      ? "/static/populateFavorites.js"
+      : "/static/populateFavorites.min.js",
+  ],
+  <FavoritesPage />,
+  "out/favorites/index.html"
+);
+
diff --git a/src/icons.css b/src/icons.css
index 20f035e..25152b5 100644
--- a/src/icons.css
+++ b/src/icons.css
@@ -131,4 +131,28 @@
     mask-repeat: no-repeat;
     -webkit-mask-size: 100% 100%;
     mask-size: 100% 100%;
+}
+
+.material-symbols--favorite-outline-rounded {
+    display: inline-block;
+    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12 20.325q-.35 0-.712-.125t-.638-.4l-1.725-1.575q-2.65-2.425-4.788-4.812T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.325 0 2.5.562t2 1.538q.825-.975 2-1.537t2.5-.563q2.35 0 3.925 1.575T22 8.15q0 2.875-2.125 5.275T15.05 18.25l-1.7 1.55q-.275.275-.637.4t-.713.125M11.05 6.75q-.725-1.025-1.55-1.563t-2-.537q-1.5 0-2.5 1t-1 2.5q0 1.3.925 2.763t2.213 2.837t2.65 2.575T12 18.3q.85-.775 2.213-1.975t2.65-2.575t2.212-2.837T20 8.15q0-1.5-1-2.5t-2.5-1q-1.175 0-2 .538T12.95 6.75q-.175.25-.425.375T12 7.25t-.525-.125t-.425-.375m.95 4.725'/%3E%3C/svg%3E");
+    background-color: currentColor;
+    -webkit-mask-image: var(--svg);
+    mask-image: var(--svg);
+    -webkit-mask-repeat: no-repeat;
+    mask-repeat: no-repeat;
+    -webkit-mask-size: 100% 100%;
+    mask-size: 100% 100%;
+}
+
+.material-symbols--favorite-rounded {
+    display: inline-block;
+    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12 20.325q-.35 0-.712-.125t-.638-.4l-1.725-1.575q-2.65-2.425-4.788-4.812T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.325 0 2.5.562t2 1.538q.825-.975 2-1.537t2.5-.563q2.35 0 3.925 1.575T22 8.15q0 2.875-2.125 5.275T15.05 18.25l-1.7 1.55q-.275.275-.637.4t-.713.125'/%3E%3C/svg%3E");
+    background-color: currentColor;
+    -webkit-mask-image: var(--svg);
+    mask-image: var(--svg);
+    -webkit-mask-repeat: no-repeat;
+    mask-repeat: no-repeat;
+    -webkit-mask-size: 100% 100%;
+    mask-size: 100% 100%;
 }
\ No newline at end of file
diff --git a/src/static/Placeholders.js b/src/static/Placeholders.js
new file mode 100644
index 0000000..4b5901d
--- /dev/null
+++ b/src/static/Placeholders.js
@@ -0,0 +1,62 @@
+function Placeholder() {
+  const placeholderRoot = document.createElement("div");
+  placeholderRoot.dataset.type = "placeholder__image__container";
+  placeholderRoot.className =
+    "relative aspect-square min-w-48 min-h-48 sm:min-w-auto rounded-sm overflow-hidden";
+
+  const placeholderImage = document.createElement("a");
+  placeholderImage.dataset.type = "placeholder__image";
+
+  const placeholderImageLoader = document.createElement("div");
+  placeholderImageLoader.dataset.type = "placeholder__image__loader";
+  placeholderImageLoader.className =
+    "w-full h-full absolute inset-0 bg-gray-400 opacity-30 animate-pulse z-[3]";
+
+  const favoriteButton = document.createElement("button");
+  favoriteButton.dataset.type = "image__fav";
+  favoriteButton.className =
+    "hidden absolute right-2 top-2 w-8 h-8 cursor-pointer";
+  favoriteButton.innerHTML =
+    '<div class="text-[#faebeb] hover:text-orange-500 transition-colors material-symbols--favorite-outline-rounded w-full h-full"></div>';
+  const unfavoriteButton = document.createElement("button");
+  unfavoriteButton.dataset.type = "image__unfav";
+  unfavoriteButton.className =
+    "hidden absolute right-2 top-2 w-8 h-8 cursor-pointer";
+  unfavoriteButton.innerHTML =
+    '<div class="text-[#faebeb] hover:text-orange-500 transition-colors material-symbols--favorite-rounded w-full h-full"></div>';
+
+  placeholderImage.appendChild(placeholderImageLoader);
+  placeholderRoot.appendChild(placeholderImage);
+  placeholderRoot.appendChild(favoriteButton);
+  placeholderRoot.appendChild(unfavoriteButton);
+
+  return placeholderRoot;
+}
+
+function PlaceholderVid() {
+  const placeholder = document.createElement("video");
+  placeholder.dataset.type = "placeholder__video";
+  placeholder.controls = true;
+  placeholder.className =
+    "relative aspect-square w-full h-full rounded-sm [&:not(:fullscreen)]:object-cover";
+
+  const placeholder_loader = document.createElement("div");
+  placeholder_loader.dataset.type = "placeholder__video__loader";
+  placeholder_loader.className =
+    "w-full h-full absolute inset-0 bg-gray-400 opacity-30 animate-pulse z-[3]";
+  const placeholder_source = document.createElement("source");
+  placeholder.appendChild(placeholder_loader);
+  placeholder.appendChild(placeholder_source);
+
+  return placeholder;
+}
+
+function AllLink(href, title) {
+    const link = document.createElement("a");
+    link.href = href;
+    link.className = "text-[#f9ebeb] hover:bg-orange-600 rounded-sm overflow-hidden transition-colors aspect-square bg-yellow-950 min-w-48 sm:min-w-auto flex items-center justify-center flex-col";
+    link.innerHTML = `
+    <span class="material-symbols--arrow-forward-rounded w-16 h-16"></span>
+    <p class="text-xl">${title}</p>`
+    return link
+}
\ No newline at end of file
diff --git a/src/static/imagePageUtils.js b/src/static/imagePageUtils.js
index 650c2a7..72c100c 100644
--- a/src/static/imagePageUtils.js
+++ b/src/static/imagePageUtils.js
@@ -40,12 +40,12 @@ function renderImage(endpoint, bucket, prefix, isrc, placeholder) {
   const Img = document.createElement("img");
   blurImg.src = `https://wsrv.nl/?url=${encodeURI(src)}&w=16&h=16`;
   blurImg.className = "object-cover w-full h-full absolute inset-0";
-  blurImg.alt = `Loading: ${isrc}`
+  blurImg.alt = `Loading: ${isrc}`;
   Img.dataset.hi = "fit";
   Img.src = `https://wsrv.nl/?url=${encodeURI(src)}`;
   Img.className = "invisible w-full h-full object-contain";
   Img.loading = "lazy";
-  Img.alt = isrc
+  Img.alt = isrc;
 
   placeholder.appendChild(blurImg);
   placeholder.appendChild(Img);
@@ -150,6 +150,33 @@ function _tmp_loadNav(url, iid) {
   const download = document.querySelectorAll("#act_download");
   const newtab = document.querySelectorAll("#act_newtab");
 
+  const favoriteButton = document.querySelectorAll('[data-type="image__fav"]');
+  const unfavoriteButton = document.querySelectorAll(
+    '[data-type="image__unfav"]'
+  );
+
+  const isFav = getFavorites().find((el) => el.iid == iid) || false;
+  favoriteButton.forEach((btn) => {
+    btn.addEventListener("click", () => {
+      addFavorites(iid, "image");
+      favoriteButton.forEach((item) => item.classList.add("hidden"));
+      unfavoriteButton.forEach((item) => item.classList.remove("hidden"));
+    });
+  });
+  unfavoriteButton.forEach((btn) => {
+    btn.addEventListener("click", () => {
+      removeFavorites(iid);
+      favoriteButton.forEach((item) => item.classList.remove("hidden"));
+      unfavoriteButton.forEach((item) => item.classList.add("hidden"));
+    });
+  });
+  console.log(isFav)
+  if (!isFav) {
+    favoriteButton.forEach((item) => item.classList.remove("hidden"));
+  } else {
+    unfavoriteButton.forEach((item) => item.classList.remove("hidden"));
+  }
+
   function handleClickPrev() {
     window.location.href = `/image/?id=${Number(iid) - 1}`;
   }
@@ -211,6 +238,38 @@ function _tmp_loadNav(url, iid) {
   });
 }
 
+function getFavorites() {
+  const favs = localStorage.getItem("favorites");
+  if (!favs) {
+    setFavorites([]);
+    return [];
+  }
+  return JSON.parse(favs);
+}
+
+function setFavorites(favs) {
+  localStorage.setItem("favorites", JSON.stringify(favs));
+}
+
+function addFavorites(iid, type) {
+  const favs = getFavorites();
+  const newFav = {
+    iid,
+    type,
+  };
+  const newFavs = [...favs, newFav];
+  setFavorites(newFavs);
+}
+
+function removeFavorites(iid) {
+  const idx = getFavorites().findIndex((el) => el.iid == iid);
+  const favs = getFavorites();
+  if (idx > -1) {
+    favs.splice(idx, 1);
+    setFavorites(favs);
+  }
+}
+
 window.onload = () => {
   const u = new URL(window.location.href);
   const iid = u.searchParams.get("id");
diff --git a/src/static/populateFavorites.js b/src/static/populateFavorites.js
new file mode 100644
index 0000000..5ef6a9f
--- /dev/null
+++ b/src/static/populateFavorites.js
@@ -0,0 +1,40 @@
+async function __tmp_loadFavs() {
+  const container = document.getElementById("favorites_favorites");
+
+  let config = await get("/data/config.json");
+  let images = await get("/data/images.json");
+  let videos = await get("/data/videos.json");
+  let favs = getFavorites();
+
+  const start = getOffset();
+  const end = getOffset() + getImagesPerPage();
+
+  if (favs.slice(start, end) <= 0) {
+    container.innerHTML =
+      '<p class="text-2xl mx-auto col-span-full">No Favorites Found!</p>';
+  }
+
+  favs.slice(start, end).forEach((item, idx) => {
+    let pl;
+    if (item.type == "image") {
+      pl = container.appendChild(Placeholder());
+      setTimeout(() => {
+        renderImage(
+          config.endpoint,
+          config.bucket,
+          config.prefix,
+          images[item.iid],
+          item.iid,
+          pl
+        );
+      }, 250);
+    } else {
+      console.log("video not supported");
+    }
+  });
+}
+
+window.onload = () => {
+  enableNav();
+  __tmp_loadFavs();
+};
diff --git a/src/static/populateImages.js b/src/static/populateImages.js
index aedf506..3c1ef05 100644
--- a/src/static/populateImages.js
+++ b/src/static/populateImages.js
@@ -1,19 +1,3 @@
-function Placeholder() {
-  const placeholder = document.createElement("a");
-  placeholder.dataset.type = "placeholder__image";
-  placeholder.className =
-    "relative aspect-square min-w-48 sm:min-w-auto rounded-sm overflow-hidden";
-
-  const placeholder_loader = document.createElement("div");
-  placeholder_loader.dataset.type = "placeholder__image__loader";
-  placeholder_loader.className =
-    "w-full h-full absolute inset-0 bg-gray-400 opacity-30 animate-pulse z-[3]";
-
-  placeholder.appendChild(placeholder_loader);
-
-  return placeholder;
-}
-
 async function __tmp_loadImages() {
   const container = document.getElementById("images_images");
 
@@ -35,7 +19,7 @@ async function __tmp_loadImages() {
 
   images.slice(start, end).forEach((image, idx) => {
     container.appendChild(Placeholder());
-    let Images = document.querySelectorAll('[data-type="placeholder__image"]');
+    let Images = document.querySelectorAll('[data-type="placeholder__image__container"]');
     const iid = Number(start) + Number(idx);
     setTimeout(() => {
       renderImage(
diff --git a/src/static/populateIndex.js b/src/static/populateIndex.js
index b21e33d..4c90c46 100644
--- a/src/static/populateIndex.js
+++ b/src/static/populateIndex.js
@@ -16,7 +16,11 @@ async function populateIndex() {
   let images = await get("/data/images.json");
   let videos = await get("/data/videos.json");
 
-  const Images = document.querySelectorAll('[data-type="placeholder__image"]');
+  let favs = getFavorites();
+
+  const Images = document.querySelectorAll(
+    '[data-type="placeholder__image__container"]'
+  );
   const VisibleImages = [];
   Images.forEach((placeholder) => {
     if (placeholder.checkVisibility()) {
@@ -49,8 +53,38 @@ async function populateIndex() {
       config.bucket,
       config.prefix,
       video.src,
-      VisibleVideos[idx])
+      VisibleVideos[idx]
+    );
   });
+
+  const FavoritesContainer = document.getElementById("index_favorites");
+  if (favs.length > 0) {
+    FavoritesContainer.innerHTML = "";
+    favs.forEach((item, idx) => {
+      if (idx >= 11) return;
+      let pl;
+      if (item.type == "image") {
+        pl = FavoritesContainer.appendChild(Placeholder());
+        setTimeout(() => {
+          renderImage(
+            config.endpoint,
+            config.bucket,
+            config.prefix,
+            images[item.iid],
+            item.iid,
+            pl
+          );
+        }, 250);
+      } else {
+        console.log("video not supported");
+      }
+    });
+    if (favs.length >= 11) {
+      FavoritesContainer.appendChild(
+        AllLink("/favorites/", "View All Favorites")
+      );
+    }
+  }
 }
 window.onload = () => {
   populateIndex();
diff --git a/src/static/populateVideos.js b/src/static/populateVideos.js
index dfa501e..7f122e6 100644
--- a/src/static/populateVideos.js
+++ b/src/static/populateVideos.js
@@ -1,21 +1,3 @@
-function PlaceholderVid() {
-  const placeholder = document.createElement("video");
-  placeholder.dataset.type = "placeholder__video";
-  placeholder.controls = true;
-  placeholder.className =
-    "relative aspect-square w-full h-full rounded-sm [&:not(:fullscreen)]:object-cover";
-
-  const placeholder_loader = document.createElement("div");
-  placeholder_loader.dataset.type = "placeholder__video__loader";
-  placeholder_loader.className =
-    "w-full h-full absolute inset-0 bg-gray-400 opacity-30 animate-pulse z-[3]";
-  const placeholder_source = document.createElement("source");
-  placeholder.appendChild(placeholder_loader);
-  placeholder.appendChild(placeholder_source);
-
-  return placeholder;
-}
-
 async function __tmp_loadVideos() {
   const container = document.getElementById("videos_videos");
 
diff --git a/src/static/tailwind.css b/src/static/tailwind.css
index 53fabf7..643ce66 100644
--- a/src/static/tailwind.css
+++ b/src/static/tailwind.css
@@ -521,6 +521,12 @@
   }
 }
 @layer utilities {
+  .pointer-events-auto {
+    pointer-events: auto;
+  }
+  .pointer-events-none {
+    pointer-events: none;
+  }
   .collapse {
     visibility: collapse;
   }
@@ -545,21 +551,51 @@
   .top-0 {
     top: calc(var(--spacing) * 0);
   }
+  .top-2 {
+    top: calc(var(--spacing) * 2);
+  }
   .right-0 {
     right: calc(var(--spacing) * 0);
   }
+  .right-2 {
+    right: calc(var(--spacing) * 2);
+  }
+  .right-8 {
+    right: calc(var(--spacing) * 8);
+  }
+  .bottom-2 {
+    bottom: calc(var(--spacing) * 2);
+  }
+  .bottom-8 {
+    bottom: calc(var(--spacing) * 8);
+  }
   .left-0 {
     left: calc(var(--spacing) * 0);
   }
+  .z-4 {
+    z-index: 4;
+  }
+  .z-5 {
+    z-index: 5;
+  }
   .z-10 {
     z-index: 10;
   }
   .z-\[3\] {
     z-index: 3;
   }
+  .z-\[4\] {
+    z-index: 4;
+  }
+  .z-\[15\] {
+    z-index: 15;
+  }
   .\[grid-column\:span_2\] {
     grid-column: span 2;
   }
+  .col-span-full {
+    grid-column: 1 / -1;
+  }
   .\[grid-row\:span_2\] {
     grid-row: span 2;
   }
@@ -638,6 +674,12 @@
   .h-8 {
     height: calc(var(--spacing) * 8);
   }
+  .h-12 {
+    height: calc(var(--spacing) * 12);
+  }
+  .h-14 {
+    height: calc(var(--spacing) * 14);
+  }
   .h-16 {
     height: calc(var(--spacing) * 16);
   }
@@ -659,6 +701,12 @@
   .w-8 {
     width: calc(var(--spacing) * 8);
   }
+  .w-12 {
+    width: calc(var(--spacing) * 12);
+  }
+  .w-14 {
+    width: calc(var(--spacing) * 14);
+  }
   .w-16 {
     width: calc(var(--spacing) * 16);
   }
@@ -752,6 +800,9 @@
   .bg-gray-400\/50 {
     background-color: color-mix(in oklab, var(--color-gray-400) 50%, transparent);
   }
+  .bg-gray-600 {
+    background-color: var(--color-gray-600);
+  }
   .bg-orange-800 {
     background-color: var(--color-orange-800);
   }
@@ -779,6 +830,10 @@
   .py-4 {
     padding-block: calc(var(--spacing) * 4);
   }
+  .text-2xl {
+    font-size: var(--text-2xl);
+    line-height: var(--tw-leading, var(--text-2xl--line-height));
+  }
   .text-4xl {
     font-size: var(--text-4xl);
     line-height: var(--tw-leading, var(--text-4xl--line-height));
@@ -801,15 +856,30 @@
   .text-\[\#f9ebeb\] {
     color: #f9ebeb;
   }
+  .text-\[\#faebeb\] {
+    color: #faebeb;
+  }
+  .text-gray-100 {
+    color: var(--color-gray-100);
+  }
   .text-gray-200 {
     color: var(--color-gray-200);
   }
   .text-gray-200\/75 {
     color: color-mix(in oklab, var(--color-gray-200) 75%, transparent);
   }
+  .text-gray-300 {
+    color: var(--color-gray-300);
+  }
+  .text-gray-500 {
+    color: var(--color-gray-500);
+  }
   .text-orange-400 {
     color: var(--color-orange-400);
   }
+  .text-slate-900 {
+    color: var(--color-slate-900);
+  }
   .text-white {
     color: var(--color-white);
   }
@@ -1099,6 +1169,28 @@
   -webkit-mask-size: 100% 100%;
   mask-size: 100% 100%;
 }
+.material-symbols--favorite-outline-rounded {
+  display: inline-block;
+  --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12 20.325q-.35 0-.712-.125t-.638-.4l-1.725-1.575q-2.65-2.425-4.788-4.812T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.325 0 2.5.562t2 1.538q.825-.975 2-1.537t2.5-.563q2.35 0 3.925 1.575T22 8.15q0 2.875-2.125 5.275T15.05 18.25l-1.7 1.55q-.275.275-.637.4t-.713.125M11.05 6.75q-.725-1.025-1.55-1.563t-2-.537q-1.5 0-2.5 1t-1 2.5q0 1.3.925 2.763t2.213 2.837t2.65 2.575T12 18.3q.85-.775 2.213-1.975t2.65-2.575t2.212-2.837T20 8.15q0-1.5-1-2.5t-2.5-1q-1.175 0-2 .538T12.95 6.75q-.175.25-.425.375T12 7.25t-.525-.125t-.425-.375m.95 4.725'/%3E%3C/svg%3E");
+  background-color: currentColor;
+  -webkit-mask-image: var(--svg);
+  mask-image: var(--svg);
+  -webkit-mask-repeat: no-repeat;
+  mask-repeat: no-repeat;
+  -webkit-mask-size: 100% 100%;
+  mask-size: 100% 100%;
+}
+.material-symbols--favorite-rounded {
+  display: inline-block;
+  --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12 20.325q-.35 0-.712-.125t-.638-.4l-1.725-1.575q-2.65-2.425-4.788-4.812T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.325 0 2.5.562t2 1.538q.825-.975 2-1.537t2.5-.563q2.35 0 3.925 1.575T22 8.15q0 2.875-2.125 5.275T15.05 18.25l-1.7 1.55q-.275.275-.637.4t-.713.125'/%3E%3C/svg%3E");
+  background-color: currentColor;
+  -webkit-mask-image: var(--svg);
+  mask-image: var(--svg);
+  -webkit-mask-repeat: no-repeat;
+  mask-repeat: no-repeat;
+  -webkit-mask-size: 100% 100%;
+  mask-size: 100% 100%;
+}
 @keyframes spin {
   to {
     transform: rotate(360deg);
diff --git a/src/static/utils.js b/src/static/utils.js
index 400e224..2a177e0 100644
--- a/src/static/utils.js
+++ b/src/static/utils.js
@@ -13,17 +13,27 @@ function getAspectVid(video) {
   return Number(video.videoWidth / video.videoHeight);
 }
 
-function renderImage(endpoint, bucket, prefix, isrc, iid, placeholder) {
+function renderImage(endpoint, bucket, prefix, isrc, iid, placeholderRoot) {
   const src = `${endpoint}/${bucket}/${prefix}/${isrc}`;
-  const loader = placeholder.querySelector(
+  const placeholderImage = placeholderRoot.querySelector(
+    '[data-type="placeholder__image"]'
+  );
+  const placeholderImageLoader = placeholderImage.querySelector(
     '[data-type="placeholder__image__loader"]'
   );
+  const favoriteButton = placeholderRoot.querySelector(
+    '[data-type="image__fav"]'
+  );
+  const unfavoriteButton = placeholderRoot.querySelector(
+    '[data-type="image__unfav"]'
+  );
+
   const blurImg = document.createElement("img");
   const Img = document.createElement("img");
   blurImg.src = `https://wsrv.nl/?url=${encodeURI(src)}&w=16&h=16`;
   blurImg.className = "object-cover w-full h-full absolute inset-0";
   blurImg.loading = "lazy";
-  blurImg.alt = `Loading: ${isrc}`
+  blurImg.alt = `Loading: ${isrc}`;
   Img.src = `https://wsrv.nl/?url=${encodeURI(src)}&w=256&h=256`;
   Img.srcset = `https://wsrv.nl/?url=${encodeURI(
     src
@@ -31,19 +41,41 @@ function renderImage(endpoint, bucket, prefix, isrc, iid, placeholder) {
   Img.sizes = `(max-width: 600px) 256px, 512px`;
   Img.className = "invisible object-cover w-full h-full absolute inset-0";
   Img.loading = "lazy";
-  Img.alt = isrc
+  Img.alt = isrc;
+
+  const isFav = getFavorites().find((el) => el.iid == iid) || false;
+  favoriteButton.addEventListener("click", () => {
+    addFavorites(iid, "image");
+    favoriteButton.classList.add("hidden");
+    unfavoriteButton.classList.remove("hidden");
+  });
+  unfavoriteButton.addEventListener("click", () => {
+    removeFavorites(iid);
+    favoriteButton.classList.remove("hidden");
+    unfavoriteButton.classList.add("hidden");
+  });
+  if (!isFav) {
+    favoriteButton.classList.remove("hidden");
+  } else {
+    unfavoriteButton.classList.remove("hidden");
+  }
 
   const view = getView();
-  const container = document.getElementById("images_images");
+  const container = document.getElementById("images_images") || document.getElementById("favorites_favorites");
 
-  placeholder.appendChild(blurImg);
-  placeholder.appendChild(Img);
+  placeholderImage.appendChild(blurImg);
+  placeholderImage.appendChild(Img);
 
   if (
     view == "masonry" &&
-    ["/images", "/images/", "/images/index.html"].includes(
-      window.location.pathname
-    )
+    [
+      "/images",
+      "/images/",
+      "/images/index.html",
+      "/favorites",
+      "/favorites/",
+      "/favorites/index.html",
+    ].includes(window.location.pathname)
   ) {
     container.classList.remove(
       "xl:grid-cols-[repeat(auto-fill,minmax(20%,1fr))]"
@@ -55,21 +87,21 @@ function renderImage(endpoint, bucket, prefix, isrc, iid, placeholder) {
       const aspect = getAspect(blurImg);
 
       if (aspect < 0.95) {
-        placeholder.classList.remove("aspect-square");
-        placeholder.classList.add("aspect-[1/2]");
-        placeholder.classList.add("w-full");
-        placeholder.classList.add("h-full");
+        placeholderRoot.classList.remove("aspect-square");
+        placeholderRoot.classList.add("aspect-[1/2]");
+        placeholderRoot.classList.add("w-full");
+        placeholderRoot.classList.add("h-full");
         Img.classList.add("object-cover");
         blurImg.classList.add("object-cover");
-        placeholder.classList.add("[grid-row:span_2]");
+        placeholderRoot.classList.add("[grid-row:span_2]");
       } else if (aspect > 1.05) {
-        placeholder.classList.remove("aspect-square");
-        placeholder.classList.add("aspect-[2/1]");
-        placeholder.classList.add("w-full");
-        placeholder.classList.add("h-full");
+        placeholderRoot.classList.remove("aspect-square");
+        placeholderRoot.classList.add("aspect-[2/1]");
+        placeholderRoot.classList.add("w-full");
+        placeholderRoot.classList.add("h-full");
         Img.classList.add("object-cover");
         blurImg.classList.add("object-cover");
-        placeholder.classList.add("[grid-column:span_2]");
+        placeholderRoot.classList.add("[grid-column:span_2]");
       }
 
       blurImg.removeEventListener("load", this);
@@ -78,9 +110,9 @@ function renderImage(endpoint, bucket, prefix, isrc, iid, placeholder) {
 
   Img.addEventListener("load", () => {
     Img.classList.remove("invisible");
-    if (view == "grid") blurImg.remove();
-    loader.remove();
-    placeholder.href = `/image/?id=${iid}`;
+    blurImg.remove();
+    placeholderImageLoader.remove();
+    placeholderImage.href = `/image/?id=${iid}`;
     Img.removeEventListener("load", this);
   });
 }
@@ -134,6 +166,38 @@ function renderVideo(endpoint, bucket, prefix, vsrc, placeholder) {
   }
 }
 
+function getFavorites() {
+  const favs = localStorage.getItem("favorites");
+  if (!favs) {
+    setFavorites([]);
+    return [];
+  }
+  return JSON.parse(favs);
+}
+
+function setFavorites(favs) {
+  localStorage.setItem("favorites", JSON.stringify(favs));
+}
+
+function addFavorites(iid, type) {
+  const favs = getFavorites();
+  const newFav = {
+    iid,
+    type,
+  };
+  const newFavs = [...favs, newFav];
+  setFavorites(newFavs);
+}
+
+function removeFavorites(iid) {
+  const idx = getFavorites().findIndex((el) => el.iid == iid);
+  const favs = getFavorites();
+  if (idx > -1) {
+    favs.splice(idx, 1);
+    setFavorites(favs);
+  }
+}
+
 function setView(view) {
   localStorage.setItem("view", view);
 }
@@ -244,9 +308,14 @@ function getOffset() {
 function enableNav() {
   function handleClickPrev() {
     if (
-      ["/images", "/images/", "/images/index.html"].includes(
-        window.location.pathname
-      )
+      [
+        "/images",
+        "/images/",
+        "/images/index.html",
+        "/favorites",
+        "/favorites/",
+        "/favorites/index.html",
+      ].includes(window.location.pathname)
     ) {
       setOffset(getOffset() - getImagesPerPage());
     } else if (
@@ -260,9 +329,14 @@ function enableNav() {
 
   function handleClickNext() {
     if (
-      ["/images", "/images/", "/images/index.html"].includes(
-        window.location.pathname
-      )
+      [
+        "/images",
+        "/images/",
+        "/images/index.html",
+        "/favorites",
+        "/favorites/",
+        "/favorites/index.html",
+      ].includes(window.location.pathname)
     ) {
       setOffset(getOffset() + getImagesPerPage());
     } else if (
@@ -274,20 +348,18 @@ function enableNav() {
     }
   }
 
-  function handleClickIpp(ipp) {
-    setVideosPerPage(ipp);
-    const url = new URL(window.location.toString());
-    url.searchParams.set("VideosPP", ipp);
-    window.location.href = url.href;
-  }
-
   function handleClickIpp(ipp) {
     const url = new URL(window.location.toString());
 
     if (
-      ["/images", "/images/", "/images/index.html"].includes(
-        window.location.pathname
-      )
+      [
+        "/images",
+        "/images/",
+        "/images/index.html",
+        "/favorites",
+        "/favorites/",
+        "/favorites/index.html",
+      ].includes(window.location.pathname)
     ) {
       setImagesPerPage(ipp);
       url.searchParams.set("ImagesPP", ipp);
diff --git a/src/templates/Components/ImagePageNavigation.tsx b/src/templates/Components/ImagePageNavigation.tsx
index d7cfbbd..e345c4d 100644
--- a/src/templates/Components/ImagePageNavigation.tsx
+++ b/src/templates/Components/ImagePageNavigation.tsx
@@ -10,43 +10,55 @@ export default function ImagePageNav() {
         </button>
 
         <div className="flex gap-4 md:gap-8 flex-col md:flex-row">
-            <div className="flex gap-4">
-              <button
-                className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
-                id="fit_cover"
-              >
-                <div className="material-symbols--crop w-8 h-8"></div>
-              </button>
-              <button
-                className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
-                id="fit_contain"
-              >
-                <div className="material-symbols--fullscreen-exit w-8 h-8"></div>
-              </button>
-              <button
-                className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
-                id="fit_full"
-              >
-                <div className="material-symbols--open-in-full w-8 h-8"></div>
-              </button>
-            </div>
-    
-            <div className="flex gap-4">
-              <button
-                className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
-                id="act_download"
-              >
-                <div className="material-symbols--download w-8 h-8"></div>
-              </button>
-              <button
-                className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
-                id="act_newtab"
-              >
-                <div className="material-symbols--open-in-new w-8 h-8"></div>
-              </button>
-            </div>
+          <div className="flex gap-4">
+            <button
+              className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
+              id="fit_cover"
+            >
+              <div className="material-symbols--crop w-8 h-8"></div>
+            </button>
+            <button
+              className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
+              id="fit_contain"
+            >
+              <div className="material-symbols--fullscreen-exit w-8 h-8"></div>
+            </button>
+            <button
+              className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
+              id="fit_full"
+            >
+              <div className="material-symbols--open-in-full w-8 h-8"></div>
+            </button>
+          </div>
+
+          <div className="flex gap-4">
+            <button
+              className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
+              id="act_download"
+            >
+              <div className="material-symbols--download w-8 h-8"></div>
+            </button>
+            <button
+              className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
+              id="act_newtab"
+            >
+              <div className="material-symbols--open-in-new w-8 h-8"></div>
+            </button>
+            <button
+              data-type="image__fav"
+              className="hidden w-8 h-8 cursor-pointer"
+            >
+              <div className="text-[#faebeb] hover:text-orange-500 transition-colors material-symbols--favorite-outline-rounded w-full h-full"></div>
+            </button>
+            <button
+              data-type="image__unfav"
+              className="hidden w-8 h-8 cursor-pointer"
+            >
+              <div className="text-[#faebeb] hover:text-orange-500 transition-colors material-symbols--favorite-rounded w-full h-full"></div>
+            </button>
+          </div>
         </div>
-        
+
         <button
           className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
           id="nav_next"
diff --git a/src/templates/Components/PageNavigation.tsx b/src/templates/Components/PageNavigation.tsx
index 9c261b9..ff21916 100644
--- a/src/templates/Components/PageNavigation.tsx
+++ b/src/templates/Components/PageNavigation.tsx
@@ -12,7 +12,7 @@ export default function PageNav(props: { path: string }) {
         className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
         id="nav_prev"
       >
-        <div className="material-symbols--navigate-before w-16 h-16"></div>
+        <div className="material-symbols--navigate-before w-14 h-14"></div>
       </button>
       <div className="flex gap-4">
         {ipp.map((item, idx) => {
@@ -51,7 +51,7 @@ export default function PageNav(props: { path: string }) {
           className="flex justify-center items-center cursor-pointer hover:text-orange-500 transition-colors"
           id="nav_next"
         >
-          <div className="material-symbols--navigate-next w-16 h-16"></div>
+          <div className="material-symbols--navigate-next w-14 h-14"></div>
         </button>
       </div>
     </div>
diff --git a/src/templates/Components/PlaceHolder.tsx b/src/templates/Components/PlaceHolder.tsx
index a169ee4..4616e03 100644
--- a/src/templates/Components/PlaceHolder.tsx
+++ b/src/templates/Components/PlaceHolder.tsx
@@ -1,17 +1,29 @@
-export default function Placeholder(props: {
-  isMobileHidden?: boolean;
-}) {
+export default function Placeholder(props: { isMobileHidden?: boolean }) {
   return (
-    <a
-      data-type="placeholder__image"
+    <div
+      data-type="placeholder__image__container"
       className={`relative aspect-square min-w-48 min-h-48 sm:min-w-auto rounded-sm overflow-hidden ${
         props.isMobileHidden ? "hidden xl:block" : ""
       }`}
     >
-      <div
-        data-type="placeholder__image__loader"
-        className="w-full h-full absolute inset-0 bg-gray-400 opacity-30 animate-pulse z-[3]"
-      ></div>
-    </a>
+      <a data-type="placeholder__image">
+        <div
+          data-type="placeholder__image__loader"
+          className="w-full h-full absolute inset-0 bg-gray-400 opacity-30 animate-pulse z-[3]"
+        ></div>
+      </a>
+      <button
+        data-type="image__fav"
+        className="hidden absolute right-2 top-2 w-8 h-8 cursor-pointer"
+      >
+        <div className="text-[#faebeb] hover:text-orange-500 transition-colors material-symbols--favorite-outline-rounded w-full h-full"></div>
+      </button>
+      <button
+        data-type="image__unfav"
+        className="hidden absolute right-2 top-2 w-8 h-8 cursor-pointer"
+      >
+        <div className="text-[#faebeb] hover:text-orange-500 transition-colors material-symbols--favorite-rounded w-full h-full"></div>
+      </button>
+    </div>
   );
 }
diff --git a/src/templates/favorites.tsx b/src/templates/favorites.tsx
new file mode 100644
index 0000000..4c2e229
--- /dev/null
+++ b/src/templates/favorites.tsx
@@ -0,0 +1,14 @@
+import PageNav from "./Components/PageNavigation";
+
+export default function FavoritesPage() {
+  return (
+    <>
+      <PageNav path="/images"/>
+      <div
+        id="favorites_favorites"
+        className="my-2 overflow-hidden grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] xl:grid-cols-[repeat(auto-fill,minmax(20%,1fr))] sm:items-center sm:justify-center gap-2"
+      ></div>
+      <PageNav path="/images"/>
+    </>
+  );
+}
diff --git a/src/templates/index.tsx b/src/templates/index.tsx
index 28c63e7..8e637d7 100644
--- a/src/templates/index.tsx
+++ b/src/templates/index.tsx
@@ -23,7 +23,7 @@ export default function IndexPage() {
       <p className="text-4xl text-white dark:text-white bg-orange-800 dark:bg-orange-800/50 rounded-sm p-4">Videos</p>
       <div
         id="index_videos"
-        className="mt-2 flex overflow-x-auto sm:overflow-x-hidden sm:grid sm:grid-cols-[repeat(auto-fill,minmax(25%,1fr))] xl:grid-cols-[repeat(auto-fill,minmax(20%,1fr))] sm:items-center sm:justify-center gap-2"
+        className="mt-2 mb-4 flex overflow-x-auto sm:overflow-x-hidden sm:grid sm:grid-cols-[repeat(auto-fill,minmax(25%,1fr))] xl:grid-cols-[repeat(auto-fill,minmax(20%,1fr))] sm:items-center sm:justify-center gap-2"
       >
         {[...Array(3).keys()].map((idx) => {
           return (
@@ -35,6 +35,13 @@ export default function IndexPage() {
         })}
         <AllLink location="/videos/" text="View All Videos" />
       </div>
+      <p className="text-4xl text-white dark:text-white bg-orange-800 dark:bg-orange-800/50 rounded-sm p-4">Favorites</p>
+      <div
+        id="index_favorites"
+        className="mt-2 flex overflow-x-auto sm:overflow-x-hidden sm:grid sm:grid-cols-[repeat(auto-fill,minmax(25%,1fr))] xl:grid-cols-[repeat(auto-fill,minmax(20%,1fr))] sm:items-center sm:justify-center gap-2"
+      >
+        <p className="text-2xl mx-auto col-span-full">You have not added any Favorites</p>
+      </div>
     </div>
   );
 }