diff --git a/README.md b/README.md index 31466b54c2..f51cbe0e90 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,85 @@ # Final Project +A project to get all knowledge together after a fast paced bootcamp, from zero to Hero, and challenge us to explore more of the world of tech. -Replace this readme with your own information about your project. +## The problem +Problem Approach: +I identified a gap in existing garden management applications - they were either too complex and overwhelming, or lacked essential functionality. My approach was to conduct a competitive analysis of available apps to understand what worked well and what didn't. I focused on creating a minimal viable product that prioritized core features while maintaining simplicity and user-friendliness. +Planning Process: -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +Research Phase: Analyzed existing garden apps to identify pain points and essential features +Feature Prioritization: Defined must-have functionality vs. nice-to-have features +Architecture Design: Planned a full-stack solution with clear separation of concerns +Technology Selection: Chose modern, well-supported technologies that would allow for future scalability -## The problem +Technologies & Tools Used: +Frontend: + +React with Vite for fast development and optimized builds +React Router for navigation management +Zustand for lightweight state management +Styled Components for component-based styling +React Hot Toast for user notifications +React Icons for consistent iconography +Axios for API communication + +Backend: + +Node.js with Express for the REST API +MongoDB with Mongoose for data persistence and modeling +bcrypt for secure password hashing +CORS for cross-origin request handling +dotenv for environment variable management + +Data & Integration: + +Plant API for comprehensive plant information +CSV processing with Papaparse for data import/export +Cheerio for web scraping plant data when needed +node-fetch for external API calls +crypto for generating secure tokens + +Development Tools: + +Nodemon for automatic server restarts during development + +Add seasonal planning tools + +Drag and drop to create your own garden + +Possibility to connect it to an offline calender + +Seasonal Planning Tools: + +Plant Calendar Integration: Implement a dynamic planting calendar that suggests optimal sowing, transplanting, and harvesting dates based on user location and last frost dates -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +Seasonal Task Management: Create automated reminders for seasonal garden tasks (pruning, fertilizing, pest control) with customizable scheduling + +Crop Rotation Planning: Build a multi-year planning system that suggests crop rotation patterns to maintain soil health + +Weather Integration: Connect with weather APIs to provide frost warnings and adjust planting recommendations based on seasonal forecasts + +Drag and Drop Garden Designer: + +Interactive Garden Layout: Implement a drag-and-drop interface using libraries like React DnD or React Beautiful DnD for visual garden bed planning + + +Offline Calendar Integration: + +iCal/Google Calendar Sync: Implement calendar export functionality using iCal format for seamless integration with existing calendar apps + + +Technical Implementation Considerations: + +Use React DnD for drag-and-drop functionality with touch support for mobile devices + +Integrate date-fns or moment.js for robust date calculations and timezone handling + +Consider Canvas API or SVG for more advanced garden visualization tools + +These features would transform the app from a basic plant tracker into a comprehensive garden management and planning platform. ## View it live -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +Frontend: https://lillebrorgrodafinalproject.netlify.app/ + +Backend: https://garden-backend-r6x2.onrender.com diff --git a/backend/data/plants.csv b/backend/data/plants.csv new file mode 100644 index 0000000000..d449cb45e0 --- /dev/null +++ b/backend/data/plants.csv @@ -0,0 +1,91 @@ +Svenskt namn;Vetenskapligt namn;Typ;Blomningstid;Sol/Skugga;Vattenbehov;Rödlistestatus;Såtid;Skördetid;Bild_URL;Bild_licens +Tomat;Solanum lycopersicum;Grönsak;-;Sol;Måttligt;Ej hotad;Feb-Mar (förkult.) / Ut Maj-Jun;Jul-Sep;https://commons.wikimedia.org/wiki/Category:Solanum_lycopersicum;Varierar per fil (se Commons-sidan) +Potatis;Solanum tuberosum;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Maj (sätt);Aug-Sep;https://commons.wikimedia.org/wiki/Category:Solanum_tuberosum;Varierar per fil (se Commons-sidan) +Morot;Daucus carota subsp. sativus;Grönsak;-;Sol;Lågt;Ej hotad;Apr-Jun (direktsådd);Jul-Okt;https://commons.wikimedia.org/wiki/Category:Daucus_carota_subsp._sativus;Varierar per fil (se Commons-sidan) +Gurka (friland);Cucumis sativus;Grönsak;-;Sol;Högt;Ej hotad;Maj (förkult.) / Ut Jun;Jul-Sep;https://commons.wikimedia.org/wiki/Category:Cucumis_sativus;Varierar per fil (se Commons-sidan) +Zucchini;Cucurbita pepo;Grönsak;-;Sol;Högt;Ej hotad;Apr (förkult.) / Ut Jun;Jul-Sep;https://commons.wikimedia.org/wiki/Category:Cucurbita_pepo;Varierar per fil (se Commons-sidan) +Paprika;Capsicum annuum;Grönsak;-;Sol;Måttligt;Ej hotad;Feb-Mar (förkult.) / Ut Jun;Aug-Sep;https://commons.wikimedia.org/wiki/Category:Capsicum_annuum;Varierar per fil (se Commons-sidan) +Chili;Capsicum spp.;Grönsak;-;Sol;Måttligt;Ej hotad;Feb (förkult.) / Ut Jun;Aug-Okt;https://commons.wikimedia.org/wiki/Category:Capsicum_spp.;Varierar per fil (se Commons-sidan) +Sallat (huvud);Lactuca sativa;Grönsak;-;Sol/Måttlig skugga;Högt;Ej hotad;Apr-Jul (omgångssådd);Jun-Sep;https://commons.wikimedia.org/wiki/Category:Lactuca_sativa;Varierar per fil (se Commons-sidan) +Plocksallat;Lactuca sativa var. crispa;Grönsak;-;Sol/Måttlig skugga;Högt;Ej hotad;Apr-Aug (omgångssådd);Jun-Sep;https://commons.wikimedia.org/wiki/Category:Lactuca_sativa_var._crispa;Varierar per fil (se Commons-sidan) +Ruccola;Eruca sativa;Grönsak;-;Sol/Måttlig skugga;Måttligt;Ej hotad;Apr-Aug (omgångssådd);Jun-Sep;https://commons.wikimedia.org/wiki/Category:Eruca_sativa;Varierar per fil (se Commons-sidan) +Spetskål;Brassica oleracea convar. capitata;Grönsak;-;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.) / Ut Maj;Jul-Sep;https://commons.wikimedia.org/wiki/Category:Brassica_oleracea_convar._capitata;Varierar per fil (se Commons-sidan) +Vitkål;Brassica oleracea convar. capitata;Grönsak;-;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.) / Ut Maj;Aug-Okt;https://commons.wikimedia.org/wiki/Category:Brassica_oleracea_convar._capitata;Varierar per fil (se Commons-sidan) +Savoykål;Brassica oleracea var. sabauda;Grönsak;-;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.) / Ut Maj;Sep-Nov;https://commons.wikimedia.org/wiki/Category:Brassica_oleracea_var._sabauda;Varierar per fil (se Commons-sidan) +Grönkål;Brassica oleracea var. sabellica;Grönsak;-;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.) / Ut Maj;Sep-Dec;https://commons.wikimedia.org/wiki/Category:Brassica_oleracea_var._sabellica;Varierar per fil (se Commons-sidan) +Brysselkål;Brassica oleracea var. gemmifera;Grönsak;-;Sol;Måttligt;Ej hotad;Mar (förkult.) / Ut Maj;Sep-Nov;https://commons.wikimedia.org/wiki/Category:Brassica_oleracea_var._gemmifera;Varierar per fil (se Commons-sidan) +Blomkål;Brassica oleracea var. botrytis;Grönsak;-;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.) / Ut Maj;Jul-Sep;https://commons.wikimedia.org/wiki/Category:Brassica_oleracea_var._botrytis;Varierar per fil (se Commons-sidan) +Broccoli;Brassica oleracea var. italica;Grönsak;-;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.) / Ut Maj;Jul-Sep;https://commons.wikimedia.org/wiki/Category:Brassica_oleracea_var._italica;Varierar per fil (se Commons-sidan) +Kålrabbi;Brassica oleracea Gongylodes-gr.;Grönsak;-;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.) / Ut Maj;Jun-Jul;https://commons.wikimedia.org/wiki/Category:Brassica_oleracea_Gongylodes-gr.;Varierar per fil (se Commons-sidan) +Kålrot;Brassica napus subsp. rapifera;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Maj (direktsådd);Sep-Nov;https://commons.wikimedia.org/wiki/Category:Brassica_napus_subsp._rapifera;Varierar per fil (se Commons-sidan) +Rödbeta;Beta vulgaris;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Jun (direktsådd);Jul-Okt;https://commons.wikimedia.org/wiki/Category:Beta_vulgaris;Varierar per fil (se Commons-sidan) +Mangold;Beta vulgaris var. cicla;Grönsak;-;Sol/Måttlig skugga;Måttligt;Ej hotad;Apr-Jun (direktsådd);Jun-Okt;https://commons.wikimedia.org/wiki/Category:Beta_vulgaris_var._cicla;Varierar per fil (se Commons-sidan) +Spenat;Spinacia oleracea;Grönsak;-;Sol/Måttlig skugga;Högt;Ej hotad;Apr-Maj & Aug (direktsådd);Maj-Jun & Sep-Okt;https://commons.wikimedia.org/wiki/Category:Spinacia_oleracea;Varierar per fil (se Commons-sidan) +Sockerärta;Pisum sativum var. saccharatum;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Maj (direktsådd);Jun-Jul;https://commons.wikimedia.org/wiki/Category:Pisum_sativum_var._saccharatum;Varierar per fil (se Commons-sidan) +Brytböna;Phaseolus vulgaris;Grönsak;-;Sol;Måttligt;Ej hotad;Maj (förkult.) / Ut Jun;Jul-Sep;https://commons.wikimedia.org/wiki/Category:Phaseolus_vulgaris;Varierar per fil (se Commons-sidan) +Bondböna;Vicia faba;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-May (direktsådd);Jul-Aug;https://commons.wikimedia.org/wiki/Category:Vicia_faba;Varierar per fil (se Commons-sidan) +Majs (sockermajs);Zea mays var. saccharata;Grönsak;-;Sol;Högt;Ej hotad;Apr (förkult.) / Ut Jun;Aug-Sep;https://commons.wikimedia.org/wiki/Category:Zea_mays_var._saccharata;Varierar per fil (se Commons-sidan) +Purjolök;Allium ampeloprasum var. porrum;Grönsak;-;Sol;Måttligt;Ej hotad;Feb-Mar (förkult.) / Ut Maj;Aug-Nov;https://commons.wikimedia.org/wiki/Category:Allium_ampeloprasum_var._porrum;Varierar per fil (se Commons-sidan) +Gul lök (sättlök);Allium cepa;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Maj (sättlök);Aug-Sep;https://commons.wikimedia.org/wiki/Category:Allium_cepa;Varierar per fil (se Commons-sidan) +Rödlök (sättlök);Allium cepa;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Maj (sättlök);Aug-Sep;https://commons.wikimedia.org/wiki/Category:Allium_cepa;Varierar per fil (se Commons-sidan) +Schalottenlök;Allium cepa Aggregatum-gr.;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Maj (utsättning);Aug-Sep;https://commons.wikimedia.org/wiki/Category:Allium_cepa_Aggregatum-gr.;Varierar per fil (se Commons-sidan) +Vårlök;Allium fistulosum;Grönsak;-;Sol;Måttligt;Ej hotad;Mar-Apr (förkult./direkt);Jun-Sep;https://commons.wikimedia.org/wiki/Category:Allium_fistulosum;Varierar per fil (se Commons-sidan) +Vitlök;Allium sativum;Grönsak;-;Sol;Måttligt;Ej hotad;Okt-Nov (sätt);Jul-Aug (nästa år);https://commons.wikimedia.org/wiki/Category:Allium_sativum;Varierar per fil (se Commons-sidan) +Fänkål (knöl);Foeniculum vulgare var. azoricum;Grönsak;-;Sol;Måttligt;Ej hotad;May-Jun (så/plantera);Aug-Sep;https://commons.wikimedia.org/wiki/Category:Foeniculum_vulgare_var._azoricum;Varierar per fil (se Commons-sidan) +Selleri (stjälk);Apium graveolens var. dulce;Grönsak;-;Sol;Högt;Ej hotad;Feb-Mar (förkult.) / Ut Jun;Aug-Sep;https://commons.wikimedia.org/wiki/Category:Apium_graveolens_var._dulce;Varierar per fil (se Commons-sidan) +Rotselleri;Apium graveolens var. rapaceum;Grönsak;-;Sol;Högt;Ej hotad;Feb (förkult.) / Ut Maj;Sep-Okt;https://commons.wikimedia.org/wiki/Category:Apium_graveolens_var._rapaceum;Varierar per fil (se Commons-sidan) +Rädisor;Raphanus sativus;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Aug (omgångssådd);May-Sep;https://commons.wikimedia.org/wiki/Category:Raphanus_sativus;Varierar per fil (se Commons-sidan) +Rättika;Raphanus sativus var. longipinnatus;Grönsak;-;Sol;Måttligt;Ej hotad;Jul-Aug (direktsådd);Sep-Okt;https://commons.wikimedia.org/wiki/Category:Raphanus_sativus_var._longipinnatus;Varierar per fil (se Commons-sidan) +Pak choi;Brassica rapa subsp. chinensis;Grönsak;-;Sol/Måttlig skugga;Måttligt;Ej hotad;Apr-Aug (omgångssådd);Jun-Okt;https://commons.wikimedia.org/wiki/Category:Brassica_rapa_subsp._chinensis;Varierar per fil (se Commons-sidan) +Komatsuna;Brassica rapa var. perviridis;Grönsak;-;Sol/Måttlig skugga;Måttligt;Ej hotad;Apr-Aug (omgångssådd);Jun-Okt;https://commons.wikimedia.org/wiki/Category:Brassica_rapa_var._perviridis;Varierar per fil (se Commons-sidan) +Mizuna;Brassica rapa var. nipposinica;Grönsak;-;Sol/Måttlig skugga;Måttligt;Ej hotad;Apr-Aug (omgångssådd);Jun-Okt;https://commons.wikimedia.org/wiki/Category:Brassica_rapa_var._nipposinica;Varierar per fil (se Commons-sidan) +Salladskål;Brassica rapa subsp. pekinensis;Grönsak;-;Sol/Måttlig skugga;Måttligt;Ej hotad;Jun-Jul (sådd);Aug-Sep;https://commons.wikimedia.org/wiki/Category:Brassica_rapa_subsp._pekinensis;Varierar per fil (se Commons-sidan) +Ärtskott;Pisum sativum (skott);Grönsak;-;Sol/Måttlig skugga;Måttligt;Ej hotad;Året runt (inomhus);Året runt;https://commons.wikimedia.org/wiki/Category:Pisum_sativum_(skott);Varierar per fil (se Commons-sidan) +Gurka (växthus);Cucumis sativus;Grönsak;-;Sol;Högt;Ej hotad;Apr (förkult.) / Ut Maj (växthus);Jun-Sep;https://commons.wikimedia.org/wiki/Category:Cucumis_sativus;Varierar per fil (se Commons-sidan) +Vintersquash;Cucurbita maxima/moschata;Grönsak;-;Sol;Högt;Ej hotad;Apr (förkult.) / Ut Jun;Sep-Okt;https://commons.wikimedia.org/wiki/Category:Cucurbita_maxima/moschata;Varierar per fil (se Commons-sidan) +Pumpa;Cucurbita maxima/moschata;Grönsak;-;Sol;Högt;Ej hotad;Apr (förkult.) / Ut Jun;Sep-Okt;https://commons.wikimedia.org/wiki/Category:Cucurbita_maxima/moschata;Varierar per fil (se Commons-sidan) +Aubergine;Solanum melongena;Grönsak;-;Sol;Måttligt;Ej hotad;Feb (förkult.) / Ut Jun (växthus);Aug-Sep;https://commons.wikimedia.org/wiki/Category:Solanum_melongena;Varierar per fil (se Commons-sidan) +Grönkål (baby leaves);Brassica oleracea var. sabellica;Grönsak;-;Sol/Måttlig skugga;Måttligt;Ej hotad;Apr-Aug (omgångssådd);Jun-Okt;https://commons.wikimedia.org/wiki/Category:Brassica_oleracea_var._sabellica;Varierar per fil (se Commons-sidan) +Sockermajs (tidig);Zea mays var. saccharata;Grönsak;-;Sol;Högt;Ej hotad;Apr (förkult.) / Ut Jun;Aug;https://commons.wikimedia.org/wiki/Category:Zea_mays_var._saccharata;Varierar per fil (se Commons-sidan) +Salladsmix (blad);Lactuca sativa (mix);Grönsak;-;Sol/Måttlig skugga;Högt;Ej hotad;Apr-Aug (omgångssådd);Jun-Sep;https://commons.wikimedia.org/wiki/Category:Lactuca_sativa_(mix);Varierar per fil (se Commons-sidan) +Dill (som grönsak);Anethum graveolens;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Jul (omgångssådd);Jun-Sep;https://commons.wikimedia.org/wiki/Category:Anethum_graveolens;Varierar per fil (se Commons-sidan) +Persiljerot;Petroselinum crispum var. tuberosum;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-May (direktsådd);Sep-Nov;https://commons.wikimedia.org/wiki/Category:Petroselinum_crispum_var._tuberosum;Varierar per fil (se Commons-sidan) +Sötpotatis;Ipomoea batatas;Grönsak;-;Sol;Högt;Ej hotad;Apr (förkult. sticklingar) / Ut Jun (varmt);Sep-Okt;https://commons.wikimedia.org/wiki/Category:Ipomoea_batatas;Varierar per fil (se Commons-sidan) +Jordärtskocka;Helianthus tuberosus;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-May (sätt knölar);Okt-Mar;https://commons.wikimedia.org/wiki/Category:Helianthus_tuberosus;Varierar per fil (se Commons-sidan) +Rabarber;Rheum rhabarbarum;Grönsak;-;Sol/Måttlig skugga;Högt;Ej hotad;Plantera vår/höst;Apr-Jun;https://commons.wikimedia.org/wiki/Category:Rheum_rhabarbarum;Varierar per fil (se Commons-sidan) +Sockerärta (låg);Pisum sativum var. saccharatum;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-May (direktsådd);Jun-Jul;https://commons.wikimedia.org/wiki/Category:Pisum_sativum_var._saccharatum;Varierar per fil (se Commons-sidan) +Snabbspenat;Spinacia oleracea;Grönsak;-;Sol/Måttlig skugga;Högt;Ej hotad;Apr-May & Aug;May-Jun & Sep-Okt;https://commons.wikimedia.org/wiki/Category:Spinacia_oleracea;Varierar per fil (se Commons-sidan) +Bladselleri;Apium graveolens var. secalinum;Grönsak;-;Sol/Måttlig skugga;Högt;Ej hotad;Apr (förkult.) / Ut Jun;Aug-Okt;https://commons.wikimedia.org/wiki/Category:Apium_graveolens_var._secalinum;Varierar per fil (se Commons-sidan) +Bataviasallat;Lactuca sativa var. capitata;Grönsak;-;Sol/Måttlig skugga;Högt;Ej hotad;Apr-Jul (omgångssådd);Jun-Sep;https://commons.wikimedia.org/wiki/Category:Lactuca_sativa_var._capitata;Varierar per fil (se Commons-sidan) +Endive;Cichorium endivia;Grönsak;-;Sol;Måttligt;Ej hotad;Jun-Jul (sådd);Aug-Okt;https://commons.wikimedia.org/wiki/Category:Cichorium_endivia;Varierar per fil (se Commons-sidan) +Majrova;Brassica rapa var. rapa;Grönsak;-;Sol;Måttligt;Ej hotad;Apr-Jun (direktsådd);Jul-Sep;https://commons.wikimedia.org/wiki/Category:Brassica_rapa_var._rapa;Varierar per fil (se Commons-sidan) +Ringblomma;Calendula officinalis;Blomma;Jun-Okt;Sol;Måttligt;Ej hotad;Apr-May (direktsådd);-;https://commons.wikimedia.org/wiki/Category:Calendula_officinalis;Varierar per fil (se Commons-sidan) +Solros;Helianthus annuus;Blomma;Jul-Sep;Sol;Måttligt;Ej hotad;Apr-May (direktsådd);-;https://commons.wikimedia.org/wiki/Category:Helianthus_annuus;Varierar per fil (se Commons-sidan) +Blåklint;Centaurea cyanus;Blomma;Jun-Aug;Sol;Måttligt;Ej hotad;Apr-May (direktsådd);-;https://commons.wikimedia.org/wiki/Category:Centaurea_cyanus;Varierar per fil (se Commons-sidan) +Prästkrage;Leucanthemum vulgare;Blomma;Jun-Aug;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Leucanthemum_vulgare;Varierar per fil (se Commons-sidan) +Rudbeckia;Rudbeckia hirta;Blomma;Jul-Sep;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Rudbeckia_hirta;Varierar per fil (se Commons-sidan) +Lavendel;Lavandula angustifolia;Blomma;Jul-Aug;Sol;Lågt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Lavandula_angustifolia;Varierar per fil (se Commons-sidan) +Luktärt;Lathyrus odoratus;Blomma;Jun-Sep;Sol;Måttligt;Ej hotad;Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Lathyrus_odoratus;Varierar per fil (se Commons-sidan) +Kornvallmo;Papaver rhoeas;Blomma;Jun-Aug;Sol;Lågt;Ej hotad;Apr (direktsådd);-;https://commons.wikimedia.org/wiki/Category:Papaver_rhoeas;Varierar per fil (se Commons-sidan) +Lejongap;Antirrhinum majus;Blomma;Jun-Sep;Sol;Måttligt;Ej hotad;Mar (förkult.);-;https://commons.wikimedia.org/wiki/Category:Antirrhinum_majus;Varierar per fil (se Commons-sidan) +Petunia;Petunia x hybrida;Blomma;Jun-Sep;Sol;Måttligt;Ej hotad;Feb-Mar (förkult.);-;https://commons.wikimedia.org/wiki/Category:Petunia_x_hybrida;Varierar per fil (se Commons-sidan) +Tagetes;Tagetes patula/erecta;Blomma;Jun-Sep;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Tagetes_patula/erecta;Varierar per fil (se Commons-sidan) +Zinnia;Zinnia elegans;Blomma;Jul-Sep;Sol;Måttligt;Ej hotad;Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Zinnia_elegans;Varierar per fil (se Commons-sidan) +Cosmos (Rosenskära);Cosmos bipinnatus;Blomma;Jul-Sep;Sol;Måttligt;Ej hotad;Apr-May (direktsådd/ förkult.);-;https://commons.wikimedia.org/wiki/Category:Cosmos_bipinnatus;Varierar per fil (se Commons-sidan) +Stäppsalvia;Salvia nemorosa;Blomma;Jun-Aug;Sol;Lågt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Salvia_nemorosa;Varierar per fil (se Commons-sidan) +Blomstertobak;Nicotiana alata;Blomma;Jul-Sep;Sol;Måttligt;Ej hotad;Mar (förkult.);-;https://commons.wikimedia.org/wiki/Category:Nicotiana_alata;Varierar per fil (se Commons-sidan) +Nigella (Jungfrun i det gröna);Nigella damascena;Blomma;Jun-Aug;Sol;Måttligt;Ej hotad;Apr (direktsådd);-;https://commons.wikimedia.org/wiki/Category:Nigella_damascena;Varierar per fil (se Commons-sidan) +Reseda;Reseda odorata;Blomma;Jun-Sep;Sol;Måttligt;Ej hotad;Apr (direktsådd);-;https://commons.wikimedia.org/wiki/Category:Reseda_odorata;Varierar per fil (se Commons-sidan) +Akleja;Aquilegia vulgaris;Blomma;May-Jun;Sol/Måttlig skugga;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Aquilegia_vulgaris;Varierar per fil (se Commons-sidan) +Vallmo (orient.);Papaver orientale;Blomma;May-Jun;Sol;Lågt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Papaver_orientale;Varierar per fil (se Commons-sidan) +Lupin;Lupinus polyphyllus;Blomma;Jun-Jul;Sol;Måttligt;Ej hotad;Apr (direktsådd);-;https://commons.wikimedia.org/wiki/Category:Lupinus_polyphyllus;Varierar per fil (se Commons-sidan) +Stjärnflocka;Astrantia major;Blomma;Jun-Aug;Sol/Måttlig skugga;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Astrantia_major;Varierar per fil (se Commons-sidan) +Praktvädd;Scabiosa atropurpurea;Blomma;Jul-Sep;Sol;Måttligt;Ej hotad;Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Scabiosa_atropurpurea;Varierar per fil (se Commons-sidan) +Digitalis (Fingerborgsblomma);Digitalis purpurea;Blomma;Jun-Aug;Sol/Måttlig skugga;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Digitalis_purpurea;Varierar per fil (se Commons-sidan) +Kornblomster (Centaurea montana);Centaurea montana;Blomma;May-Jun;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Centaurea_montana;Varierar per fil (se Commons-sidan) +Riddarsporre;Delphinium elatum;Blomma;Jun-Jul;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Delphinium_elatum;Varierar per fil (se Commons-sidan) +Höstflox;Phlox paniculata;Blomma;Jul-Sep;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Phlox_paniculata;Varierar per fil (se Commons-sidan) +Echinacea (Solhatt);Echinacea purpurea;Blomma;Jul-Sep;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Echinacea_purpurea;Varierar per fil (se Commons-sidan) +Daglilja;Hemerocallis spp.;Blomma;Jun-Aug;Sol;Måttligt;Ej hotad;Mar-Apr (förkult.);-;https://commons.wikimedia.org/wiki/Category:Hemerocallis_spp.;Varierar per fil (se Commons-sidan) +Rosenmalva;Malva alcea;Blomma;Jul-Sep;Sol;Måttligt;Ej hotad;Apr (direktsådd);-;https://commons.wikimedia.org/wiki/Category:Malva_alcea;Varierar per fil (se Commons-sidan) +Verbena (Järnört); \ No newline at end of file diff --git a/backend/models/Event.js b/backend/models/Event.js new file mode 100644 index 0000000000..a699ebb4cf --- /dev/null +++ b/backend/models/Event.js @@ -0,0 +1,20 @@ +import mongoose from "mongoose" + +const eventSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + date: { type: Date, required: true }, + time: { type: String }, + text: { type: String, required: true }, + done: { type: Boolean, default: false } +}, { + toJSON: { + transform: function (doc, ret) { + ret.id = ret._id + delete ret._id + delete ret.__v + return ret + } + } +}) + +export default mongoose.model("Event", eventSchema) \ No newline at end of file diff --git a/backend/models/plant.js b/backend/models/plant.js new file mode 100644 index 0000000000..5d60c6a502 --- /dev/null +++ b/backend/models/plant.js @@ -0,0 +1,117 @@ +import mongoose from "mongoose"; + +const plantSchema = new mongoose.Schema({ + + scientificName: { + type: [String], + required: true, + }, + swedishName: { + type: String, + }, + commonName: { + type: String, // for the english name + }, + description: { + type: String, + }, + imageUrl: { + type: String, + }, + watering: { + type: [String], + default: [], + }, + sunlight: { + type: [String], + default: [], + }, + soil: { + type: [String], + default: [], + }, + sowingPeriod: { + type: [String], // Ex: ["March", "April"] + default: [], + }, + harvestPeriod: { + type: [String], + default: [], + }, + sowingMonths: { + type: [Number], // Ex: [3, 4, 5] för mars-maj + default: [], + }, + harvestMonths: { + type: [Number], + default: [], + }, + companionPlants: [{ + type: mongoose.Schema.Types.ObjectId, + ref: "Plant", + }], + companionPlantNames: { + type: [String], + default: [], + }, + edibleParts: { + type: [String], // Ex: ["leaves", "fruit", "root"] + default: [], + }, + isEdible: { + type: Boolean, + default: false, + }, + + redListStatus: { + type: String, // Ex: "LC", "NT", "EN", "CR", etc. + }, + tags: { + type: [String], // Ex: ["indoors", "perennial", "climber"] + default: [], + }, + + // API-data + perenualId: { + type: Number, // ID from Perenual API + }, + source: { + type: String, + enum: ["csv", "api", "csv_and_api", "manual"], + default: "manual", + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + createdAt: { + type: Date, + default: Date.now, + }, + updatedAt: { + type: Date, + default: Date.now, + }, +}) + +// Index +plantSchema.index({ scientificName: 1 }); +plantSchema.index({ swedishName: 1 }); +plantSchema.index({ commonName: 1 }); +plantSchema.index({ sunlight: 1 }); +plantSchema.index({ watering: 1 }); +plantSchema.index({ sowingMonths: 1 }); + + +plantSchema.virtual('fullName').get(function () { + return `${this.swedishName} (${this.scientificName})`; +}); + +// Middleware for updatedAt +plantSchema.pre('findOneAndUpdate', function () { + this.set({ updatedAt: new Date() }); +}); + +const Plant = mongoose.model("Plant", plantSchema); + +export default Plant; \ No newline at end of file diff --git a/backend/models/user.js b/backend/models/user.js new file mode 100644 index 0000000000..0a339b0d51 --- /dev/null +++ b/backend/models/user.js @@ -0,0 +1,62 @@ +import mongoose from "mongoose" +import crypto from "crypto" + +const userSchema = new mongoose.Schema({ + username: { + type: String, + required: true, + unique: true, + minlength: 3, + maxlength: 50, + trim: true, + }, + password: { + type: String, + required: true, + minlength: 6, + maxlength: 100, + }, + email: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + match: [/.+\@.+\..+/, 'Please enter a valid email address'], + }, + savedPlants: [{ + plant: { + type: mongoose.Schema.Types.ObjectId, + ref: "Plant" + }, + savedAt: { + type: Date, + default: Date.now + }, + notes: { + type: String, + maxlength: 500 + } + }], + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex"), + }, + createdAt: { + type: Date, + default: Date.now, + }, + lastLogin: { + type: Date, + } +}, { + timestamps: true // +}) + +// Index +//userSchema.index({ email: 1 }); +userSchema.index({ accessToken: 1 }); + +const User = mongoose.model("User", userSchema) + +export default User \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f2448..0463813fb7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,7 @@ { "name": "project-final-backend", "version": "1.0.0", + "type": "module", "description": "Server part of final project", "scripts": { "start": "babel-node server.js", @@ -12,9 +13,19 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "axios": "^1.11.0", + "bcrypt": "^6.0.0", + "cheerio": "^1.1.2", "cors": "^2.8.5", + "crypto": "^1.0.1", + "dotenv": "^17.2.1", "express": "^4.17.3", - "mongoose": "^8.4.0", - "nodemon": "^3.0.1" + "express-list-endpoints": "^7.1.1", + "jsonwebtoken": "^9.0.2", + "mongodb": "^6.18.0", + "mongoose": "^8.17.0", + "node-fetch": "^3.3.2", + "nodemon": "^3.0.1", + "papaparse": "^5.5.3" } } \ No newline at end of file diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000000..51979d02bb --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,116 @@ +import express from "express" +import bcrypt from "bcrypt" +import dotenv from "dotenv" +import User from "../models/user.js" + +dotenv.config() + +const router = express.Router() + +// 🧠 Middleware – kontrollera accessToken +export const authenticationUser = async (req, res, next) => { + try { + let accessToken = req.headers.authorization + + if (!accessToken) { + return res.status(401).json({ loggedOut: true, message: "Access token missing" }) + } + + if (accessToken.startsWith("Bearer ")) { + accessToken = accessToken.slice(7) + } + + const user = await User.findOne({ accessToken }) + + if (user) { + req.user = user + next() + } else { + res.status(401).json({ loggedOut: true, message: "Unauthorized: invalid token" }) + } + } catch (error) { + res.status(500).json({ success: false, message: "Server error", error: error.message }) + } +} + +// 🔐 Register user +router.post("/signup", async (req, res) => { + try { + const { username, email, password } = req.body + + const existingUser = await User.findOne({ email }) + + if (existingUser) { + return res.status(400).json({ + success: false, + message: "Email already exists" + }) + } + + if (!username || !email || !password) { + return res.status(400).json({ + success: false, + message: "All fields are required" + }) + } + + if (password.length < 6) { + return res.status(400).json({ + success: false, + message: "Password must be at least 6 characters" + }) + } + + const hashedPassword = await bcrypt.hash(password, 10) + + const newUser = new User({ + username, + email, + password: hashedPassword, + }) + + await newUser.save() + + res.status(201).json({ + success: true, + userId: newUser._id, + accessToken: newUser.accessToken, + }) + } catch (error) { + console.log("Signup error:", err) + res.status(500).json({ + success: false, + message: "Could not create user", + error: error.message, + + }) + + + + } +}) + +// 🔓 Login +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body + + const user = await User.findOne({ email }) + + if (!user || !(await bcrypt.compare(password, user.password))) { + return res.status(401).json({ success: false, message: "Invalid email or password" }) + } + + res.status(200).json({ + success: true, + userId: user._id, + username: user.username, + accessToken: user.accessToken, + }) + } catch (error) { + res.status(500).json({ success: false, message: "Login failed", error: error.message }) + } +}) + +export default router + diff --git a/backend/routes/events.js b/backend/routes/events.js new file mode 100644 index 0000000000..b76127bd8d --- /dev/null +++ b/backend/routes/events.js @@ -0,0 +1,58 @@ +import express from "express" +import Event from "../models/Event.js" +import { authenticationUser } from "./auth.js" + +const router = express.Router() + +// hämta alla events för inloggad user +router.get("/events", authenticationUser, async (req, res) => { + try { + const events = await Event.find({ userId: req.user._id }) + res.json(events) + } catch (err) { + res.status(500).json({ error: "Failed to fetch events" }) + + } + +}) + +// skapa nytt event +router.post("/events", authenticationUser, async (req, res) => { + try { + const { date, time, text, done } = req.body; + const event = new Event({ userId: req.user._id, date, time, text, done }) + await event.save() + res.json(event) + } catch (err) { + res.status(500).json({ error: "Failed to create event" }) + } +}) + +// uppdatera event +router.put("/events/:id", authenticationUser, async (req, res) => { + try { + const updated = await Event.findOneAndUpdate( + { _id: req.params.id, userId: req.user._id }, + req.body, + { new: true } + ) + res.json(updated) + + } catch (err) { + res.status(500).json({ error: "Failed to update event" }) + } + +}) + +// ta bort event +router.delete("/events/:id", authenticationUser, async (req, res) => { + try { + await Event.findOneAndDelete({ _id: req.params.id, userId: req.user._id }); + res.json({ success: true }) + } catch (err) { + res.status(500).json({ error: "Failed to delete event" }) + } + +}) + +export default router; \ No newline at end of file diff --git a/backend/routes/plants.js b/backend/routes/plants.js new file mode 100644 index 0000000000..aec9fab974 --- /dev/null +++ b/backend/routes/plants.js @@ -0,0 +1,442 @@ +import express from "express" +import Plant from "../models/plant.js" +import { authenticationUser } from "./auth.js" +import axios from "axios" +import dotenv from "dotenv" + +dotenv.config() + +const plantRouter = express.Router() +const API_KEY = process.env.PERENUAL_API_KEY + +// Function to search in API as fallback +const searchPlantInAPI = async (searchTerm) => { + try { + console.log(`🔍 Searching API for: "${searchTerm}"`) + + const response = await axios.get(`https://perenual.com/api/v2/species-list`, { + params: { + key: API_KEY, + q: searchTerm, + }, + timeout: 10000 // Add timeout + }) + + const apiPlants = response.data.data || [] + console.log(`📦 API returned ${apiPlants.length} results`) + + // Convert API results to our format + return apiPlants.map(apiPlant => ({ + _id: `api_${apiPlant.id}`, // Temp ID for frontend + scientificName: Array.isArray(apiPlant.scientific_name) + ? apiPlant.scientific_name[0] + : apiPlant.scientific_name || "", + swedishName: apiPlant.common_name || "Unknown Swedish name", + commonName: apiPlant.common_name || "", + description: apiPlant.description || "", + imageUrl: apiPlant.default_image?.medium_url || "", + sunlight: Array.isArray(apiPlant.sunlight) + ? apiPlant.sunlight + : [apiPlant.sunlight].filter(Boolean), + watering: apiPlant.watering ? [apiPlant.watering] : ["unknown"], + perenualId: apiPlant.id, + cycle: apiPlant.cycle || "", + isEdible: false, + companionPlantNames: [], + edibleParts: [], + source: "api_live", // Mark as live API result + isFromAPI: true, // Extra flag for frontend + createdAt: new Date() + })) + } catch (error) { + console.error(`❌ API search failed for "${searchTerm}":`, error.message) + return [] + } +} + + +// GET all plants - with search, filtering AND API fallback +plantRouter.get("/plants", async (req, res) => { + try { + const { search, startMonth, endMonth, companion, sunlight, watering, includeAPI } = req.query + const query = {} + + console.log("🔍 Incoming query params", req.query) + + // Text search on name or scientific name + if (search && search.trim()) { + const searchRegex = { $regex: search.trim(), $options: "i" } + + query.$or = [ + { scientificName: searchRegex }, + { swedishName: searchRegex }, + { commonName: searchRegex }, + { description: searchRegex } + ] + } + + // Month filtering + if (startMonth && endMonth) { + const start = Number(startMonth) + const end = Number(endMonth) + + if (start <= end) { + // Normal period, e.g. 3–5 + query.sowingMonths = { $gte: start, $lte: end } + } else { + // If period spans year end, e.g. 11–2 + query.$or = query.$or ? [...query.$or, + { sowingMonths: { $gte: start } }, + { sowingMonths: { $lte: end } } + ] : [ + { sowingMonths: { $gte: start } }, + { sowingMonths: { $lte: end } } + ] + } + } + + // Companion plants filtering + if (companion) { + query.companionPlantNames = { $in: [companion] } + } + + // Sunlight filtering + if (sunlight) { + query.sunlight = { $in: [sunlight] } + } + + // Watering filtering + if (watering) { + query.watering = { $in: [watering] } + } + + console.log("📊 Filters sent to MongoDB:", JSON.stringify(query, null, 2)) + + // Search database first + const plants = await Plant.find(query).limit(50) + console.log(`📊 Database hits: ${plants.length}`) + + let allResults = plants + let apiResults = [] + + // If no database results AND there's a search term, search API + if (plants.length === 0 && search && search.trim() && includeAPI !== 'false') { + console.log("🌐 No database results, searching API as fallback...") + apiResults = await searchPlantInAPI(search.trim()) + + if (apiResults.length > 0) { + console.log(`✨ Found ${apiResults.length} results in API`) + allResults = [...plants, ...apiResults] + } + } + + // Debug if no hits at all + if (allResults.length === 0) { + console.log("⚠️ No hits found in either database or API.") + + // Debug: Show what's in the database + const totalCount = await Plant.countDocuments({}) + console.log(`📢 Total plants in database: ${totalCount}`) + + if (totalCount > 0) { + const sample = await Plant.findOne().lean() + console.log("🔍 Example plant in database:", { + scientificName: sample?.scientificName, + swedishName: sample?.swedishName, + sunlight: sample?.sunlight, + watering: sample?.watering + }) + } + } + + res.json({ + success: true, + count: allResults.length, + dbCount: plants.length, + apiCount: apiResults.length, + plants: allResults, + searchedInAPI: apiResults.length > 0, // Info for frontend + searchTerm: search || null + }) + } catch (error) { + console.error("❌ Error in GET /plants:", error) + res.status(500).json({ + success: false, + message: "Failed to get plants", + error: error.message + }) + } +}) + +// Save plant to user's saved plants +plantRouter.post("/plants/saved", authenticationUser, async (req, res) => { + try { + const { plantId, notes } = req.body + + if (!plantId) { + return res.status(400).json({ + success: false, + message: "Plant ID is required" + }) + } + + const plant = await Plant.findById(plantId) + if (!plant) { + return res.status(404).json({ + success: false, + message: "Plant not found" + }) + } + + // Add to user's saved plants + const user = req.user + const alreadySaved = user.savedPlants.some( + (p) => p.plant.toString() === plantId + ) + + if (alreadySaved) { + return res.status(400).json({ + success: false, + message: "Plant already saved" + }) + } + + const savedPlant = { + plant: plant._id, + savedAt: new Date(), + notes: notes || "" + } + + user.savedPlants.push(savedPlant) + await user.save() + + res.status(201).json({ + success: true, + message: "Plant saved successfully", + savedPlant + }) + } catch (error) { + console.error("❌ Error saving plant:", error) + res.status(500).json({ + success: false, + message: "Failed to save plant", + error: error.message + }) + } +}) + + + +// Save API plant to database AND add to favorites +plantRouter.post("/plants/save-and-favorite", authenticationUser, async (req, res) => { + try { + const { apiPlant, notes } = req.body + + if (!apiPlant || !apiPlant.perenualId) { + return res.status(400).json({ + success: false, + message: "API plant data required" + }) + } + + // Find or create plant in database + let savedPlant = await Plant.findOne({ perenualId: apiPlant.perenualId }) + if (!savedPlant) { + // Remove temporary fields before saving + const { _id, isFromAPI, source, ...plantDataToSave } = apiPlant + plantDataToSave.source = "api" // Set proper source + + savedPlant = new Plant(plantDataToSave) + await savedPlant.save() + } + + const user = req.user + // Fixed typo: savedPlant -> savedPlants + const alreadySaved = user.savedPlants.some( + (p) => p.plant.toString() === savedPlant._id.toString() + ) + + if (alreadySaved) { + return res.status(400).json({ + success: false, + message: "Plant already saved" + }) + } + + const savedPlantEntry = { + plant: savedPlant._id, + savedAt: new Date(), + notes: notes || "" + } + + user.savedPlants.push(savedPlantEntry) + await user.save() + + res.status(201).json({ + success: true, + message: "Plant saved to database and favorites", + plant: savedPlant, + savedPlant: savedPlantEntry + }) + + } catch (error) { + console.error("❌ Error in save-and-favorite:", error) + res.status(500).json({ + success: false, + message: "Failed to save plant", + error: error.message + }) + } +}) + + + +// GET user's saved plants +plantRouter.get("/plants/saved", authenticationUser, async (req, res) => { + try { + const user = req.user + await user.populate("savedPlants.plant") + + res.status(200).json({ + success: true, + savedPlants: user.savedPlants.map(sp => ({ + _id: sp._id, + savedAt: sp.savedAt, + notes: sp.notes, + plant: sp.plant + })) + }) + } catch (error) { + console.error("❌ Error getting saved plants:", error) + res.status(500).json({ + success: false, + message: "Failed to get saved plants", + error: error.message + }) + } +}) + +// GET specific plant by ID +plantRouter.get("/plants/:id", async (req, res) => { + try { + const plant = await Plant.findById(req.params.id) + + if (!plant) { + return res.status(404).json({ + success: false, + message: "Plant not found" + }) + } + + res.json({ + success: true, + plant: plant + }) + } catch (error) { + console.error("❌ Error getting plant by ID:", error) + res.status(500).json({ + success: false, + message: "Failed to get plant", + error: error.message + }) + } +}) + +// POST new plant (only for logged in users) +plantRouter.post("/plants", authenticationUser, async (req, res) => { + try { + const plantData = { + ...req.body, + createdBy: req.user._id, + createdAt: new Date() + } + + const newPlant = new Plant(plantData) + await newPlant.save() + + res.status(201).json({ + success: true, + message: "Plant created successfully", + plant: newPlant + }) + } catch (error) { + console.error("❌ Error creating plant:", error) + res.status(400).json({ + success: false, + message: "Failed to add plant", + error: error.message + }) + } +}) + +// PUT update plant (only for logged in users) +plantRouter.put("/plants/:id", authenticationUser, async (req, res) => { + try { + const updatedPlant = await Plant.findByIdAndUpdate( + req.params.id, + { ...req.body, updatedAt: new Date() }, + { new: true, runValidators: true } + ) + + if (!updatedPlant) { + return res.status(404).json({ + success: false, + message: "Plant not found" + }) + } + + res.json({ + success: true, + message: "Plant updated successfully", + plant: updatedPlant + }) + } catch (error) { + console.error("❌ Error updating plant:", error) + res.status(400).json({ + success: false, + message: "Failed to update plant", + error: error.message + }) + } +}) + +// DELETE plant (only for logged in users) +plantRouter.delete("/plants/saved/:savedPlantId", authenticationUser, async (req, res) => { + try { + const { savedPlantId } = req.params + const user = req.user + + + // Find the saved plant entry + const index = user.savedPlants.findIndex( + (p) => p._id.toString() === savedPlantId + ) + + if (index === -1) { + return res.status(404).json({ + success: false, + message: "Saved plant not found" + }) + } + + // Remove it from the array + const removed = user.savedPlants.splice(index, 1) + await user.save() + + res.status(200).json({ + success: true, + message: "Saved plant removed successfully", + removed: removed[0] + }) + } catch (error) { + console.error("❌ Error removing saved plant:", error) + res.status(500).json({ + success: false, + message: "Failed to remove saved plant", + error: error.message + }) + } + +}) + +export default plantRouter \ No newline at end of file diff --git a/backend/scripts/seedPlants.js b/backend/scripts/seedPlants.js new file mode 100644 index 0000000000..9bc078594b --- /dev/null +++ b/backend/scripts/seedPlants.js @@ -0,0 +1,326 @@ +import fs from "fs" +import Papa from "papaparse" +import mongoose from "mongoose" +import Plant from "../models/plant.js" +import axios from "axios" +import dotenv from "dotenv" +import fetch from "node-fetch" +import * as cheerio from "cheerio" + +dotenv.config() + +// 🔑 Environment variables +const API_KEY = process.env.PERENUAL_API_KEY +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project" + +// ⚙️ CSV Reader +const readCSVAndCombine = async () => { + try { + const csvContent = fs.readFileSync("data/plants.csv", 'utf-8') + + const csvData = Papa.parse(csvContent, { + header: true, + skipEmptyLines: true, + delimiter: ';', // CSV uses semicolons + dynamicTyping: false, + transformHeader: (header) => header.trim() + }) + + if (csvData.errors.length > 0) { + console.error('⚠️ CSV parsing errors:', csvData.errors) + } + + // Map actual CSV columns to the expected format + const results = csvData.data + .filter(row => { + const swedishName = row['Svenskt namn']?.trim() + return swedishName && swedishName.length > 0 && swedishName !== 'undefined' + }) + .map(row => ({ + scientificName: row['Vetenskapligt namn']?.trim() || '', + swedishName: row['Svenskt namn']?.trim() || '', + description: '', + companionPlants: [], + edibleParts: [], + csvType: row['Typ']?.trim() || '', + csvBloomingTime: row['Blomningstid']?.trim() || '', + csvSunlight: row['Sol/Skugga']?.trim() || '', + csvWatering: row['Vattenbehov']?.trim() || '', + csvImageUrl: row['Bild_URL']?.trim() || '', + csvSowingTime: row['Såtid']?.trim() || row['SÃ¥tid']?.trim() || '', + csvHarvestTime: row['Skördetid']?.trim() || row['Skördetid']?.trim() || '', + csvRedListStatus: row['Rödlistestatus']?.trim() || row['Rödlistestatus']?.trim() || '' + })) + + return results + + } catch (error) { + console.error('❌ Error reading CSV:', error.message) + return [] + } +} + +// 🖼️ Wikimedia image scraper +const getFirstImageFromCommons = async (categoryUrl) => { + try { + const response = await fetch(categoryUrl) + const html = await response.text() + const $ = cheerio.load(html) + + const firstFilePage = $(".galleryfilename a").attr("href") + if (!firstFilePage) return "" + + const filePageUrl = `https://commons.wikimedia.org${firstFilePage}` + + const fileRes = await fetch(filePageUrl); + const fileHtml = await fileRes.text(); + const $$ = cheerio.load(fileHtml); + + const imgUrl = $$(".fullMedia a").attr("href"); + return imgUrl ? `https:${imgUrl}` : ""; + } catch (error) { + console.error(`❌ Failed to fetch image for${categoryUrl}`, error); + return ""; + } +} + +// 🌱 Fetch multiple plants from API +const fetchAllPlantsFromAPI = async (maxPages = 30) => { + try { + let allAPIPlants = [] + + for (let page = 1; page <= maxPages; page++) { + + const response = await axios.get(`https://perenual.com/api/v2/species-list`, { + params: { + key: API_KEY, + page: page + }, + }) + + const plants = response.data.data || [] + allAPIPlants = [...allAPIPlants, ...plants] + + await new Promise(resolve => setTimeout(resolve, 1000)) + + // Stop if there is no more pages + if (!response.data.to || response.data.to >= response.data.total) { + break + } + } + + return allAPIPlants + } catch (err) { + console.error("❌ API fetch error:", err.message) + return [] + } +} + +// 🔄 Convert API format to DB format +const convertAPIPlantToOurFormat = (apiPlant) => { + return { + scientificName: Array.isArray(apiPlant.scientific_name) + ? apiPlant.scientific_name[0] + : apiPlant.scientific_name || "", + swedishName: apiPlant.common_name || "Okänt svenska namn", + commonName: apiPlant.common_name || "", + description: apiPlant.description || "", + imageUrl: apiPlant.default_image?.medium_url || "", + sunlight: Array.isArray(apiPlant.sunlight) ? apiPlant.sunlight : [apiPlant.sunlight].filter(Boolean), + watering: apiPlant.watering ? [apiPlant.watering] : ["unknown"], + perenualId: apiPlant.id, + cycle: apiPlant.cycle || "", + isEdible: false, + companionPlantNames: [], + edibleParts: [], + source: "api", + createdAt: new Date() + } +} + +// 🔎 Fetch single plant from API +const fetchFromExternalAPI = async (scientificName) => { + try { + if (!scientificName || scientificName === 'undefined') { + return {} + } + + const response = await axios.get(`https://perenual.com/api/v2/species-list`, { + params: { + key: API_KEY, + q: scientificName, + }, + timeout: 10000 + }) + + const plantData = response.data.data?.[0] + if (plantData) { + return { + imageUrl: plantData.default_image?.medium_url || "", + sunlight: Array.isArray(plantData.sunlight) ? plantData.sunlight : [plantData.sunlight].filter(Boolean), + watering: plantData.watering || "unknown", + perenualId: plantData.id, + commonName: plantData.common_name + } + } + return {} + } catch (err) { + if (err.response?.status === 429) { + console.error(`⏰ Rate limit reached for ${scientificName}:`, err.response.headers) + throw err // Re-throw to stop processing + } + console.error(`❌ API error for ${scientificName}:`, err.response?.data || err.message) + return {} + } +} + +// 🌍 Seeder +const seedCombinedData = async () => { + try { + await mongoose.connect(mongoUrl) + await Plant.deleteMany({}) + + let allPlantsToSave = [] + + // 1. Read CSV and add API-data + const csvPlants = await readCSVAndCombine() + + // Add rate limiting and better loop control + let apiCallsCount = 0 + const MAX_API_CALLS = 90 // Stay under 100 limit + + for (let i = 0; i < csvPlants.length && apiCallsCount < MAX_API_CALLS; i++) { + const plant = csvPlants[i] + + let externalData = {} + + // Only make API call if we haven't hit limit + if (apiCallsCount < MAX_API_CALLS && plant.scientificName) { + try { + externalData = await fetchFromExternalAPI(plant.scientificName) + apiCallsCount++ + + // Add delay between API calls + if (apiCallsCount < MAX_API_CALLS) { + await new Promise(resolve => setTimeout(resolve, 200)) + } + + } catch (error) { + if (error.response?.status === 429) { + console.log('⏰ Rate limit reached, continuing with CSV data only...') + break // Stop making API calls + } + } + } + + allPlantsToSave.push({ + scientificName: plant.scientificName, + swedishName: plant.swedishName, + commonName: externalData.commonName || "", + description: plant.description || `${plant.csvType} - ${plant.csvBloomingTime}`.trim(), + companionPlantNames: plant.companionPlants, + edibleParts: plant.edibleParts, + isEdible: plant.edibleParts && plant.edibleParts.length > 0, + imageUrl: externalData.imageUrl || await getFirstImageFromCommons(plant.csvImageUrl) || "", + sunlight: externalData.sunlight?.length > 0 ? externalData.sunlight : [plant.csvSunlight].filter(Boolean), + watering: externalData.watering !== "unknown" ? [externalData.watering] : [plant.csvWatering].filter(Boolean), + perenualId: externalData.perenualId || null, + type: plant.csvType, + bloomingTime: plant.csvBloomingTime, + sowingTime: plant.csvSowingTime, + harvestTime: plant.csvHarvestTime, + redListStatus: plant.csvRedListStatus, + createdAt: new Date(), + source: "csv" + }) + } + + // Process remaining CSV plants without API data if hit rate limit + if (allPlantsToSave.length < csvPlants.length) { + + for (let i = allPlantsToSave.length; i < csvPlants.length; i++) { + const plant = csvPlants[i] + + allPlantsToSave.push({ + scientificName: plant.scientificName, + swedishName: plant.swedishName, + commonName: "", + description: `${plant.csvType} - ${plant.csvBloomingTime}`.trim(), + companionPlantNames: plant.companionPlants, + edibleParts: plant.edibleParts, + isEdible: plant.edibleParts && plant.edibleParts.length > 0, + imageUrl: plant.csvImageUrl || "", + sunlight: [plant.csvSunlight].filter(Boolean), + watering: [plant.csvWatering].filter(Boolean), + perenualId: null, + type: plant.csvType, + bloomingTime: plant.csvBloomingTime, + sowingTime: plant.csvSowingTime, + harvestTime: plant.csvHarvestTime, + redListStatus: plant.csvRedListStatus, + createdAt: new Date(), + source: "csv" + }) + } + } + + // 2. Get more plants from API(only if we haven't hit rate limit) + if (apiCallsCount < MAX_API_CALLS) { + const apiPlants = await fetchAllPlantsFromAPI(5) // Reduce to 5 pages to stay under rate limit + const convertedAPIPlants = apiPlants.map(convertAPIPlantToOurFormat) + // Take away dubblets + const existingPerenualIds = new Set( + allPlantsToSave + .filter(plant => plant.perenualId) + .map(plant => plant.perenualId) + ) + + const uniqueAPIPlants = convertedAPIPlants.filter(plant => + plant.perenualId && !existingPerenualIds.has(plant.perenualId) + ) + + allPlantsToSave = [...allPlantsToSave, ...uniqueAPIPlants] + } + + // Save in smaller batches to handle validation errors better + const savedPlants = [] + const batchSize = 10 + + for (let i = 0; i < allPlantsToSave.length; i += batchSize) { + const batch = allPlantsToSave.slice(i, i + batchSize) + + try { + const saved = await Plant.insertMany(batch, { ordered: false }) + savedPlants.push(...saved) + + } catch (error) { + // Try saving individually to see which plants cause issues + for (const plantData of batch) { + try { + const saved = await Plant.create(plantData) + savedPlants.push(saved) + } catch (individualError) { + console.error(`❌ Failed to save ${plantData.swedishName}:`, individualError.message) + } + } + } + } + + // Show one example + if (savedPlants.length > 0) { + console.log("\n📋 Example imported plant:") + console.log(JSON.stringify(savedPlants[0], null, 2)) + } + + } catch (err) { + console.error("💥 Seeder error:", err.message) + console.error(err) + } finally { + console.log("🔌 Closing DB connection...") + mongoose.disconnect() + process.exit(0) + } +} + + +seedCombinedData() \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 070c875189..a23c5741f6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,41 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +import express from "express" +import cors from "cors" +import mongoose from "mongoose" +import dotenv from "dotenv" +import authRoutes from "./routes/auth.js" +import plantRouter from "./routes/plants.js" +import "./models/user.js" +import eventRoutes from "./routes/events.js" -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; + +dotenv.config() + +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project" +mongoose.connect(mongoUrl) +mongoose.Promise = Promise const port = process.env.PORT || 8080; const app = express(); -app.use(cors()); -app.use(express.json()); + +app.use(cors()) +app.use(express.json()) + +app.use(authRoutes) +app.use(plantRouter) +app.use(eventRoutes) + +//Routes app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); + res.json({ + message: "Garden Backend API", + status: "running", + endpoints: ["/signup", "/login", "/plants", "/account"] + }) +}) // Start the server app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); + console.log(`Server running on http://localhost:${port}`) +}) diff --git a/frontend/README.md b/frontend/README.md index 5cdb1d9cf3..62967306f2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -5,4 +5,93 @@ This boilerplate is designed to give you a head start in your React projects, wi ## Getting Started 1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +2. Start the development server using `npm run dev`. + + +Final Project +A project to get all knowledge together after a fast paced bootcamp, from zero to Hero, and challenge us to explore more of the world of tech. + +The problem +Problem Approach: +I identified a gap in existing garden management applications - they were either too complex and overwhelming, or lacked essential functionality. My approach was to conduct a competitive analysis of available apps to understand what worked well and what didn't. I focused on creating a minimal viable product that prioritized core features while maintaining simplicity and user-friendliness. +Planning Process: + +Research Phase: Analyzed existing garden apps to identify pain points and essential features +Feature Prioritization: Defined must-have functionality vs. nice-to-have features +Architecture Design: Planned a full-stack solution with clear separation of concerns +Technology Selection: Chose modern, well-supported technologies that would allow for future scalability + +Technologies & Tools Used: +Frontend: + +React with Vite for fast development and optimized builds +React Router for navigation management +Zustand for lightweight state management +Styled Components for component-based styling +React Hot Toast for user notifications +React Icons for consistent iconography +Axios for API communication + +Backend: + +Node.js with Express for the REST API +MongoDB with Mongoose for data persistence and modeling +bcrypt for secure password hashing +CORS for cross-origin request handling +dotenv for environment variable management + +Data & Integration: + +Plant API for comprehensive plant information +CSV processing with Papaparse for data import/export +Cheerio for web scraping plant data when needed +node-fetch for external API calls +crypto for generating secure tokens + +Development Tools: + +Nodemon for automatic server restarts during development + +Next Steps with More Time: + +Add seasonal planning tools + +Drag and drop to create your own garden + +Possibility to connect it to an offline calender + +Seasonal Planning Tools: + +Plant Calendar Integration: Implement a dynamic planting calendar that suggests optimal sowing, transplanting, and harvesting dates based on user location and last frost dates + +Seasonal Task Management: Create automated reminders for seasonal garden tasks (pruning, fertilizing, pest control) with customizable scheduling + +Crop Rotation Planning: Build a multi-year planning system that suggests crop rotation patterns to maintain soil health + +Weather Integration: Connect with weather APIs to provide frost warnings and adjust planting recommendations based on seasonal forecasts + +Drag and Drop Garden Designer: + +Interactive Garden Layout: Implement a drag-and-drop interface using libraries like React DnD or React Beautiful DnD for visual garden bed planning + + +Offline Calendar Integration: + +iCal/Google Calendar Sync: Implement calendar export functionality using iCal format for seamless integration with existing calendar apps + + +Technical Implementation Considerations: + +Use React DnD for drag-and-drop functionality with touch support for mobile devices + +Integrate date-fns or moment.js for robust date calculations and timezone handling + +Consider Canvas API or SVG for more advanced garden visualization tools + +These features would transform the app from a basic plant tracker into a comprehensive garden management and planning platform. + + +View it live +Frontend: https://lillebrorgrodafinalproject.netlify.app/ + +Backend: https://garden-backend-r6x2.onrender.com \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 664410b5b9..17794c8aee 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,23 @@ - - - - - - Technigo React Vite Boiler Plate - - -
- - - + + + + + + + + + + + + Technigo React Vite Boiler Plate + + + +
+ + + + \ No newline at end of file diff --git a/frontend/netlify.toml b/frontend/netlify.toml new file mode 100644 index 0000000000..33a2c2c97f --- /dev/null +++ b/frontend/netlify.toml @@ -0,0 +1,23 @@ +[build] + publish = "dist" + command = "npm run build" + +[[headers]] + for = "*.js" + [headers.values] + Content-Type = "application/javascript" + +[[headers]] + for = "*.mjs" + [headers.values] + Content-Type = "application/javascript" + +[[headers]] + for = "*.jsx" + [headers.values] + Content-Type = "application/javascript" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 7b2747e949..2548db6c62 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,17 @@ "preview": "vite preview" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "^7.3.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hot-toast": "^2.6.0", + "react-icons": "^5.5.0", + "react-router": "^7.8.0", + "react-router-dom": "^7.8.0", + "styled-components": "^6.1.19", + "zustand": "^5.0.8" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/public/Broccoli.jpg b/frontend/public/Broccoli.jpg new file mode 100644 index 0000000000..4e3af1c067 Binary files /dev/null and b/frontend/public/Broccoli.jpg differ diff --git a/frontend/public/Eggplant.png b/frontend/public/Eggplant.png new file mode 100644 index 0000000000..655adcbada Binary files /dev/null and b/frontend/public/Eggplant.png differ diff --git a/frontend/public/Frog.jpg b/frontend/public/Frog.jpg new file mode 100644 index 0000000000..2ded40c297 Binary files /dev/null and b/frontend/public/Frog.jpg differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6e..4eb3e39edc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,87 @@ -export const App = () => { +import { useState, useEffect } from "react"; +import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from "react-router-dom"; +import { Toaster } from "react-hot-toast"; +import { FaUser } from "react-icons/fa6"; +import { FaSignOutAlt } from "react-icons/fa"; +import { ThemeProvider } from "styled-components"; +import GlobalStyle from "./styles/GlobalStyle" +import theme from "./styles/theme" +import { FooterIcon, Header } from "./styles/components/Navigation.styles"; +import LandingPage from "./pages/LandingPage"; +import SignUpPage from "./pages/SignupPage"; +import LoginPage from "./pages/LoginPage"; +import PlantPage from "./pages/PlantsPage"; +import MyPlantsPage from "./pages/MyPlantsPage"; +import AccountPage from "./pages/AccountPage"; +import Footer from "./components/Footer"; +//import "./index.css"; + + + +const App = () => { + const [token, setToken] = useState(""); + const [username, setUsername] = useState(""); + const navigate = useNavigate(); + + const handleLogout = () => { + setToken(""); + setUsername(""); + localStorage.removeItem("token"); + navigate("/"); + }; + + useEffect(() => { + const savedToken = localStorage.getItem("token") + console.log('Loading token from localStorage:', savedToken) + if (savedToken) { + setToken(savedToken); + } + }, []); + + console.log('Current token in App:', token) return ( - <> -

Welcome to Final Project!

- + + +
+ + {token && ( +
+ +
+ + + + {username || "Användare"} +
+ + + +
)} + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + 404 - Sidan hittades inte} /> + + + +
+
); }; + +const AppWrapper = () => ( + + + +); + +export default AppWrapper; diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000000..53dea9e08e --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,23 @@ + + + +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8080" + +const signup = (username, email, password) => + fetch(`${API_URL}/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, email, password }) + }).then(res => res.json()); + +const login = (email, password) => + fetch(`${API_URL}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }) + }).then(res => res.json()); + +export default { + signup, + login +} diff --git a/frontend/src/api/plants.js b/frontend/src/api/plants.js new file mode 100644 index 0000000000..5674708d0a --- /dev/null +++ b/frontend/src/api/plants.js @@ -0,0 +1,116 @@ + + +const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080"; + +const plantsAPI = { + // Förbättrad sökning med filter och API-fallback + searchPlants: async (searchParams = {}, token) => { + const { + search, + startMonth, + endMonth, + companion, + sunlight, + watering, + includeAPI = true + } = searchParams; + + // Bygg query parameters + const params = new URLSearchParams(); + + if (search) params.append('search', search); + if (startMonth) params.append('startMonth', startMonth); + if (endMonth) params.append('endMonth', endMonth); + if (companion) params.append('companion', companion); + if (sunlight) params.append('sunlight', sunlight); + if (watering) params.append('watering', watering); + if (includeAPI !== undefined) params.append('includeAPI', includeAPI); + + const url = `${BASE_URL}/plants${params.toString() ? '?' + params.toString() : ''}`; + + const res = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); + + if (!res.ok) throw new Error("Nätverksfel vid sökning"); + return await res.json(); + }, + + // Hämta specifik växt + getPlant: async (plantId, token) => { + const res = await fetch(`${BASE_URL}/plants/${plantId}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); + if (!res.ok) throw new Error("Kunde inte hämta växt"); + return await res.json(); + }, + + + + saveAPIPlantToGarden: async (apiPlant, notes, token) => { + const res = await fetch(`${BASE_URL}/plants/save-and-favorite`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ apiPlant, notes }) + }); + if (!res.ok) throw new Error("Kunde inte spara API-växt"); + return await res.json(); + }, + + saveExistingPlantAsFavorite: async (plantId, notes = "", token) => { + const res = await fetch(`${BASE_URL}/plants/saved`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ plantId, notes }) + }); + if (!res.ok) throw new Error("Kunde inte spara växt som favorit"); + return await res.json(); + }, + + + getMyGarden: async (token) => { + const res = await fetch(`${BASE_URL}/plants/saved`, { + headers: { + Authorization: `Bearer ${token}` + }, + }) + if (!res.ok) throw new Error("Kunde inte hämta sparade växter"); + return await res.json() + }, + + + // Uppdatera växt + updatePlant: async (plantId, updates, token) => { + const res = await fetch(`${BASE_URL}/plants/${plantId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(updates) + }); + if (!res.ok) throw new Error("Kunde inte uppdatera växt"); + return await res.json(); + }, + + // Ta bort växt + removePlantFromGarden: async (savedPlantId, token) => { + const res = await fetch(`${BASE_URL}/plants/${savedPlantId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}` + } + }); + if (!res.ok) throw new Error("Kunde inte ta bort växt"); + return await res.json(); + }, + +}; + +export default plantsAPI; \ No newline at end of file diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx new file mode 100644 index 0000000000..6dde4a457b --- /dev/null +++ b/frontend/src/components/Footer.jsx @@ -0,0 +1,54 @@ +import { useNavigate } from "react-router-dom" +import toast from "react-hot-toast" +import { FaHouse, FaMagnifyingGlass, FaSeedling } from "react-icons/fa6" +import { FooterStyled, FooterIcon } from "../styles/components/Navigation.styles" + +const Footer = () => { + const navigate = useNavigate() + const token = localStorage.getItem("token") + + const handleProtectedNavigation = (path, actionName) => { + if (token) { + navigate(path) + } else { + toast.error(`Du måste vara inloggad för att ${actionName}`, { icon: "🔒" }) + } + } + + const handleHomeNavigation = () => { + if (token) { + navigate("/account") + } else { + toast("Logga in för att se all funktionalitet", { + icon: "ℹ️" + }) + navigate("/") + } + } + + return ( + + handleHomeNavigation()} > + + + + < FooterIcon onClick={() => navigate("/search")} > + + + handleProtectedNavigation("/plants/saved", "se sparade växter")} + > + + + {/* handleProtectedNavigation("/events", "se kalendern")} + > + + */} + + ) +} + +export default Footer \ No newline at end of file diff --git a/frontend/src/components/PlantList.jsx b/frontend/src/components/PlantList.jsx new file mode 100644 index 0000000000..3a422c31c8 --- /dev/null +++ b/frontend/src/components/PlantList.jsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from "react" +import plantsAPI from "../api/plants" +import { + ErrorMessage, + PlantImage, + PlantContent, + PlantHeader, + PlantName, + ScientificName, + PlantFacts, + PlantNotes, + StyledP + +} from "../styles/stylecomponents/StyledComponentsLibrary" +import { BaseCard } from "../styles/components/Card.styles" +import { GridLayout } from "../styles/components/Layout.styles" +import { RemoveButton } from "../styles/components/Button.styles" + +import { FaTrash } from "react-icons/fa6" + + +const PlantList = ({ token }) => { + const [savedPlants, setSavedPlants] = useState([]) + const [error, setError] = useState("") + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchSavedPlants = async () => { + if (!token) { + setError("Du måste vara inloggad.") + setLoading(false) + return + } + + try { + const data = await plantsAPI.getMyGarden(token) + setSavedPlants(data.savedPlants || []) + setError("") + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + if (token) { + fetchSavedPlants() + } + }, [token]) + + const handleRemove = async (savedPlantId) => { + if (!window.confirm(`Är du säker på att du vill ta bort ${plantName} från din trädgård?`)) return + + try { + await plantsAPI.removePlantFromGarden(savedPlantId, token) + setSavedPlants(prev => + prev.filter(entry => entry._id !== savedPlantId) + ) + + //Add Success state here + } catch (err) { + setError("Kunde inte ta bort växten: " + err.message) + } + } + + if (loading) { + return ( + + Laddar din trädgård.. 🌱 + + ) + } + + if (error) { + return ( + {error} + + ) + } + + return ( +
+ {savedPlants.length === 0 ? ( +
+ Din trädgård är tom ännu! 🌱 + Gå till sökfunktionen för att lägga till växter. +
+ ) : ( + + {savedPlants.map((entry) => ( + + { + e.target.src = "/Frog.jpg" + }} + /> + + + + {entry.plant?.swedishName || entry.plant?.commonName || "Okänd växt"} + + {entry.plant?.scientificName && ( + {entry.plant.scientificName} + )} + + + + {/* ✅ FIX: Säker rendering av array-data */} + {entry.plant?.description && ( +

Beskrivning: {entry.plant.description}

+ )} + + {entry.plant?.watering && entry.plant.watering.length > 0 && ( +

Vattning: { + Array.isArray(entry.plant.watering) + ? entry.plant.watering.join(', ') + : entry.plant.watering + }

+ )} + + {entry.plant?.sunlight && entry.plant.sunlight.length > 0 && ( +

Ljus: { + Array.isArray(entry.plant.sunlight) + ? entry.plant.sunlight.join(', ') + : entry.plant.sunlight + }

+ )} + + {/* ✅ TILLÄGG: Visa när växten sparades */} +

Sparad: {new Date(entry.savedAt).toLocaleDateString('sv-SE')}

+ + {/* ✅ TILLÄGG: Visa källa */} + {entry.plant?.source && ( +

Källa: { + entry.plant.source === 'api' ? 'Extern databas' : 'Lokal databas' + }

+ )} +
+ + {entry.notes && entry.notes.trim() && ( + + Mina anteckningar: +

{entry.notes}

+
+ )} + + handleRemove(entry._id, entry.plant?.swedishName || entry.plant?.commonName)} + disabled={loading} + style={{ + background: 'none', + border: 'none', + color: '#dc3545', + cursor: 'pointer', + fontSize: '1.2rem', + padding: '8px', + borderRadius: '4px', + transition: 'background-color 0.2s', + marginTop: '10px' + }} + onMouseEnter={(e) => e.target.style.backgroundColor = '#f8d7da'} + onMouseLeave={(e) => e.target.style.backgroundColor = 'transparent'} + title="Ta bort från trädgård" + > + + +
+
+ ))} +
+ )} +
+ ) +} + +export default PlantList \ No newline at end of file diff --git a/frontend/src/components/calender.jsx b/frontend/src/components/calender.jsx new file mode 100644 index 0000000000..2d8068b2fb --- /dev/null +++ b/frontend/src/components/calender.jsx @@ -0,0 +1,401 @@ +import { useEffect, useState } from 'react' +import useCalenderStore from '../store/useCalenderStore' +import toast from 'react-hot-toast' +import { + CalendarContainer, CalendarMainContent, CalendarSection, TaskSection, + CalendarHeader, CalendarNav, + Weekdays, DaysGrid, DayCell, + WeekView, WeekDayColumn, + TaskSectionHeader, TaskList, TaskItem, TaskCheckbox, TaskContent, + TaskText, TaskTime, TaskDate, TaskActions, TaskActionButton, + EmptyTaskMessage, + EventPopup, EventButton, TimeInputs, CloseButton +} from '../styles/CalenderStyle' + +const BASE_URL = import.meta.env.VITE_API_URL + +const Calender = ({ token }) => { + const daysOfWeek = ["Mån", "Tis", "Ons", "Tors", "Fre", "Lör", "Sön"] + const monthsOfYear = ["Januari", "Februari", "Mars", "April", "Maj", "Juni", "Juli", "Augusti", "September", "Oktober", "November", "December"] + const currentDate = new Date() + const [currentMonth, setCurrentMonth] = useState(currentDate.getMonth()) + const [currentYear, setCurrentYear] = useState(currentDate.getFullYear()) + const [selectedDate, setSelectedDate] = useState(currentDate) + const [showEventPopup, setShowEventPopup] = useState(false) + const [eventTime, setEventTime] = useState({ hours: "00", minutes: "00" }) + const [eventText, setEventText] = useState("") + const [editingEvent, setEditingEvent] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [view, setView] = useState("month") + + const { events, addEvent, updateEvent, deleteEvent } = useCalenderStore() + + useEffect(() => { + const fetchEvents = async () => { + if (!token) return + try { + setLoading(true) + setError(null) + const res = await fetch(`${BASE_URL}/events`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + }) + if (!res.ok) throw new Error("Kunde inte hämta events") + const data = await res.json() + data.forEach((ev) => addEvent(ev)) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + fetchEvents() + }, [token, addEvent]) + + const saveEvent = async (event, isEdit = false) => { + try { + const url = isEdit ? `${BASE_URL}/events/${event.id}` : `${BASE_URL}/events` + const method = isEdit ? "PUT" : "POST" + + const res = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(event), + }) + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})) + throw new Error(errorData.message || `HTTP ${res.status}`) + } + + const saved = await res.json() + if (isEdit) { + updateEvent(saved) + } else { + addEvent(saved) + } + } catch (err) { + console.error(err) + alert(`Fel vid sparande: ${err.message}`) + } + } + + const removeEvent = async (id) => { + try { + const res = await fetch(`${BASE_URL}/events/${id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + }) + if (!res.ok) throw new Error("Kunde inte ta bort event") + await res.json() + deleteEvent(id) + } catch (err) { + console.error(err) + alert(err.message) + } + } + + const toggleTodo = async (event) => { + const updated = { ...event, done: !event.done } + try { + const res = await fetch(`${BASE_URL}/events/${event.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(updated), + }) + if (!res.ok) throw new Error("Kunde inte uppdatera todo") + const saved = await res.json() + updateEvent(saved) + } catch (err) { + console.error(err) + alert(err.message) + } + } + + // Hjälpfunktion för att få veckodagsnamn på svenska + const getWeekdayName = (date) => { + const weekdays = ["Söndag", "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag"] + return weekdays[date.getDay()] + } + + const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate() + const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay() - 1 + + const startOfWeek = (date) => { + const d = new Date(date) + const day = d.getDay() || 7 + const diff = d.getDate() - day + 1 + return new Date(d.setDate(diff)) + } + + const currentWeekStart = startOfWeek(new Date()) + const daysOfWeekDates = Array.from({ length: 7 }).map((_, i) => { + const d = new Date(currentWeekStart) + d.setDate(currentWeekStart.getDate() + i) + return d + }) + + const prevMonth = () => { + setCurrentMonth((prevMonth) => (prevMonth === 0 ? 11 : prevMonth - 1)) + setCurrentYear((prevYear) => (currentMonth === 0 ? prevYear - 1 : prevYear)) + } + + const nextMonth = () => { + setCurrentMonth((prevMonth) => (prevMonth === 11 ? 0 : prevMonth + 1)) + setCurrentYear((prevYear) => (currentMonth === 11 ? prevYear + 1 : prevYear)) + } + + const isSameDay = (date1, date2) => + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + + const handleDateClick = (dayOrDate) => { + const clickedDate = + typeof dayOrDate === "number" + ? new Date(currentYear, currentMonth, dayOrDate) + : dayOrDate + + const today = new Date() + if (clickedDate >= today || isSameDay(clickedDate, today)) { + setSelectedDate(clickedDate) + setShowEventPopup(true) + setEventTime({ hours: "00", minutes: "00" }) + setEventText("") + setEditingEvent(null) + } + } + + const handleEventSubmit = async () => { + const newEvent = { + id: editingEvent ? editingEvent.id : undefined, + date: selectedDate.toISOString(), + time: `${eventTime.hours.padStart(2, "0")}:${eventTime.minutes.padStart(2, "0")}`, + text: eventText, + done: editingEvent ? editingEvent.done : false + } + + try { + await saveEvent(newEvent, !!editingEvent) + setEventTime({ hours: "00", minutes: "00" }) + setEventText("") + setShowEventPopup(false) + setEditingEvent(null) + } catch (err) { + console.error('Error submitting event:', err) + toast.info("Fel vid sparande av händelse") + } + } + + const handleEditEvent = (event) => { + setSelectedDate(new Date(event.date)) + setEventTime({ + hours: event.time.split(":")[0], + minutes: event.time.split(":")[1] + }) + setEventText(event.text) + setEditingEvent(event) + setShowEventPopup(true) + } + + const handleTimeChange = (e) => { + const { name, value } = e.target + setEventTime((prev) => ({ ...prev, [name]: value.padStart(2, "0") })) + } + + // Sortera och filtrera uppgifter för att-göra-sektionen + const getAllTasks = () => { + return events + .sort((a, b) => { + // Sortera först på datum, sedan på tid + const dateA = new Date(a.date) + const dateB = new Date(b.date) + if (dateA.getTime() !== dateB.getTime()) { + return dateA - dateB + } + return a.time.localeCompare(b.time) + }) + } + + const formatTaskDate = (dateString) => { + const date = new Date(dateString) + const today = new Date() + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + + if (isSameDay(date, today)) { + return "Idag" + } else if (isSameDay(date, tomorrow)) { + return "Imorgon" + } else { + return `${date.getDate()}/${date.getMonth() + 1}` + } + } + + return ( + + +

