diff --git a/package.json b/package.json index d527454..8d15f9e 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,12 @@ "preview": "vite preview" }, "dependencies": { + "leaflet": "^1.9.4", + "lucide-react": "^0.546.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.27.0", - "leaflet": "^1.9.4", - "react-leaflet": "^4.2.1" + "react-leaflet": "^4.2.1", + "react-router-dom": "^6.27.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", diff --git a/src/pages/Pets.jsx b/src/pages/Pets.jsx index 5057f26..18ecbfe 100644 --- a/src/pages/Pets.jsx +++ b/src/pages/Pets.jsx @@ -1,99 +1,888 @@ -/** - * PETS (DOG & CAT) DASHBOARD TODOs - * -------------------------------- - * Easy: - * - [ ] Loading skeleton for images - * - [ ] Click-to-refresh area (instead of button) - * - [ ] Show breed name overlay on dog image - * - [ ] Basic accessibility: alt text describing breed - * Medium: - * - [ ] Persist favorites (localStorage) - * - [ ] Remove favorite (X button) - * - [ ] Download image / open in new tab control - * - [ ] Add cat breed info (needs different API – consider public cat API) - * Advanced: - * - [ ] Masonry grid for favorites - * - [ ] Drag & reorder favorites, persist order - * - [ ] Tagging / labeling favorites (e.g., funny, cute) - * - [ ] Extract service + hook (useRandomDog, useRandomCat) - */ -import { useEffect, useState } from 'react'; -import Loading from '../components/Loading.jsx'; -import ErrorMessage from '../components/ErrorMessage.jsx'; -import Card from '../components/Card.jsx'; +import { useEffect, useState } from "react"; +import { Heart, X, Download, ExternalLink, RefreshCw } from "lucide-react"; export default function Pets() { const [dog, setDog] = useState(null); const [cat, setCat] = useState(null); + const [catBreed, setCatBreed] = useState(null); const [breeds, setBreeds] = useState([]); - const [breed, setBreed] = useState(''); + const [breed, setBreed] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [imageLoading, setImageLoading] = useState({ dog: false, cat: false }); const [favorites, setFavorites] = useState([]); + const [draggedIndex, setDraggedIndex] = useState(null); + const [catBreeds, setCatBreeds] = useState([]); + const [selectedCatBreed, setSelectedCatBreed] = useState(""); - useEffect(() => { fetchDog(); fetchCat(); fetchBreeds(); }, []); + useEffect(() => { + fetchDog(); + fetchCat(); + fetchBreeds(); + fetchCatBreeds(); + }, []); async function fetchDog(selectedBreed) { try { - setLoading(true); setError(null); - const url = selectedBreed ? `https://dog.ceo/api/breed/${selectedBreed}/images/random` : 'https://dog.ceo/api/breeds/image/random'; + setLoading(true); + setImageLoading((prev) => ({ ...prev, dog: true })); + setError(null); + const url = selectedBreed + ? `https://dog.ceo/api/breed/${selectedBreed}/images/random` + : "https://dog.ceo/api/breeds/image/random"; const res = await fetch(url); - if (!res.ok) throw new Error('Failed to fetch'); + if (!res.ok) throw new Error("Failed to fetch dog"); const json = await res.json(); - setDog(json.message); - } catch (e) { setError(e); } finally { setLoading(false); } + setDog({ url: json.message, breed: selectedBreed || "mixed breed" }); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + setImageLoading((prev) => ({ ...prev, dog: false })); + } } - async function fetchCat() { + async function fetchBreeds() { try { - setLoading(true); setError(null); - // cataas needs an image endpoint; for consistency just store URL - const url = 'https://cataas.com/cat?timestamp=' + Date.now(); - setCat(url); - } catch (e) { setError(e); } finally { setLoading(false); } + const res = await fetch("https://dog.ceo/api/breeds/list/all"); + if (!res.ok) throw new Error("Failed to fetch breeds"); + const json = await res.json(); + setBreeds(Object.keys(json.message)); + } catch (e) { + setError(e.message); + } } - async function fetchBreeds() { + async function fetchCat(selectedBreed) { try { - const res = await fetch('https://dog.ceo/api/breeds/list/all'); - if (!res.ok) throw new Error('Failed to fetch breeds'); + setLoading(true); + setImageLoading((prev) => ({ ...prev, cat: true })); + setError(null); + + // Construct API URL + let url = "https://api.thecatapi.com/v1/images/search"; + if (selectedBreed) url += `?breed_ids=${selectedBreed}`; + + const res = await fetch(url); + if (!res.ok) throw new Error("Failed to fetch cat"); const json = await res.json(); - setBreeds(Object.keys(json.message)); - } catch (e) { /* ignore */ } + + if (json.length > 0) { + const breedName = json[0].breeds?.[0]?.name || "Random"; + setCat({ url: json[0].url, breed: breedName }); + setCatBreed(breedName); + } else { + // fallback if no image returned + setCat({ url: "", breed: "Random" }); + setCatBreed("Random"); + } + } catch (err) { + console.error(err); + setError(err.message); + } finally { + setLoading(false); + setImageLoading((prev) => ({ ...prev, cat: false })); + } + } + + async function fetchCatBreeds() { + try { + const res = await fetch("https://api.thecatapi.com/v1/breeds"); + if (!res.ok) throw new Error("Failed to fetch cat breeds"); + const data = await res.json(); + setCatBreeds(data); // store full breed info including id and name + } catch (err) { + console.error(err); + } } - function favorite(url) { - setFavorites(f => [...new Set([...f, url])]); - // TODO: Persist favorites locally + function addFavorite(item) { + const exists = favorites.find((f) => f.url === item.url); + if (!exists) { + setFavorites((f) => [...f, { ...item, id: Date.now(), tags: [] }]); + } } + function removeFavorite(id) { + setFavorites((f) => f.filter((fav) => fav.id !== id)); + } + async function downloadImage(url, name) { + try { + // Fetch the image as a blob + const res = await fetch(url, { mode: "cors" }); + if (!res.ok) throw new Error("Failed to fetch image"); + const blob = await res.blob(); + + // Create a temporary URL for the blob + const blobUrl = URL.createObjectURL(blob); + + // Create a hidden link to trigger download + const a = document.createElement("a"); + a.href = blobUrl; + a.download = name || "pet-image.jpg"; + a.style.display = "none"; // hide link + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + // Release the object URL + URL.revokeObjectURL(blobUrl); + } catch (err) { + alert("Failed to download image. Try refreshing."); + console.error(err); + } + } + + function openInNewTab(url) { + window.open(url, "_blank"); + } + + function addTag(id, tag) { + setFavorites((f) => + f.map((fav) => + fav.id === id ? { ...fav, tags: [...new Set([...fav.tags, tag])] } : fav + ) + ); + } + + function removeTag(id, tag) { + setFavorites((f) => + f.map((fav) => + fav.id === id + ? { ...fav, tags: fav.tags.filter((t) => t !== tag) } + : fav + ) + ); + } + + function handleDragStart(index) { + setDraggedIndex(index); + } + + function handleDragOver(e, index) { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === index) return; + + const newFavorites = [...favorites]; + const draggedItem = newFavorites[draggedIndex]; + newFavorites.splice(draggedIndex, 1); + newFavorites.splice(index, 0, draggedItem); + + setFavorites(newFavorites); + setDraggedIndex(index); + } + + function handleDragEnd() { + setDraggedIndex(null); + } + + const tagOptions = ["cute", "funny", "sleepy", "playful", "majestic"]; + return ( -
+ Discover adorable dogs and cats, save your favorites! +
+{dog.breed}
+{cat.breed}
++ No favorites yet! Click on any pet image to add it to your + collection. +
+{fav.breed}
+ +