From bcbefba06dc368af9e89600f24bb8664c1beb292 Mon Sep 17 00:00:00 2001 From: Helene Lotsberg Date: Tue, 20 Oct 2026 08:39:31 +0200 Subject: [PATCH 1/7] Add explorePlants query with search, filter, sort and pagination --- backend/src/server.ts | 131 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 7 deletions(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index 128b26c..9d6d33b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,7 +1,8 @@ import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; -import type { WithId } from "mongodb"; +import type { Document, Filter, WithId } from "mongodb"; import { db } from "./db.js"; + // The shape of a plant document in MongoDB (only the fields we read) interface PlantDoc { commonName: string; @@ -32,26 +33,47 @@ interface GetPlantsArgs { offset: number; } +interface ExplorePlantsArgs { + search?: string; + sunlight?: string; + watering?: string; + careLevel?: string; + sort?: string; + limit?: number; + offset?: number; +} + const plants = db.collection("plants"); const typeDefs = ` type Query{ getPlants(limit: Int = 50, offset: Int = 0): [Plant] + explorePlants( + search: String + sunlight: String + watering: String + careLevel: String + sort: String + limit: Int = 12 + offset: Int = 0 + ): PlantPage! } + type PlantPage { + plants: [Plant!]! + totalCount: Int! + } type Plant{ - id:ID! + id: ID! name: String! - imageURL:String + imageURL: String scientificName: [String] description: String watering: String sunlight: [String] - careLevel: String} - - - + careLevel: String + } `; const toPlant = (doc: WithId): Plant => ({ @@ -65,6 +87,62 @@ const toPlant = (doc: WithId): Plant => ({ careLevel: doc.careLevel, }); +// Escape user input so it can't be interpreted as regex +const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// Filter values come in as "full_sun", the database has "full sun" +const normalize = (s: string) => escapeRegex(s.replace(/_/g, " ").trim()); + +// Order from low to high. +const SORT_ORDERS = { + watering: ["minimum", "average", "frequent"], + careLevel: ["low", "medium", "high"], +}; + +// Position in the order list, unknown or missing values go last +const rankOf = (order: string[], value: unknown): Document => ({ + $let: { + vars: { i: { $indexOfArray: [order, value] } }, + in: { $cond: [{ $lt: ["$$i", 0] }, 999, "$$i"] }, + }, +}); + +// Builds the aggregation stages that handle sorting +const buildSortStages = (sort?: string): Document[] => { + switch (sort) { + case "name_desc": + return [{ $sort: { commonName: -1, _id: 1 } }]; + case "watering_asc": + return [ + { + $addFields: { + sortRank: rankOf(SORT_ORDERS.watering, { $toLower: "$watering" }), + }, + }, + { $sort: { sortRank: 1, commonName: 1, _id: 1 } }, + ]; + case "careLevel_asc": + // "Moderate" and "Medium" both exist in the data, treat them as equal + return [ + { + $addFields: { + sortRank: rankOf(SORT_ORDERS.careLevel, { + $let: { + vars: { c: { $toLower: { $ifNull: ["$careLevel", ""] } } }, + in: { + $cond: [{ $eq: ["$$c", "moderate"] }, "medium", "$$c"], + }, + }, + }), + }, + }, + { $sort: { sortRank: 1, commonName: 1, _id: 1 } }, + ]; + default: + return [{ $sort: { commonName: 1, _id: 1 } }]; + } +}; + const resolvers = { Query: { getPlants: async ( @@ -94,6 +172,45 @@ const resolvers = { .toArray(); return docs.map(toPlant); }, + + explorePlants: async ( + _parent: unknown, + args: ExplorePlantsArgs, + ): Promise<{ plants: Plant[]; totalCount: number }> => { + const filter: Filter = {}; + + if (args.search?.trim()) { + filter.commonName = { + $regex: escapeRegex(args.search.trim()), + $options: "i", + }; + } + if (args.watering) { + filter.watering = new RegExp(`^${normalize(args.watering)}$`, "i"); + } + if (args.careLevel) { + const level = normalize(args.careLevel); + // "Medium" and "Moderate" both exist in the data and mean the same + filter.careLevel = new RegExp( + level === "medium" ? "^(medium|moderate)$" : `^${level}$`, + "i", + ); + } + + const [docs, totalCount] = await Promise.all([ + plants + .aggregate>([ + { $match: filter }, + ...buildSortStages(args.sort), + { $skip: args.offset }, + { $limit: args.limit }, + ]) + .toArray(), + plants.countDocuments(filter), + ]); + + return { plants: docs.map(toPlant), totalCount }; + }, }, }; From 1e8e2f9eecf960d3b9352a9ac3c34253e261a013 Mon Sep 17 00:00:00 2001 From: Helene Lotsberg Date: Tue, 20 Oct 2026 09:53:07 +0200 Subject: [PATCH 2/7] Connect ExplorePlants to backend with PlantCard and pagination --- .../src/pages/explorePlants/ExplorePlants.css | 70 ++--- .../src/pages/explorePlants/ExplorePlants.tsx | 253 +++++++++++------- 2 files changed, 186 insertions(+), 137 deletions(-) diff --git a/frontend/src/pages/explorePlants/ExplorePlants.css b/frontend/src/pages/explorePlants/ExplorePlants.css index 8a74a7b..01f25ec 100644 --- a/frontend/src/pages/explorePlants/ExplorePlants.css +++ b/frontend/src/pages/explorePlants/ExplorePlants.css @@ -98,40 +98,48 @@ margin: 0; padding: 0; display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 20px; + grid-template-columns: repeat(auto-fill, minmax(390px, 1fr)); + gap: 30px 20px; } -.explore-plant-card-placeholder { - aspect-ratio: 3 / 4; - border-radius: 12px; - overflow: hidden; - box-sizing: border-box; - border: 3px solid var(--border-soft); +.explore-plant-item { + width: 390px; + height: 620px; + justify-self: center; } -.explore-plant-card-placeholder:focus-within { - border-color: var(--dark-green); +.pagination { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 1rem; + margin: 1.5rem 0; } -.plant-card-button { - width: 100%; - height: 100%; - padding: 1rem; - text-align: center; - background: none; - border: none; - outline: none; +.pagination button { + padding: 0.5rem 1rem; + border: 1px solid var(--border-soft); + border-radius: 8px; + background-color: var(--light-green); + color: var(--text-dark); font: inherit; - color: inherit; cursor: pointer; } -.plant-card-info { - display: flex; - flex-direction: column; - gap: 0.75rem; - text-align: left; +.pagination button:hover:not(:disabled) { + background-color: var(--dark-green); + color: var(--text-light); +} + +.pagination button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dark-green); +} + +.pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; } @media (max-width: 767px) { @@ -143,16 +151,12 @@ } .plant-grid { - grid-template-columns: repeat(2, 1fr); - gap: 15px; - } - - .plant-card-button { - padding: 0.5rem; + grid-template-columns: 1fr; + gap: 20px; } - .plant-card-info { - font-size: 13px; - gap: 0.4rem; + .explore-plant-item { + width: min(280px, 62vw); + height: 502px; } } diff --git a/frontend/src/pages/explorePlants/ExplorePlants.tsx b/frontend/src/pages/explorePlants/ExplorePlants.tsx index abd3706..b3f8502 100644 --- a/frontend/src/pages/explorePlants/ExplorePlants.tsx +++ b/frontend/src/pages/explorePlants/ExplorePlants.tsx @@ -1,56 +1,95 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { gql } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; import { useDebounce } from "../../hooks/useDebounce"; import { useSessionState } from "../../hooks/useSessionState"; +import PlantCard from "../../components/plantCard/PlantCard.tsx"; import "./ExplorePlants.css"; -// prettier-ignore -const plants = [ - { id: 1, name: "Hydrangea", watering: "average", sunlight: "part_shade", indoor: false, edible: false }, - { id: 2, name: "Orchid", watering: "minimum", sunlight: "part_sun", indoor: true, edible: false }, - { id: 3, name: "Fig Tree", watering: "average", sunlight: "full_sun", indoor: true, edible: true }, - { id: 4, name: "Monstera", watering: "average", sunlight: "part_shade", indoor: true, edible: false }, - { id: 5, name: "Peony", watering: "frequent", sunlight: "full_sun", indoor: false, edible: false }, - { id: 6, name: "Lily", watering: "average", sunlight: "part_sun", indoor: true, edible: false }, - { id: 7, name: "Rose", watering: "frequent", sunlight: "full_sun", indoor: false, edible: false }, - { id: 8, name: "Tulip", watering: "minimum", sunlight: "full_sun", indoor: false, edible: false }, - { id: 9, name: "Daisy", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, - { id: 10, name: "Sunflower", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, - { id: 11, name: "Lavender", watering: "minimum", sunlight: "full_sun", indoor: true, edible: true }, - { id: 12, name: "Jasmine", watering: "average", sunlight: "part_sun", indoor: true, edible: false }, - { id: 13, name: "Marigold", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, - { id: 14, name: "Daffodil", watering: "average", sunlight: "part_sun", indoor: false, edible: false }, - { id: 15, name: "Carnation", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, -]; +const PAGE_SIZE = 12; + +const EXPLORE_PLANTS = gql` + query ExplorePlants( + $search: String + $sunlight: String + $watering: String + $careLevel: String + $sort: String + $limit: Int + $offset: Int + ) { + explorePlants( + search: $search + sunlight: $sunlight + watering: $watering + careLevel: $careLevel + sort: $sort + limit: $limit + offset: $offset + ) { + totalCount + plants { + id + name + imageURL + scientificName + description + watering + sunlight + careLevel + } + } + } +`; + +type ExplorePlantsData = { + explorePlants: { + totalCount: number; + plants: { + id: string; + name: string | null; + imageURL: string | null; + scientificName: string[] | null; + description: string | null; + watering: string | null; + sunlight: string[] | null; + careLevel: string | null; + }[]; + }; +}; export default function ExplorePlants() { const { t } = useTranslation(); const [search, setSearch] = useSessionState("ExplorePlants.search"); const debouncedSearch = useDebounce(search, 800); - const [openId, setOpenId] = useState(null); const [watering, setWatering] = useSessionState("ExplorePlants.watering"); const [sunlight, setSunlight] = useSessionState("ExplorePlants.sunlight"); - const [location, setLocation] = useSessionState("ExplorePlants.location"); - const [edibility, setEdibility] = useSessionState("ExplorePlants.edibility"); - - const filteredPlants = plants.filter( - (plant) => - plant.name.toLowerCase().includes(debouncedSearch.toLowerCase()) && - (watering === "" || plant.watering === watering) && - (sunlight === "" || plant.sunlight === sunlight) && - (location === "" || - (location === "indoor" && plant.indoor) || - (location === "outdoor" && !plant.indoor)) && - (edibility === "" || - (edibility === "edible" && plant.edible) || - (edibility === "poison" && !plant.edible)), - ); + const [careLevel, setCareLevel] = useSessionState("ExplorePlants.careLevel"); + const [sort, setSort] = useSessionState("ExplorePlants.sort"); + const [page, setPage] = useState(1); + + const { data, loading, error } = useQuery(EXPLORE_PLANTS, { + variables: { + search: debouncedSearch || null, + watering: watering || null, + sunlight: sunlight || null, + careLevel: careLevel || null, + sort: sort || "name_asc", + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }, + }); + + const plants = data?.explorePlants.plants ?? []; + const totalCount = data?.explorePlants.totalCount ?? 0; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); return (

- {t("explore.title", "Explore plants")} + {t("explore.title")}

setSearch(e.target.value)} + onChange={(e) => { + setSearch(e.target.value) + setPage(1); + }} />
- {t("ExplorePlants.filters", "Filters")} + {t("ExplorePlants.filters")}
@@ -134,65 +187,57 @@ export default function ExplorePlants() { setSearch(""); setWatering(""); setSunlight(""); - setLocation(""); - setEdibility(""); + setCareLevel(""); + setSort(""); + setPage(1); }} > {t("ExplorePlants.reset")} - {filteredPlants.length === 0 &&

{t("ExplorePlants.noResults")}

} + {loading &&

{t("ExplorePlants.loading")}

} + {error &&

{t("ExplorePlants.error")}

} + {!loading && !error && plants.length === 0 && ( +

{t("ExplorePlants.noResults")}

+ )}
    - {filteredPlants.map((plant) => { - const isOpen = openId === plant.id; - return ( -
  • - -
  • - ); - })} + {plants.map((plant) => ( +
  • + +
  • + ))}
