Initial commit: React + Vite plant app with footer navigation

- Wiki page with 8 plant entries, search, and category/difficulty filters
- Calendar page with monthly grid, task scheduling, and upcoming tasks
- My Plants page with personal collection, health tracking, and add/edit/remove
- Footer navigation with Wiki, Calendar, and My Plants tabs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
NillanHendrix
2026-04-14 07:10:40 +02:00
commit ce2d03f7d6
26 changed files with 4443 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "plant-app (Vite dev)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
},
{
"name": "plant-app (Vite preview)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "preview"],
"port": 4173
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(npm create:*)",
"Bash(cmd /c \"dir /a C:\\\\Users\\\\Nille\\\\code\\\\web\\\\plant-app\")",
"Bash(cmd /c \"dir /a /s C:\\\\Users\\\\Nille\\\\code\\\\web\\\\plant-app 2>&1\")",
"Bash(powershell -Command \"Get-ChildItem -Path 'C:\\\\Users\\\\Nille\\\\code\\\\web\\\\plant-app' -Recurse | Select-Object FullName\")",
"Bash(powershell -Command ':*)",
"Bash(powershell -Command \"Get-ChildItem -Path 'C:\\\\Users\\\\Nille\\\\code\\\\web\\\\plant-app' | Select-Object Name\")",
"Bash(npm install:*)",
"mcp__Claude_Preview__preview_start"
]
}
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>plant-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2633
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "plant-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.4"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+6
View File
@@ -0,0 +1,6 @@
.app-main {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
+25
View File
@@ -0,0 +1,25 @@
import { useState } from 'react'
import FooterNav from './components/FooterNav'
import Wiki from './pages/Wiki'
import Calendar from './pages/Calendar'
import MyPlants from './pages/MyPlants'
import './App.css'
const pages = {
wiki: <Wiki />,
calendar: <Calendar />,
myplants: <MyPlants />,
}
export default function App() {
const [page, setPage] = useState('myplants')
return (
<>
<main className="app-main">
{pages[page]}
</main>
<FooterNav active={page} onNavigate={setPage} />
</>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+56
View File
@@ -0,0 +1,56 @@
.footer-nav {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 1126px;
display: flex;
border-top: 1px solid var(--border);
background: var(--bg);
z-index: 50;
padding-bottom: env(safe-area-inset-bottom);
}
.footer-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px 8px 12px;
background: transparent;
border: none;
cursor: pointer;
color: var(--text);
transition: color 0.15s, background 0.15s;
font-family: inherit;
}
.footer-tab:hover {
color: var(--accent);
background: var(--accent-bg);
}
.footer-tab.active {
color: var(--accent);
}
.footer-tab-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.footer-tab-icon svg {
width: 22px;
height: 22px;
}
.footer-tab-label {
font-size: 11px;
letter-spacing: 0.2px;
}
+56
View File
@@ -0,0 +1,56 @@
import './FooterNav.css'
const tabs = [
{
id: 'wiki',
label: 'Wiki',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
),
},
{
id: 'calendar',
label: 'Calendar',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
},
{
id: 'myplants',
label: 'My Plants',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22V12" />
<path d="M5 12C5 7 8 4 12 4c4 0 7 3 7 8" />
<path d="M5 12c0-3 2-5 5-5" />
<path d="M19 12c0-3-2-5-5-5" />
</svg>
),
},
]
export default function FooterNav({ active, onNavigate }) {
return (
<nav className="footer-nav" role="navigation" aria-label="Main navigation">
{tabs.map((tab) => (
<button
key={tab.id}
className={`footer-tab ${active === tab.id ? 'active' : ''}`}
onClick={() => onNavigate(tab.id)}
aria-current={active === tab.id ? 'page' : undefined}
>
<span className="footer-tab-icon">{tab.icon}</span>
<span className="footer-tab-label">{tab.label}</span>
</button>
))}
</nav>
)
}
+111
View File
@@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+263
View File
@@ -0,0 +1,263 @@
.cal-page {
padding: 24px 20px 100px;
max-width: 680px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
text-align: left;
}
.cal-header {
margin-bottom: 20px;
}
.cal-header h1 {
margin: 0 0 6px;
font-size: 28px;
letter-spacing: -0.5px;
}
.cal-header p {
color: var(--text);
font-size: 15px;
}
.cal-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.cal-month-label {
font-size: 17px;
font-weight: 500;
color: var(--text-h);
}
.cal-nav-btn {
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 12px;
font-size: 18px;
cursor: pointer;
color: var(--text-h);
transition: background 0.15s;
}
.cal-nav-btn:hover {
background: var(--accent-bg);
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 24px;
}
.cal-day-name {
text-align: center;
font-size: 12px;
color: var(--text);
padding: 4px 0 6px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.cal-day {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 2px 4px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
background: transparent;
transition: background 0.15s, border-color 0.15s;
min-height: 44px;
gap: 3px;
}
.cal-day:hover {
background: var(--accent-bg);
}
.cal-day.today .cal-day-num {
background: var(--accent);
color: #fff;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.cal-day.selected {
border-color: var(--accent-border);
background: var(--accent-bg);
}
.cal-day-num {
font-size: 13px;
color: var(--text-h);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.cal-dots {
display: flex;
gap: 2px;
}
.cal-dot {
width: 5px;
height: 5px;
border-radius: 50%;
}
.dot-water { background: #3b82f6; }
.dot-fertilize { background: #22c55e; }
.dot-repot { background: #a855f7; }
.dot-prune { background: #f59e0b; }
.cal-dot.done { opacity: 0.3; }
.cal-selected-tasks,
.cal-upcoming {
margin-bottom: 24px;
}
.cal-selected-tasks h2,
.cal-upcoming h2 {
font-size: 17px;
margin: 0 0 12px;
}
.cal-empty {
color: var(--text);
font-size: 14px;
margin: 0 0 12px;
}
.task-list {
list-style: none;
padding: 0;
margin: 0 0 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.task-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
color: var(--text-h);
}
.task-item.done {
opacity: 0.5;
}
.task-item.done .task-label {
text-decoration: line-through;
}
.task-check {
background: transparent;
border: none;
cursor: pointer;
font-size: 16px;
color: var(--accent);
padding: 0;
width: 20px;
flex-shrink: 0;
}
.task-icon {
font-size: 16px;
flex-shrink: 0;
}
.task-label {
flex: 1;
}
.task-date {
font-size: 12px;
color: var(--text);
flex-shrink: 0;
}
.task-remove {
background: transparent;
border: none;
cursor: pointer;
font-size: 18px;
color: var(--text);
padding: 0;
line-height: 1;
flex-shrink: 0;
transition: color 0.15s;
}
.task-remove:hover {
color: #ef4444;
}
.add-task-form {
display: flex;
gap: 8px;
}
.task-type-select {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-h);
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
}
.task-input {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-h);
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.task-input:focus {
border-color: var(--accent);
}
.task-add-btn {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: var(--accent);
color: #fff;
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s;
}
.task-add-btn:hover {
opacity: 0.85;
}
+191
View File
@@ -0,0 +1,191 @@
import { useState } from 'react'
import './Calendar.css'
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
]
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
const today = new Date()
const initialTasks = [
{ id: 1, date: fmt(today), type: 'water', label: 'Water Monstera' },
{ id: 2, date: fmt(addDays(today, 1)), type: 'water', label: 'Water Pothos' },
{ id: 3, date: fmt(addDays(today, 2)), type: 'fertilize', label: 'Fertilize Snake Plant' },
{ id: 4, date: fmt(addDays(today, 3)), type: 'water', label: 'Water Peace Lily' },
{ id: 5, date: fmt(addDays(today, 5)), type: 'repot', label: 'Repot Fiddle Leaf Fig' },
{ id: 6, date: fmt(addDays(today, 7)), type: 'water', label: 'Water Echeveria' },
{ id: 7, date: fmt(addDays(today, 10)), type: 'prune', label: 'Prune Basil' },
{ id: 8, date: fmt(addDays(today, 14)), type: 'fertilize', label: 'Fertilize Monstera' },
]
function fmt(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function addDays(d, n) {
const r = new Date(d)
r.setDate(r.getDate() + n)
return r
}
function getDaysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate()
}
function getFirstDay(year, month) {
return new Date(year, month, 1).getDay()
}
const typeIcon = { water: '💧', fertilize: '🌿', repot: '🪴', prune: '✂️' }
const typeLabel = { water: 'Water', fertilize: 'Fertilize', repot: 'Repot', prune: 'Prune' }
export default function Calendar() {
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [tasks, setTasks] = useState(initialTasks)
const [selected, setSelected] = useState(fmt(today))
const [newLabel, setNewLabel] = useState('')
const [newType, setNewType] = useState('water')
const [nextId, setNextId] = useState(initialTasks.length + 1)
const daysInMonth = getDaysInMonth(year, month)
const firstDay = getFirstDay(year, month)
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1) }
else setMonth(m => m - 1)
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
function addTask(e) {
e.preventDefault()
if (!newLabel.trim()) return
setTasks(prev => [...prev, { id: nextId, date: selected, type: newType, label: newLabel.trim() }])
setNextId(n => n + 1)
setNewLabel('')
}
function removeTask(id) {
setTasks(prev => prev.filter(t => t.id !== id))
}
function toggleDone(id) {
setTasks(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t))
}
const tasksForDay = (dateStr) => tasks.filter(t => t.date === dateStr)
const selectedTasks = tasksForDay(selected)
const todayStr = fmt(today)
const upcoming = tasks
.filter(t => t.date >= todayStr && !t.done)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 5)
return (
<div className="cal-page">
<div className="cal-header">
<h1>Care Calendar</h1>
<p>Schedule and track your plant care tasks</p>
</div>
<div className="cal-nav">
<button className="cal-nav-btn" onClick={prevMonth}></button>
<span className="cal-month-label">{MONTHS[month]} {year}</span>
<button className="cal-nav-btn" onClick={nextMonth}></button>
</div>
<div className="cal-grid">
{DAYS.map(d => <div key={d} className="cal-day-name">{d}</div>)}
{Array.from({ length: firstDay }).map((_, i) => <div key={`empty-${i}`} />)}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const dayTasks = tasksForDay(dateStr)
const isToday = dateStr === todayStr
const isSelected = dateStr === selected
return (
<button
key={day}
className={`cal-day ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''}`}
onClick={() => setSelected(dateStr)}
>
<span className="cal-day-num">{day}</span>
{dayTasks.length > 0 && (
<span className="cal-dots">
{dayTasks.slice(0, 3).map(t => (
<span key={t.id} className={`cal-dot dot-${t.type} ${t.done ? 'done' : ''}`} />
))}
</span>
)}
</button>
)
})}
</div>
<div className="cal-selected-tasks">
<h2>
{selected === todayStr ? 'Today' : selected} tasks
</h2>
{selectedTasks.length === 0 && (
<p className="cal-empty">No tasks add one below.</p>
)}
<ul className="task-list">
{selectedTasks.map(t => (
<li key={t.id} className={`task-item ${t.done ? 'done' : ''}`}>
<button className="task-check" onClick={() => toggleDone(t.id)}>
{t.done ? '✓' : '○'}
</button>
<span className="task-icon">{typeIcon[t.type]}</span>
<span className="task-label">{t.label}</span>
<button className="task-remove" onClick={() => removeTask(t.id)} aria-label="Remove">×</button>
</li>
))}
</ul>
<form className="add-task-form" onSubmit={addTask}>
<select
className="task-type-select"
value={newType}
onChange={e => setNewType(e.target.value)}
>
{Object.entries(typeLabel).map(([val, lbl]) => (
<option key={val} value={val}>{typeIcon[val]} {lbl}</option>
))}
</select>
<input
className="task-input"
type="text"
placeholder="Add task…"
value={newLabel}
onChange={e => setNewLabel(e.target.value)}
/>
<button className="task-add-btn" type="submit">Add</button>
</form>
</div>
<div className="cal-upcoming">
<h2>Upcoming</h2>
{upcoming.length === 0 ? (
<p className="cal-empty">All caught up!</p>
) : (
<ul className="task-list">
{upcoming.map(t => (
<li key={t.id} className="task-item">
<span className="task-icon">{typeIcon[t.type]}</span>
<span className="task-label">{t.label}</span>
<span className="task-date">{t.date}</span>
</li>
))}
</ul>
)}
</div>
</div>
)
}
+292
View File
@@ -0,0 +1,292 @@
.myplants {
padding: 24px 20px 100px;
max-width: 680px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
text-align: left;
}
.myplants-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
gap: 12px;
}
.myplants-header h1 {
margin: 0 0 6px;
font-size: 28px;
letter-spacing: -0.5px;
}
.myplants-header p {
color: var(--text);
font-size: 15px;
margin: 0;
}
.add-plant-btn {
padding: 9px 16px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s;
}
.add-plant-btn:hover {
opacity: 0.85;
}
.plants-empty {
text-align: center;
padding: 60px 20px;
color: var(--text);
}
.empty-emoji {
font-size: 48px;
display: block;
margin-bottom: 12px;
}
.plants-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.plant-card {
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.plant-card-header {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--text-h);
font-size: 15px;
transition: background 0.15s;
}
.plant-card-header:hover {
background: var(--accent-bg);
}
.plant-emoji {
font-size: 24px;
flex-shrink: 0;
}
.plant-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.plant-species {
font-size: 12px;
color: var(--text);
font-style: italic;
}
.health-badge {
font-size: 12px;
padding: 3px 9px;
border-radius: 999px;
flex-shrink: 0;
}
.health-thriving { background: #d1fae5; color: #065f46; }
.health-good { background: #dbeafe; color: #1e40af; }
.health-needs-care { background: #fef9c3; color: #854d0e; }
.health-critical { background: #fee2e2; color: #991b1b; }
@media (prefers-color-scheme: dark) {
.health-thriving { background: rgba(6,95,70,0.3); color: #6ee7b7; }
.health-good { background: rgba(30,64,175,0.3); color: #93c5fd; }
.health-needs-care { background: rgba(133,77,14,0.3); color: #fde68a; }
.health-critical { background: rgba(153,27,27,0.3); color: #fca5a5; }
}
.plant-chevron {
font-size: 11px;
color: var(--text);
flex-shrink: 0;
}
.plant-card-body {
padding: 12px 16px 16px;
border-top: 1px solid var(--border);
}
.plant-meta {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.meta-item {
flex: 1;
background: var(--code-bg);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text);
}
.meta-value {
font-size: 14px;
color: var(--text-h);
}
.plant-notes {
font-size: 14px;
color: var(--text);
line-height: 1.5;
margin: 0 0 12px;
font-style: italic;
}
.plant-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.action-btn {
padding: 7px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-h);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.action-btn:hover {
background: var(--accent-bg);
border-color: var(--accent-border);
}
.water-btn {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.water-btn:hover {
opacity: 0.85;
background: var(--accent);
}
.remove-btn:hover {
background: #fee2e2;
border-color: #fca5a5;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.remove-btn:hover {
background: rgba(153,27,27,0.3);
border-color: #fca5a5;
color: #fca5a5;
}
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 100;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
}
.modal {
background: var(--bg);
border-radius: 20px 20px 0 0;
padding: 24px 20px 40px;
width: 100%;
max-width: 560px;
max-height: 90svh;
overflow-y: auto;
}
.modal h2 {
margin: 0 0 20px;
font-size: 20px;
}
.plant-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.plant-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.plant-form input,
.plant-form select,
.plant-form textarea {
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-h);
font-size: 15px;
outline: none;
transition: border-color 0.2s;
font-family: inherit;
}
.plant-form input:focus,
.plant-form select:focus,
.plant-form textarea:focus {
border-color: var(--accent);
}
.form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 4px;
}
+236
View File
@@ -0,0 +1,236 @@
import { useState } from 'react'
import './MyPlants.css'
const healthOptions = ['Thriving', 'Good', 'Needs care', 'Critical']
const locationOptions = ['Living room', 'Bedroom', 'Kitchen', 'Bathroom', 'Office', 'Balcony', 'Other']
const initialPlants = [
{
id: 1,
name: 'Monstera',
species: 'Monstera deliciosa',
emoji: '🌿',
location: 'Living room',
health: 'Thriving',
lastWatered: '2026-04-11',
notes: 'New leaf unfurling — so excited!',
},
{
id: 2,
name: 'Snakey',
species: 'Sansevieria trifasciata',
emoji: '🐍',
location: 'Bedroom',
health: 'Good',
lastWatered: '2026-04-01',
notes: 'Barely needs water. Rock solid.',
},
{
id: 3,
name: 'Basil bunch',
species: 'Ocimum basilicum',
emoji: '🌱',
location: 'Kitchen',
health: 'Needs care',
lastWatered: '2026-04-12',
notes: 'Getting a bit leggy, needs pruning.',
},
]
const healthColor = {
Thriving: 'health-thriving',
Good: 'health-good',
'Needs care': 'health-needs-care',
Critical: 'health-critical',
}
function fmt(d) {
const date = new Date(d)
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
export default function MyPlants() {
const [plants, setPlants] = useState(initialPlants)
const [nextId, setNextId] = useState(initialPlants.length + 1)
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState(null)
const [expanded, setExpanded] = useState(null)
const [form, setForm] = useState({
name: '', species: '', emoji: '🪴', location: 'Living room',
health: 'Good', lastWatered: '', notes: '',
})
function openAdd() {
setForm({ name: '', species: '', emoji: '🪴', location: 'Living room', health: 'Good', lastWatered: '', notes: '' })
setEditId(null)
setShowForm(true)
}
function openEdit(plant) {
setForm({ ...plant })
setEditId(plant.id)
setShowForm(true)
}
function handleSubmit(e) {
e.preventDefault()
if (!form.name.trim()) return
if (editId !== null) {
setPlants(prev => prev.map(p => p.id === editId ? { ...form, id: editId } : p))
} else {
setPlants(prev => [...prev, { ...form, id: nextId }])
setNextId(n => n + 1)
}
setShowForm(false)
setEditId(null)
}
function removePlant(id) {
setPlants(prev => prev.filter(p => p.id !== id))
if (expanded === id) setExpanded(null)
}
function waterNow(id) {
const today = new Date().toISOString().split('T')[0]
setPlants(prev => prev.map(p => p.id === id ? { ...p, lastWatered: today, health: p.health === 'Critical' ? 'Needs care' : p.health } : p))
}
return (
<div className="myplants">
<div className="myplants-header">
<div>
<h1>My Plants</h1>
<p>{plants.length} plant{plants.length !== 1 ? 's' : ''} in your collection</p>
</div>
<button className="add-plant-btn" onClick={openAdd}>+ Add plant</button>
</div>
{plants.length === 0 && (
<div className="plants-empty">
<span className="empty-emoji">🌱</span>
<p>Your collection is empty. Add your first plant!</p>
</div>
)}
<ul className="plants-list">
{plants.map(plant => (
<li key={plant.id} className="plant-card">
<button
className="plant-card-header"
onClick={() => setExpanded(expanded === plant.id ? null : plant.id)}
aria-expanded={expanded === plant.id}
>
<span className="plant-emoji">{plant.emoji}</span>
<div className="plant-info">
<strong>{plant.name}</strong>
<span className="plant-species">{plant.species}</span>
</div>
<span className={`health-badge ${healthColor[plant.health]}`}>{plant.health}</span>
<span className="plant-chevron">{expanded === plant.id ? '▲' : '▼'}</span>
</button>
{expanded === plant.id && (
<div className="plant-card-body">
<div className="plant-meta">
<div className="meta-item">
<span className="meta-label">Location</span>
<span className="meta-value">📍 {plant.location}</span>
</div>
<div className="meta-item">
<span className="meta-label">Last watered</span>
<span className="meta-value">💧 {plant.lastWatered ? fmt(plant.lastWatered) : '—'}</span>
</div>
</div>
{plant.notes && (
<p className="plant-notes">{plant.notes}</p>
)}
<div className="plant-actions">
<button className="action-btn water-btn" onClick={() => waterNow(plant.id)}>
💧 Water now
</button>
<button className="action-btn edit-btn" onClick={() => openEdit(plant)}>
Edit
</button>
<button className="action-btn remove-btn" onClick={() => removePlant(plant.id)}>
Remove
</button>
</div>
</div>
)}
</li>
))}
</ul>
{showForm && (
<div className="modal-overlay" onClick={() => setShowForm(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>{editId !== null ? 'Edit plant' : 'Add plant'}</h2>
<form onSubmit={handleSubmit} className="plant-form">
<label>
Emoji
<input
type="text"
value={form.emoji}
onChange={e => setForm(f => ({ ...f, emoji: e.target.value }))}
maxLength={2}
/>
</label>
<label>
Name *
<input
type="text"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="e.g. My Monstera"
required
/>
</label>
<label>
Species
<input
type="text"
value={form.species}
onChange={e => setForm(f => ({ ...f, species: e.target.value }))}
placeholder="e.g. Monstera deliciosa"
/>
</label>
<label>
Location
<select value={form.location} onChange={e => setForm(f => ({ ...f, location: e.target.value }))}>
{locationOptions.map(l => <option key={l}>{l}</option>)}
</select>
</label>
<label>
Health
<select value={form.health} onChange={e => setForm(f => ({ ...f, health: e.target.value }))}>
{healthOptions.map(h => <option key={h}>{h}</option>)}
</select>
</label>
<label>
Last watered
<input
type="date"
value={form.lastWatered}
onChange={e => setForm(f => ({ ...f, lastWatered: e.target.value }))}
/>
</label>
<label>
Notes
<textarea
value={form.notes}
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
placeholder="Observations, reminders…"
rows={3}
/>
</label>
<div className="form-actions">
<button type="button" className="action-btn" onClick={() => setShowForm(false)}>Cancel</button>
<button type="submit" className="action-btn water-btn">{editId !== null ? 'Save' : 'Add'}</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+197
View File
@@ -0,0 +1,197 @@
.wiki {
padding: 24px 20px 100px;
max-width: 680px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
text-align: left;
}
.wiki-header {
margin-bottom: 24px;
}
.wiki-header h1 {
margin: 0 0 6px;
font-size: 28px;
letter-spacing: -0.5px;
}
.wiki-header p {
color: var(--text);
font-size: 15px;
}
.wiki-filters {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.wiki-search {
width: 100%;
box-sizing: border-box;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-h);
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.wiki-search:focus {
border-color: var(--accent);
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 5px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.chip:hover {
border-color: var(--accent);
color: var(--accent);
}
.chip.active {
background: var(--accent-bg);
border-color: var(--accent-border);
color: var(--accent);
}
.wiki-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.wiki-card {
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.wiki-card-header {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--text-h);
font-size: 15px;
transition: background 0.15s;
}
.wiki-card-header:hover {
background: var(--accent-bg);
}
.wiki-emoji {
font-size: 22px;
flex-shrink: 0;
}
.wiki-card-title {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.wiki-category {
font-size: 12px;
color: var(--text);
}
.difficulty {
font-size: 12px;
padding: 3px 8px;
border-radius: 999px;
flex-shrink: 0;
}
.diff-very-easy { background: #d1fae5; color: #065f46; }
.diff-easy { background: #dcfce7; color: #166534; }
.diff-moderate { background: #fef9c3; color: #854d0e; }
.diff-hard { background: #fee2e2; color: #991b1b; }
@media (prefers-color-scheme: dark) {
.diff-very-easy { background: rgba(6,95,70,0.3); color: #6ee7b7; }
.diff-easy { background: rgba(22,101,52,0.3); color: #86efac; }
.diff-moderate { background: rgba(133,77,14,0.3); color: #fde68a; }
.diff-hard { background: rgba(153,27,27,0.3); color: #fca5a5; }
}
.wiki-chevron {
font-size: 11px;
color: var(--text);
flex-shrink: 0;
}
.wiki-card-body {
padding: 0 16px 16px;
border-top: 1px solid var(--border);
}
.wiki-description {
margin: 12px 0;
font-size: 14px;
line-height: 1.6;
color: var(--text);
}
.wiki-stats {
display: flex;
gap: 12px;
}
.stat {
flex: 1;
background: var(--code-bg);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text);
}
.stat-value {
font-size: 14px;
color: var(--text-h);
font-weight: 500;
}
.wiki-empty {
text-align: center;
color: var(--text);
margin-top: 48px;
}
+193
View File
@@ -0,0 +1,193 @@
const plants = [
{
id: 1,
name: 'Monstera Deliciosa',
category: 'Tropical',
light: 'Bright indirect',
water: 'Every 12 weeks',
difficulty: 'Easy',
description:
'Known for its iconic split leaves, Monstera is a fast-growing tropical plant that thrives in warm, humid conditions. Perfect for adding a jungle vibe to any room.',
emoji: '🌿',
},
{
id: 2,
name: 'Echeveria',
category: 'Succulent',
light: 'Full sun',
water: 'Every 23 weeks',
difficulty: 'Easy',
description:
'A rosette-forming succulent with thick, fleshy leaves. Extremely drought-tolerant and thrives on neglect. Ideal for beginners and sunny windowsills.',
emoji: '🪴',
},
{
id: 3,
name: 'Peace Lily',
category: 'Tropical',
light: 'Low to medium',
water: 'Weekly',
difficulty: 'Easy',
description:
'One of the few flowering plants that tolerates low light. Peace Lily also acts as an air purifier, removing toxins from indoor air.',
emoji: '🌸',
},
{
id: 4,
name: 'Basil',
category: 'Herb',
light: 'Full sun',
water: 'Every 23 days',
difficulty: 'Moderate',
description:
'A culinary staple with fragrant leaves. Basil loves warmth and direct sunlight. Pinch flowers to keep leaves coming. Best grown on a kitchen windowsill.',
emoji: '🌱',
},
{
id: 5,
name: 'Snake Plant',
category: 'Succulent',
light: 'Low to bright indirect',
water: 'Every 26 weeks',
difficulty: 'Very easy',
description:
'Nearly indestructible, the Snake Plant tolerates neglect, low light, and irregular watering. Its upright leaves add architectural interest to any space.',
emoji: '🐍',
},
{
id: 6,
name: 'Fiddle Leaf Fig',
category: 'Tropical',
light: 'Bright indirect',
water: 'Weekly',
difficulty: 'Hard',
description:
'A dramatic statement plant with large, violin-shaped leaves. Fiddle Leaf Figs are sensitive to drafts and overwatering, but reward patience with stunning growth.',
emoji: '🎻',
},
{
id: 7,
name: 'Lavender',
category: 'Herb',
light: 'Full sun',
water: 'Every 12 weeks',
difficulty: 'Moderate',
description:
'Fragrant and calming, lavender thrives in sunny spots with well-drained soil. Great for borders, pots, and drying for sachets.',
emoji: '💜',
},
{
id: 8,
name: 'Pothos',
category: 'Tropical',
light: 'Low to bright indirect',
water: 'Every 12 weeks',
difficulty: 'Very easy',
description:
'A trailing vine that can grow in almost any condition. Pothos is perfect for shelves and hanging baskets, and propagates effortlessly in water.',
emoji: '🍃',
},
]
const categories = ['All', 'Tropical', 'Succulent', 'Herb']
const difficulties = ['All', 'Very easy', 'Easy', 'Moderate', 'Hard']
import { useState } from 'react'
import './Wiki.css'
export default function Wiki() {
const [search, setSearch] = useState('')
const [category, setCategory] = useState('All')
const [difficulty, setDifficulty] = useState('All')
const [expanded, setExpanded] = useState(null)
const filtered = plants.filter((p) => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase())
const matchesCategory = category === 'All' || p.category === category
const matchesDifficulty = difficulty === 'All' || p.difficulty === difficulty
return matchesSearch && matchesCategory && matchesDifficulty
})
return (
<div className="wiki">
<div className="wiki-header">
<h1>Plant Wiki</h1>
<p>Care guides for popular houseplants and herbs</p>
</div>
<div className="wiki-filters">
<input
className="wiki-search"
type="search"
placeholder="Search plants…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="filter-chips">
{categories.map((c) => (
<button
key={c}
className={`chip ${category === c ? 'active' : ''}`}
onClick={() => setCategory(c)}
>
{c}
</button>
))}
</div>
<div className="filter-chips">
{difficulties.map((d) => (
<button
key={d}
className={`chip ${difficulty === d ? 'active' : ''}`}
onClick={() => setDifficulty(d)}
>
{d}
</button>
))}
</div>
</div>
{filtered.length === 0 ? (
<p className="wiki-empty">No plants match your filters.</p>
) : (
<ul className="wiki-list">
{filtered.map((plant) => (
<li key={plant.id} className="wiki-card">
<button
className="wiki-card-header"
onClick={() => setExpanded(expanded === plant.id ? null : plant.id)}
aria-expanded={expanded === plant.id}
>
<span className="wiki-emoji">{plant.emoji}</span>
<div className="wiki-card-title">
<strong>{plant.name}</strong>
<span className="wiki-category">{plant.category}</span>
</div>
<span className={`difficulty diff-${plant.difficulty.replace(' ', '-').toLowerCase()}`}>
{plant.difficulty}
</span>
<span className="wiki-chevron">{expanded === plant.id ? '▲' : '▼'}</span>
</button>
{expanded === plant.id && (
<div className="wiki-card-body">
<p className="wiki-description">{plant.description}</p>
<div className="wiki-stats">
<div className="stat">
<span className="stat-label">Light</span>
<span className="stat-value">{plant.light}</span>
</div>
<div className="stat">
<span className="stat-label">Watering</span>
<span className="stat-value">{plant.water}</span>
</div>
</div>
</div>
)}
</li>
))}
</ul>
)}
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})