Kalender

+
+ {monthsOfYear[currentMonth]} {currentYear} + + + + + + +
+
+ + {loading &&

Laddar...

} + {error &&

{error}

} + + + + {view === "month" && ( + <> + + {daysOfWeek.map((day) => ( + {day} + ))} + + + {[...Array(firstDayOfMonth).keys()].map((_, i) =>
)} + {[...Array(daysInMonth).keys()].map((day) => ( + handleDateClick(day + 1)} + > + {day + 1} + + ))} + + + )} + + {view === "week" && ( + + {daysOfWeekDates.map((date, idx) => ( + handleDateClick(date)} + > +

{/*{getWeekdayName(date)}*/} {date.getDate()}{/*{date.getMonth() + 1}*/}

+
+ {/*} {events.filter((e) => isSameDay(new Date(e.date), date)).length} uppgifter*/} +
+
+ ))} +
+ )} + + + + Att göra + + {getAllTasks().length > 0 ? ( + getAllTasks().map((task, index) => ( + + toggleTodo(task)} + /> + + {task.text} + {task.time} + {formatTaskDate(task.date)} + + + handleEditEvent(task)} + title="Redigera" + > + ✏️ + + removeEvent(task.id)} + title="Ta bort" + > + 🗑 + + + + )) + ) : ( + Inga uppgifter än + )} + + + + + {showEventPopup && ( + + setShowEventPopup(false)}>✖ +

{editingEvent ? "Redigera händelse" : "Ny händelse"}

+ + + : + + +