diff --git a/src/App.js b/src/App.js
index 8cb62f6..522721f 100644
--- a/src/App.js
+++ b/src/App.js
@@ -3,6 +3,7 @@ import {Route, Routes} from "react-router-dom";
import {Home} from "./pages/Home";
import {Games} from "./pages/Games";
import {Activities} from "./pages/Activities";
+import Favorites from "./pages/Favorites";
import {activities, games} from "./data/content";
import {Navbar} from './components/common/Navbar';
import "slick-carousel/slick/slick.css";
@@ -24,6 +25,7 @@ function App() {
})
}
} />
+ } />
{
activities.map(activity => {
return (
diff --git a/src/assets/icons/favorite-outline.svg b/src/assets/icons/favorite-outline.svg
new file mode 100644
index 0000000..af3f93a
--- /dev/null
+++ b/src/assets/icons/favorite-outline.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/star-outline.svg b/src/assets/icons/star-outline.svg
new file mode 100644
index 0000000..18b0528
--- /dev/null
+++ b/src/assets/icons/star-outline.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/components/common/FavoriteButton.css b/src/components/common/FavoriteButton.css
new file mode 100644
index 0000000..80dc98b
--- /dev/null
+++ b/src/components/common/FavoriteButton.css
@@ -0,0 +1,28 @@
+.fav-button {
+ /* Match the Generate button visual style from RandomQuote.css */
+ font-size: 1.2rem;
+ background: white;
+ border-radius: 5px;
+ padding: 18px 20px;
+ border: 1px solid #4b4b4b;
+ box-shadow: 1px 1px 3px #4b4b4b;
+ margin: 10px 0;
+ transition-duration: 300ms;
+ cursor: pointer;
+}
+.fav-button:hover {
+ transform: scale(1.05);
+ background: black;
+ color: white;
+}
+.fav-button.saved {
+ background: #ffeaa7;
+ border-color: #ffcc00;
+}
+
+.fav-button.small {
+ /* helper class if a smaller variant is needed */
+ padding: 6px 10px;
+ font-size: 1rem;
+ box-shadow: none;
+}
diff --git a/src/components/common/FavoriteButton.js b/src/components/common/FavoriteButton.js
new file mode 100644
index 0000000..b0de84c
--- /dev/null
+++ b/src/components/common/FavoriteButton.js
@@ -0,0 +1,34 @@
+import React, {useEffect, useState} from 'react';
+import './FavoriteButton.css';
+import { listFavorites, addFavorite, removeFavorite } from '../../utils/favoritesStorage';
+
+export default function FavoriteButton({ item }) {
+ const [saved, setSaved] = useState(false);
+
+ useEffect(() => {
+ const all = listFavorites();
+ const exists = all.some((it) => JSON.stringify(it.content) === JSON.stringify(item.content));
+ setSaved(!!exists);
+ }, [item]);
+
+ const handleToggle = () => {
+ if (saved) {
+ // remove
+ const all = listFavorites();
+ const found = all.find((it) => JSON.stringify(it.content) === JSON.stringify(item.content));
+ if (found) {
+ removeFavorite(found.id);
+ setSaved(false);
+ }
+ } else {
+ const added = addFavorite(item);
+ if (added) setSaved(true);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/common/FavoriteButton.module.css b/src/components/common/FavoriteButton.module.css
new file mode 100644
index 0000000..342830e
--- /dev/null
+++ b/src/components/common/FavoriteButton.module.css
@@ -0,0 +1,11 @@
+.fav-button {
+ background: transparent;
+ border: 1px solid #ccc;
+ padding: 6px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+.fav-button.saved {
+ background: #ffeaa7;
+ border-color: #ffcc00;
+}
diff --git a/src/components/common/Navbar.js b/src/components/common/Navbar.js
index 26f619a..4b778b6 100644
--- a/src/components/common/Navbar.js
+++ b/src/components/common/Navbar.js
@@ -5,6 +5,7 @@ import logo from '../../logo.png';
import home_icon from '../../assets/icons/home-outline.svg';
import game_icon from '../../assets/icons/game-controller-outline.svg';
import pulse_icon from '../../assets/icons/pulse-outline.svg';
+import star_icon from '../../assets/icons/star-outline.svg';
const navbarOptions = [
{
@@ -22,39 +23,46 @@ const navbarOptions = [
title: "Activities",
url: "/activities"
}
+ ,
+ {
+ icon: star_icon,
+ title: "Favorites",
+ url: "/favorites"
+ }
]
export const Navbar = () => {
let location = useLocation();
useEffect(() => {
- const list = document.querySelectorAll('.list');
-
- if (location.pathname === "/") {
- list[0].classList.add("active");
- list[1].classList.remove("active");
- list[2].classList.remove("active");
- }
-
- if (location.pathname.includes("/games")) {
- list[0].classList.remove("active");
- list[1].classList.add("active");
- list[2].classList.remove("active");
- }
+ const listItems = document.querySelectorAll('.list');
+ // clear all
+ listItems.forEach(li => li.classList.remove('active'));
- if (location.pathname.includes("/activities")) {
- list[0].classList.remove("active");
- list[1].classList.remove("active");
- list[2].classList.add("active");
+ // find the link whose href best matches the current path
+ const anchors = document.querySelectorAll('.list a');
+ let matched = null;
+ anchors.forEach(a => {
+ const href = a.getAttribute('href');
+ if (!href) return;
+ // exact match or startsWith
+ if (location.pathname === href || location.pathname.startsWith(href)) {
+ matched = a;
+ }
+ });
+ if (matched && matched.parentElement) {
+ matched.parentElement.classList.add('active');
+ } else if (location.pathname === '/') {
+ // default to first
+ if (listItems[0]) listItems[0].classList.add('active');
}
function handleClick() {
- list.forEach((item) => item.classList.remove("active"));
- this.classList.add("active")
+ listItems.forEach((item) => item.classList.remove('active'));
+ this.classList.add('active')
}
- list.forEach((item) =>
- item.addEventListener("click", handleClick));
+ listItems.forEach((item) => item.addEventListener('click', handleClick));
- console.log('location', location)
+ // console.log('location', location)
}, [location])
return (
diff --git a/src/data/content.js b/src/data/content.js
index 2660737..1a4557e 100644
--- a/src/data/content.js
+++ b/src/data/content.js
@@ -1,4 +1,5 @@
-import { RandomQuote } from "../pages/activities/RandomQuote";
+// RandomQuote wrapper is used instead of original to provide favorite/save UI
+import { RandomQuoteWithFav } from "../pages/activities_wrappers/RandomQuoteWithFav";
import { MagicSquares } from "../pages/games/MagicSquares";
import { TicTacToe } from "../pages/games/TicTacToe";
import { Wordle } from "../pages/games/Wordle";
@@ -7,8 +8,9 @@ import { FortuneCard } from "../pages/activities/FotuneCard";
import { SearchWord } from "../pages/activities/getDefinition";
import { Jitter } from "../pages/games/Jitter";
import { RandomMeme } from "../pages/activities/RandomMemes";
-import { RandomJoke } from "../pages/activities/RandomJoke";
-import { RandomAnimeQuote } from "../pages/activities/RandomAnimeQuote";
+// Use wrapper components that include favorite button functionality
+import { RandomJokeWithFav } from "../pages/activities_wrappers/RandomJokeWithFav";
+import { RandomAnimeQuoteWithFav } from "../pages/activities_wrappers/RandomAnimeQuoteWithFav";
import { SimonSays } from "../pages/games/SimonSays";
import { ReactionTime } from "../pages/games/ReactionTime";
import MemeCaptionMaker from "../pages/games/MemeCaptionMaker";
@@ -28,14 +30,14 @@ export const activities = [
description: "Get random quotes",
icon: "https://cdn-icons-png.flaticon.com/512/2541/2541991.png",
urlTerm: "random-quotes",
- element: ,
+ element: ,
},
{
title: "Random Anime Quotes",
description: "Get random anime quotes",
icon: "https://64.media.tumblr.com/7b526ba246f48e294ebc87fe2cbd8e1b/1a4bdb8275a18adc-c7/s250x400/94d6c7e70601111ba79b8801cd939694d0000018.jpg",
urlTerm: "random-anime-quotes",
- element: ,
+ element: ,
},
{
title: "Random memes",
@@ -63,7 +65,7 @@ export const activities = [
description: "Get random jokes",
icon: "https://www.troublefreepool.com/media/joke-png.127455/full",
urlTerm: "random-jokes",
- element: ,
+ element: ,
},
{
title: "Calculator",
diff --git a/src/pages/Favorites.js b/src/pages/Favorites.js
new file mode 100644
index 0000000..cdcaa97
--- /dev/null
+++ b/src/pages/Favorites.js
@@ -0,0 +1,184 @@
+import React, { useEffect, useState } from 'react';
+import { listFavorites, removeFavorite, clearAll, timeRemainingMs } from '../utils/favoritesStorage';
+import "../styles/pages/activities/RandomQuote.css";
+import "../styles/pages/activities/RandomJoke.css";
+import "../styles/pages/Activities.css";
+
+function formatRemaining(ms) {
+ if (ms <= 0) return 'Expired';
+ const h = Math.floor(ms / (1000 * 60 * 60));
+ const m = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
+ return `${h}h ${m}m`;
+}
+
+export const Favorites = () => {
+ const [items, setItems] = useState([]);
+ const [remaining, setRemaining] = useState(timeRemainingMs());
+ const [expandedMap, setExpandedMap] = useState({});
+ const quoteRefs = React.useRef({});
+ const [longMap, setLongMap] = useState({});
+
+ const toggleExpanded = (id) => {
+ setExpandedMap((prev) => ({ ...prev, [id]: !prev[id] }));
+ };
+
+ useEffect(() => {
+ setItems(listFavorites());
+ const t = setInterval(() => setRemaining(timeRemainingMs()), 1000 * 60);
+ return () => clearInterval(t);
+ }, []);
+
+ // After items render, detect which quote/joke blocks take more than 2 lines
+ useEffect(() => {
+ const measure = () => {
+ if (!items || items.length === 0) {
+ setLongMap({});
+ return;
+ }
+ const map = {};
+ items.forEach((it) => {
+ const el = quoteRefs.current[it.id];
+ if (!el) return;
+ // Ensure we measure the full content height (remove any clamps temporarily)
+ const previousMax = el.style.maxHeight;
+ const previousDisplay = el.style.display;
+ el.style.maxHeight = 'none';
+ el.style.display = 'block';
+ const cs = window.getComputedStyle(el);
+ // parse line-height; fallback to 1.2 * font-size
+ let lineHeight = parseFloat(cs.lineHeight);
+ if (!lineHeight || Number.isNaN(lineHeight)) {
+ const fs = parseFloat(cs.fontSize) || 16;
+ lineHeight = fs * 1.2;
+ }
+ const lines = Math.round(el.scrollHeight / lineHeight);
+ map[it.id] = lines > 2;
+ // restore
+ el.style.maxHeight = previousMax;
+ el.style.display = previousDisplay;
+ });
+ setLongMap(map);
+ };
+
+ measure();
+ window.addEventListener('resize', measure);
+ return () => window.removeEventListener('resize', measure);
+ }, [items]);
+
+ const handleRemove = (id) => {
+ removeFavorite(id);
+ setItems(listFavorites());
+ };
+
+ const handleClearAll = () => {
+ clearAll();
+ setItems([]);
+ setRemaining(timeRemainingMs());
+ };
+
+ const renderCard = (it) => {
+ const content = it.content || {};
+ if (it.type && it.type.includes('quote')) {
+ const text = content.text || content.quote || '';
+ const author = content.author || content.character || '';
+ const extra = content.anime ? ` (${content.anime})` : '';
+ const isLong = !!longMap[it.id];
+ const isExpanded = !!expandedMap[it.id];
+ return (
+
+
+
(quoteRefs.current[it.id] = el)}>
+ {text}
+
+
+
— {author}{extra}
+ {isLong && (
+
+
+
+ )}
+
+ );
+ }
+
+ if (it.type && it.type.includes('joke')) {
+ const txt = content.text || (content.setup ? `${content.setup}\n\n${content.delivery}` : '');
+ const isLong = !!longMap[it.id];
+ const isExpanded = !!expandedMap[it.id];
+ return (
+
+
+
(quoteRefs.current[it.id] = el)}>
+ {txt}
+
+
+ {isLong && (
+
+
+
+ )}
+
+ );
+ }
+
+ return (
+
+
{JSON.stringify(content, null, 2)}
+
+ );
+ };
+
+ return (
+
+
Favorites
+
+ Saved items will be cleared automatically after 24 hours from the time you first saved an item.
+ {remaining > 0 && (
+
Time until auto-clear: {formatRemaining(remaining)}
+ )}
+ {remaining === 0 && (
+
Storage expired and has been cleared.
+ )}
+
+
+
+ {items.length === 0 &&
No favorites yet — save quotes/jokes from activities.
}
+
+ {items.map((it) => (
+
+
+
+ {renderCard(it)}
+
+
+
{new Date(it.createdAt).toLocaleString()}
+
+
+ ))}
+
+
+ {items.length > 0 && (
+
+
+
+ )}
+
+ );
+};
+
+export default Favorites;
diff --git a/src/pages/activities_wrappers/RandomAnimeQuoteWithFav.js b/src/pages/activities_wrappers/RandomAnimeQuoteWithFav.js
new file mode 100644
index 0000000..931246b
--- /dev/null
+++ b/src/pages/activities_wrappers/RandomAnimeQuoteWithFav.js
@@ -0,0 +1,70 @@
+import { useEffect, useState } from "react";
+import "../../styles/pages/activities/RandomQuote.css";
+import FavoriteButton from "../../components/common/FavoriteButton";
+
+export const RandomAnimeQuoteWithFav = () => {
+ const [quote, setQuote] = useState(null);
+ const [error, setError] = useState(null);
+
+ const generateQuote = async () => {
+ try {
+ setQuote(null);
+ setError(null);
+
+ const response = await fetch("https://animotto-api.onrender.com/api/quotes/random");
+ if (!response.ok) throw new Error("Network response was not ok");
+
+ const data = await response.json();
+
+ setQuote({
+ quote: data.quote,
+ character: data.character,
+ anime: data.anime.name || data.anime.altName || "Unknown",
+ });
+ } catch (err) {
+ console.error(err);
+ setError(err);
+ }
+ };
+
+ useEffect(() => {
+ generateQuote();
+ }, []);
+
+ return (
+
+
Random Anime Quote Generator
+
Generate any random anime quote to get some inspiration!
+
+ {quote && (
+
+
{quote.quote}
+
- {quote.character} ({quote.anime})
+
+ )}
+
+ {error &&
Error fetching quote. Please try again later.
}
+
+ {!quote && !error && (
+
+ )}
+
+
+
+ {quote && (
+
+ )}
+
+
+ );
+};
diff --git a/src/pages/activities_wrappers/RandomJokeWithFav.js b/src/pages/activities_wrappers/RandomJokeWithFav.js
new file mode 100644
index 0000000..828b3e5
--- /dev/null
+++ b/src/pages/activities_wrappers/RandomJokeWithFav.js
@@ -0,0 +1,63 @@
+import { useEffect, useState } from "react";
+import axios from "axios";
+import "../../styles/pages/activities/RandomJoke.css";
+import FavoriteButton from "../../components/common/FavoriteButton";
+
+export const RandomJokeWithFav = () => {
+ const [joke, setJoke] = useState(null);
+ const [error, setError] = useState(null);
+
+ const generateJoke = () => {
+ setJoke(null);
+ axios({
+ method: "GET",
+ url: "https://v2.jokeapi.dev/joke/Any",
+ })
+ .then((res) => setJoke(res.data))
+ .catch((error) => setError(error));
+ };
+
+ useEffect(() => {
+ generateJoke();
+ }, []);
+
+ return (
+
+
Random Joke Generator
+
Generate any random joke to get some laugh!
+ {joke && (
+
+ {joke.type === "single" ? (
+
{joke.joke}
+ ) : (
+
+
{joke.setup}
+
{joke.delivery}
+
+ )}
+
+ )}
+ {error &&
{error.message}
}
+ {!joke && !error && (
+
+ )}
+
+
+
+ {joke && (
+
+ )}
+
+
+ );
+};
diff --git a/src/pages/activities_wrappers/RandomQuoteWithFav.js b/src/pages/activities_wrappers/RandomQuoteWithFav.js
new file mode 100644
index 0000000..749fd41
--- /dev/null
+++ b/src/pages/activities_wrappers/RandomQuoteWithFav.js
@@ -0,0 +1,70 @@
+import { useEffect, useState } from "react";
+import axios from "axios";
+import "../../styles/pages/activities/RandomQuote.css";
+import FavoriteButton from "../../components/common/FavoriteButton";
+
+export const RandomQuoteWithFav = () => {
+ const [quote, setQuote] = useState(null);
+ const [error, setError] = useState(null);
+
+ const generateQuote = () => {
+ setQuote(null);
+ setError(null);
+
+ const url = "https://stoic-quotes.com/api/quote";
+ const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(
+ url + `?t=${Date.now()}`
+ )}`;
+
+ axios
+ .get(proxyUrl)
+ .then((res) => {
+ const data = JSON.parse(res.data.contents);
+ setQuote({ q: data.text, a: data.author });
+ })
+ .catch((err) => {
+ setError(err);
+ });
+ };
+
+ useEffect(() => {
+ generateQuote();
+ }, []);
+
+ return (
+
+
Random Quote Generator
+
Get a random quote to inspire your day 💡
+
+ {quote && (
+
+
“{quote.q}”
+
— {quote.a || "Unknown"}
+
+ )}
+
+ {error &&
{error.message}
}
+
+ {!quote && !error && (
+
+ )}
+
+
+
+ {quote && (
+
+ )}
+
+
+ );
+};
diff --git a/src/styles/components/common/Navbar.css b/src/styles/components/common/Navbar.css
index 0858b24..6c24afa 100644
--- a/src/styles/components/common/Navbar.css
+++ b/src/styles/components/common/Navbar.css
@@ -165,3 +165,15 @@
.navbar-root ul li:nth-child(3).active ~ .indicator {
transform: translateX(calc(70px * 2));
}
+/* support 4th item (Favorites) */
+.navbar-root ul li:nth-child(4).active ~ .indicator {
+ transform: translateX(calc(70px * 3));
+}
+
+/* ensure all navbar icons use the same visual size and are centered */
+.navbar-root ul li .navbar-item .icon img {
+ width: 48px;
+ height: 48px;
+ object-fit: contain;
+ display: inline-block;
+}
diff --git a/src/styles/pages/activities/RandomJoke.css b/src/styles/pages/activities/RandomJoke.css
index eeb59f3..992a5b3 100644
--- a/src/styles/pages/activities/RandomJoke.css
+++ b/src/styles/pages/activities/RandomJoke.css
@@ -97,6 +97,19 @@
color: white;
transform: scale(1.1);
}
+
+ /* Shared actions container used by wrappers to align Generate + Save */
+ .rquote-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+ align-items: center;
+ margin-top: 30px;
+ }
+ .rquote-actions .button,
+ .rquote-actions .fav-button {
+ margin: 0;
+ }
.spinner-wrapper {
margin: auto;
diff --git a/src/styles/pages/activities/RandomQuote.css b/src/styles/pages/activities/RandomQuote.css
index 242481f..22893f6 100644
--- a/src/styles/pages/activities/RandomQuote.css
+++ b/src/styles/pages/activities/RandomQuote.css
@@ -113,6 +113,19 @@
transform: scale(1.1);
}
+/* Actions container to align generate + save buttons */
+.rquote-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+ align-items: center;
+ margin-top: 30px;
+}
+.rquote-actions .rquote-button,
+.rquote-actions .fav-button {
+ margin: 0;
+}
+
.spinner-wrapper {
margin: auto;
width: 100px;
@@ -143,3 +156,13 @@
transform: rotate(360deg);
}
}
+
+/* Utility: truncate text to ~2 lines with fallback */
+.truncate-2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ /* Fallback for non-webkit browsers: limit max-height to ~2 lines */
+ max-height: calc(2 * 2.2rem);
+}
diff --git a/src/utils/favoritesStorage.js b/src/utils/favoritesStorage.js
new file mode 100644
index 0000000..c69005a
--- /dev/null
+++ b/src/utils/favoritesStorage.js
@@ -0,0 +1,95 @@
+const STORAGE_KEY = "acmfun_favorites_v1";
+const EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+function _readRaw() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ return JSON.parse(raw);
+ } catch (e) {
+ console.error("favoritesStorage: failed to read", e);
+ return null;
+ }
+}
+
+function _writeRaw(obj) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
+ } catch (e) {
+ console.error("favoritesStorage: failed to write", e);
+ }
+}
+
+export function _isExpired(raw) {
+ if (!raw || !raw.createdAt) return false;
+ return Date.now() - raw.createdAt > EXPIRY_MS;
+}
+
+export function listFavorites() {
+ const raw = _readRaw();
+ if (!raw) return [];
+ if (_isExpired(raw)) {
+ clearAll();
+ return [];
+ }
+ return raw.items || [];
+}
+
+export function timeRemainingMs() {
+ const raw = _readRaw();
+ if (!raw || !raw.createdAt) return EXPIRY_MS;
+ const remaining = EXPIRY_MS - (Date.now() - raw.createdAt);
+ return remaining > 0 ? remaining : 0;
+}
+
+export function addFavorite(item) {
+ const raw = _readRaw() || { items: [], createdAt: null };
+ if (!raw.createdAt) raw.createdAt = Date.now();
+ // avoid duplicates by content
+ const exists = (raw.items || []).some((it) => JSON.stringify(it.content) === JSON.stringify(item.content));
+ if (exists) return false;
+ const entry = {
+ id: Date.now() + Math.floor(Math.random() * 1000),
+ type: item.type || "generic",
+ content: item.content,
+ meta: item.meta || null,
+ createdAt: Date.now(),
+ };
+ raw.items = raw.items || [];
+ raw.items.push(entry);
+ _writeRaw(raw);
+ return entry;
+}
+
+export function removeFavorite(id) {
+ const raw = _readRaw();
+ if (!raw || !raw.items) return false;
+ raw.items = raw.items.filter((it) => it.id !== id);
+ if (raw.items.length === 0) raw.createdAt = null;
+ _writeRaw(raw);
+ return true;
+}
+
+export function clearAll() {
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ return true;
+ } catch (e) {
+ console.error(e);
+ return false;
+ }
+}
+
+export function isExpired() {
+ const raw = _readRaw();
+ return _isExpired(raw);
+}
+
+export default {
+ listFavorites,
+ addFavorite,
+ removeFavorite,
+ clearAll,
+ timeRemainingMs,
+ isExpired,
+};