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, +};