+ + {totalCount > 0 && ( + + )}
); } From 056d6229bd95f38e349030a7083e2ddd45180584 Mon Sep 17 00:00:00 2001 From: Helene Lotsberg Date: Tue, 20 Oct 2026 10:40:47 +0200 Subject: [PATCH 3/7] update translation to Exploreplants page --- frontend/src/i18n/locales/en/translation.json | 26 +++++++++++++------ frontend/src/i18n/locales/no/translation.json | 26 +++++++++++++------ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index b2e9aa0..298efa9 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -40,7 +40,18 @@ "hardCare": "high care" }, "ExplorePlants": { + "title": "Explore Plants", "search": "Search for plants", + "loading": "Loading plants...", + "error": "Could not load plants. Please try again later.", + "filters": "Filters", + "sort": "Sort", + "name_asc": "Name (A-Z)", + "name_desc": "Name (Z-A)", + "watering_asc": "Watering (low-high)", + "careLevel_asc": "Care (easy-hard)", + "reset": "Reset", + "noResults": "No results found", "watering": "Watering needs", "all": "All", "minimum": "Minimum", @@ -50,14 +61,13 @@ "full_sun": "Full sun", "part_sun": "Part sun", "part_shade": "Part shade", - "location": "Location", - "indoor": "Indoor", - "outdoor": "Outdoor", - "edibility": "Edibility", - "edible": "Edible", - "poison": "Poisonous", - "reset": "Reset", - "noResults": "No results found" + "careLevel": "Care level", + "medium": "Medium", + "high": "High", + "pagination": "Page navigation", + "pageOf": "Page {{page}} of {{totalPages}}", + "previous": "Previous", + "next": "Next" }, "myMatchesPage": { "guide": "These are your matches:" diff --git a/frontend/src/i18n/locales/no/translation.json b/frontend/src/i18n/locales/no/translation.json index f93bd34..3e5ab94 100644 --- a/frontend/src/i18n/locales/no/translation.json +++ b/frontend/src/i18n/locales/no/translation.json @@ -40,7 +40,18 @@ "hardCare": "krever mye stell" }, "ExplorePlants": { + "title": "Utforsk planter", "search": "Søk etter planter", + "loading": "Laster planter...", + "error": "Kunne ikke hente planter. Prøv igjen senere.", + "filters": "Filtre", + "sort": "Sorter", + "name_asc": "Navn (A-Z)", + "name_desc": "Navn (Z-A)", + "watering_asc": "Vanning (lite-mye)", + "careLevel_asc": "Stell (lett-krevende)", + "reset": "Nullstill", + "noResults": "Ingen resultater funnet", "watering": "Vanningsbehov", "all": "Alle", "minimum": "Sjeldent", @@ -50,14 +61,13 @@ "full_sun": "Full sol", "part_sun": "Delvis sol", "part_shade": "Delvis skygge", - "location": "Plassering", - "indoor": "Innendørs", - "outdoor": "Utendørs", - "edibility": "Spiselighet", - "edible": "Spiselig", - "poison": "Giftig", - "reset": "Nullstill", - "noResults": "Ingen resultater funnet" + "careLevel": "Stell", + "medium": "Middels", + "high": "Høy", + "pagination": "Sidenavigasjon", + "pageOf": "Side {{page}} av {{totalPages}}", + "previous": "Forrige", + "next": "Neste" }, "myMatchesPage": { "guide": "Dette er dine matcher:" From 272fc4d0c767c00e615b88b7601f6b7498513592 Mon Sep 17 00:00:00 2001 From: Helene Lotsberg Date: Tue, 20 Oct 2026 11:14:32 +0200 Subject: [PATCH 4/7] update styling for ExplorePlants and install react-icons --- frontend/package.json | 1 + .../src/pages/explorePlants/ExplorePlants.css | 100 +++++++++++++++++- .../src/pages/explorePlants/ExplorePlants.tsx | 83 ++++++++++++--- 3 files changed, 167 insertions(+), 17 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 99f851f..f42a96a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.14", + "react-icons": "^5.7.0", "react-router-dom": "^7.18.4" }, "devDependencies": { diff --git a/frontend/src/pages/explorePlants/ExplorePlants.css b/frontend/src/pages/explorePlants/ExplorePlants.css index 01f25ec..f65f477 100644 --- a/frontend/src/pages/explorePlants/ExplorePlants.css +++ b/frontend/src/pages/explorePlants/ExplorePlants.css @@ -11,6 +11,7 @@ gap: 30px; font-family: Georgia, serif; + --card-scale: 0.65; } .visually-hidden { @@ -93,19 +94,37 @@ box-shadow: 0 0 0 2px var(--dark-green); } +.explore-results { + width: 100%; + box-sizing: border-box; + padding: 40px; + border-radius: 16px; + background-color: var(--light-pink); +} + .plant-grid { list-style: none; margin: 0; padding: 0; display: grid; - grid-template-columns: repeat(auto-fill, minmax(390px, 1fr)); + grid-template-columns: repeat( + auto-fill, + minmax(calc(390px * var(--card-scale)), 1fr) + ); gap: 30px 20px; } .explore-plant-item { + width: calc(390px * var(--card-scale)); + height: calc(620px * var(--card-scale)); + justify-self: center; +} + +.explore-plant-item .plant-card-flipper { width: 390px; height: 620px; - justify-self: center; + transform: scale(var(--card-scale)); + transform-origin: top left; } .pagination { @@ -142,6 +161,72 @@ cursor: not-allowed; } +.sort-menu { + position: relative; + align-self: flex-end; +} + +.sort-button { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background-color: var(--light-green); + color: var(--text-dark); + font-size: 1.4rem; + cursor: pointer; +} + +.sort-button:hover { + background-color: var(--dark-green); + color: var(--text-light); +} + +.sort-button:focus-visible, +.sort-option:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dark-green); +} + +.sort-options { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 10; + min-width: 220px; + margin: 0; + padding: 0.25rem; + list-style: none; + background-color: var(--light-background); + border: 1px solid var(--border-soft); + border-radius: 8px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); +} + +.sort-option { + width: 100%; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 6px; + background: none; + color: var(--text-dark); + font: inherit; + text-align: left; + cursor: pointer; +} + +.sort-option:hover { + background-color: var(--light-green); +} + +.sort-option--active { + background-color: var(--light-green); + font-weight: 700; +} + @media (max-width: 767px) { .explore-page { margin-left: 0; @@ -150,6 +235,11 @@ gap: 20px; } + .explore-results { + padding: 20px 12px; + border-radius: 12px; + } + .plant-grid { grid-template-columns: 1fr; gap: 20px; @@ -159,4 +249,10 @@ width: min(280px, 62vw); height: 502px; } + + .explore-plant-item .plant-card-flipper { + width: 100%; + height: 100%; + transform: none; + } } diff --git a/frontend/src/pages/explorePlants/ExplorePlants.tsx b/frontend/src/pages/explorePlants/ExplorePlants.tsx index b3f8502..b180394 100644 --- a/frontend/src/pages/explorePlants/ExplorePlants.tsx +++ b/frontend/src/pages/explorePlants/ExplorePlants.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { BiSortAlt2 } from "react-icons/bi"; import { useTranslation } from "react-i18next"; import { gql } from "@apollo/client"; import { useQuery } from "@apollo/client/react"; @@ -9,6 +10,13 @@ import "./ExplorePlants.css"; const PAGE_SIZE = 12; +const SORT_OPTIONS = [ + "name_asc", + "name_desc", + "watering_asc", + "careLevel_asc", +]; + const EXPLORE_PLANTS = gql` query ExplorePlants( $search: String @@ -69,6 +77,30 @@ export default function ExplorePlants() { const [careLevel, setCareLevel] = useSessionState("ExplorePlants.careLevel"); const [sort, setSort] = useSessionState("ExplorePlants.sort"); const [page, setPage] = useState(1); + const [sortOpen, setSortOpen] = useState(false); + const sortRef = useRef(null); + const currentSort = sort || "name_asc"; + + // Close the sort menu on outside click or Escape + useEffect(() => { + if (!sortOpen) return; + + const handleClick = (e: MouseEvent) => { + if (sortRef.current && !sortRef.current.contains(e.target as Node)) { + setSortOpen(false); + } + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setSortOpen(false); + }; + + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKey); + }; + }, [sortOpen]); const { data, loading, error } = useQuery(EXPLORE_PLANTS, { variables: { @@ -162,21 +194,40 @@ export default function ExplorePlants() { - +