Browse Source

import styles, components; implement herd views

Aneurin Barker Snook 1 year ago
parent
commit
dc7ee2d477
63 changed files with 1976 additions and 164 deletions
  1. 202 10
      web/package-lock.json
  2. 6 1
      web/package.json
  3. 0 42
      web/src/App.css
  4. 0 35
      web/src/App.tsx
  5. 1 1
      web/src/api/account.ts
  6. 1 1
      web/src/api/lib.ts
  7. 5 0
      web/src/build.ts
  8. 6 0
      web/src/components/Authenticated.scss
  9. 23 0
      web/src/components/Authenticated.tsx
  10. 22 0
      web/src/components/ButtonSet.scss
  11. 14 0
      web/src/components/ButtonSet.tsx
  12. 33 0
      web/src/components/Chip.scss
  13. 22 0
      web/src/components/Chip.tsx
  14. 22 0
      web/src/components/LoadingIndicator.scss
  15. 14 0
      web/src/components/LoadingIndicator.tsx
  16. 40 0
      web/src/components/Main.scss
  17. 14 0
      web/src/components/Main.tsx
  18. 27 0
      web/src/components/Notice.scss
  19. 21 0
      web/src/components/Notice.tsx
  20. 11 0
      web/src/components/Pagination.scss
  21. 51 0
      web/src/components/Pagination.tsx
  22. 5 0
      web/src/components/Row.scss
  23. 14 0
      web/src/components/Row.tsx
  24. 5 0
      web/src/components/SearchForm.scss
  25. 44 0
      web/src/components/SearchForm.tsx
  26. 20 0
      web/src/components/button/BackButton.tsx
  27. 113 0
      web/src/components/button/Button.scss
  28. 44 0
      web/src/components/button/Button.tsx
  29. 13 0
      web/src/components/button/CreateButton.tsx
  30. 13 0
      web/src/components/button/EditButton.tsx
  31. 24 0
      web/src/components/button/HideShowButton.tsx
  32. 31 0
      web/src/components/button/LimitButton.tsx
  33. 13 0
      web/src/components/button/ResetButton.tsx
  34. 13 0
      web/src/components/button/SaveButton.tsx
  35. 29 0
      web/src/components/form/FormGroup.scss
  36. 16 0
      web/src/components/form/FormGroup.tsx
  37. 57 0
      web/src/components/form/FormInput.scss
  38. 19 0
      web/src/components/form/FormInput.tsx
  39. 27 0
      web/src/components/form/GrowingTextInput.scss
  40. 25 0
      web/src/components/form/GrowingTextInput.tsx
  41. 20 0
      web/src/components/form/mixins.scss
  42. 2 0
      web/src/hooks/index.ts
  43. 62 0
      web/src/hooks/routeSearch.ts
  44. 0 68
      web/src/index.css
  45. 28 0
      web/src/index.scss
  46. 56 0
      web/src/layouts/AppLayout.scss
  47. 38 0
      web/src/layouts/AppLayout.tsx
  48. 16 0
      web/src/layouts/app/AccountButton.tsx
  49. 12 0
      web/src/layouts/app/ConnectionStatus.scss
  50. 21 0
      web/src/layouts/app/ConnectionStatus.tsx
  51. 9 0
      web/src/layouts/app/Copyright.tsx
  52. 40 0
      web/src/layouts/app/LoginLogoutButton.tsx
  53. 7 4
      web/src/main.tsx
  54. 9 0
      web/src/modern-normalize.min.css
  55. 2 2
      web/src/providers/session.ts
  56. 61 0
      web/src/routes.tsx
  57. 70 0
      web/src/vars.scss
  58. 14 0
      web/src/views/ErrorView.tsx
  59. 13 0
      web/src/views/HerdListView.scss
  60. 82 0
      web/src/views/HerdListView.tsx
  61. 21 0
      web/src/views/HerdView.scss
  62. 228 0
      web/src/views/HerdView.tsx
  63. 105 0
      web/src/views/LoginView.tsx

+ 202 - 10
web/package-lock.json

@@ -8,13 +8,18 @@
       "name": "web",
       "version": "0.0.0",
       "dependencies": {
+        "@heroicons/react": "^2.0.18",
         "react": "^18.2.0",
-        "react-dom": "^18.2.0"
+        "react-dom": "^18.2.0",
+        "react-hook-form": "^7.48.2",
+        "react-router-dom": "^6.20.1",
+        "sass": "^1.69.5"
       },
       "devDependencies": {
         "@types/node": "^20.10.3",
         "@types/react": "^18.2.37",
         "@types/react-dom": "^18.2.15",
+        "@types/react-router-dom": "^5.3.3",
         "@typescript-eslint/eslint-plugin": "^6.10.0",
         "@typescript-eslint/parser": "^6.10.0",
         "@vitejs/plugin-react": "^4.2.0",
@@ -815,6 +820,14 @@
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
+    "node_modules/@heroicons/react": {
+      "version": "2.0.18",
+      "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.18.tgz",
+      "integrity": "sha512-7TyMjRrZZMBPa+/5Y8lN0iyvUU/01PeMGX2+RE7cQWpEUIcb4QotzUObFkJDejj/HUH4qjP/eQ0gzzKs2f+6Yw==",
+      "peerDependencies": {
+        "react": ">= 16"
+      }
+    },
     "node_modules/@humanwhocodes/config-array": {
       "version": "0.11.13",
       "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -931,6 +944,14 @@
         "node": ">= 8"
       }
     },
+    "node_modules/@remix-run/router": {
+      "version": "1.13.1",
+      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.1.tgz",
+      "integrity": "sha512-so+DHzZKsoOcoXrILB4rqDkMDy7NLMErRdOxvzvOKb507YINKUP4Di+shbTZDhSE/pBZ+vr7XGIpcOO0VLSA+Q==",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/@rollup/rollup-android-arm-eabi": {
       "version": "4.6.1",
       "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.6.1.tgz",
@@ -1128,6 +1149,12 @@
         "@babel/types": "^7.20.7"
       }
     },
+    "node_modules/@types/history": {
+      "version": "4.7.11",
+      "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
+      "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
+      "dev": true
+    },
     "node_modules/@types/json-schema": {
       "version": "7.0.15",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1169,6 +1196,27 @@
         "@types/react": "*"
       }
     },
+    "node_modules/@types/react-router": {
+      "version": "5.1.20",
+      "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
+      "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
+      "dev": true,
+      "dependencies": {
+        "@types/history": "^4.7.11",
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/react-router-dom": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
+      "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
+      "dev": true,
+      "dependencies": {
+        "@types/history": "^4.7.11",
+        "@types/react": "*",
+        "@types/react-router": "*"
+      }
+    },
     "node_modules/@types/scheduler": {
       "version": "0.16.8",
       "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
@@ -1453,6 +1501,18 @@
         "node": ">=4"
       }
     },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
     "node_modules/argparse": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1474,6 +1534,14 @@
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
       "dev": true
     },
+    "node_modules/binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/brace-expansion": {
       "version": "1.1.11",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1488,7 +1556,6 @@
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
       "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
-      "dev": true,
       "dependencies": {
         "fill-range": "^7.0.1"
       },
@@ -1571,6 +1638,43 @@
         "node": ">=4"
       }
     },
+    "node_modules/chokidar": {
+      "version": "3.5.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+      "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/color-convert": {
       "version": "1.9.3",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -2057,7 +2161,6 @@
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
       "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
-      "dev": true,
       "dependencies": {
         "to-regex-range": "^5.0.1"
       },
@@ -2111,7 +2214,6 @@
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
       "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "dev": true,
       "hasInstallScript": true,
       "optional": true,
       "os": [
@@ -2215,6 +2317,11 @@
         "node": ">= 4"
       }
     },
+    "node_modules/immutable": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz",
+      "integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA=="
+    },
     "node_modules/import-fresh": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -2256,11 +2363,21 @@
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
       "dev": true
     },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
       "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
-      "dev": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -2269,7 +2386,6 @@
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
       "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
-      "dev": true,
       "dependencies": {
         "is-extglob": "^2.1.1"
       },
@@ -2281,7 +2397,6 @@
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
       "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
-      "dev": true,
       "engines": {
         "node": ">=0.12.0"
       }
@@ -2493,6 +2608,14 @@
       "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
       "dev": true
     },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/once": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -2607,7 +2730,6 @@
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
       "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true,
       "engines": {
         "node": ">=8.6"
       },
@@ -2704,6 +2826,21 @@
         "react": "^18.2.0"
       }
     },
+    "node_modules/react-hook-form": {
+      "version": "7.48.2",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.48.2.tgz",
+      "integrity": "sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==",
+      "engines": {
+        "node": ">=12.22.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/react-hook-form"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17 || ^18"
+      }
+    },
     "node_modules/react-refresh": {
       "version": "0.14.0",
       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
@@ -2713,6 +2850,47 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/react-router": {
+      "version": "6.20.1",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.1.tgz",
+      "integrity": "sha512-ccvLrB4QeT5DlaxSFFYi/KR8UMQ4fcD8zBcR71Zp1kaYTC5oJKYAp1cbavzGrogwxca+ubjkd7XjFZKBW8CxPA==",
+      "dependencies": {
+        "@remix-run/router": "1.13.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8"
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "6.20.1",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.1.tgz",
+      "integrity": "sha512-npzfPWcxfQN35psS7rJgi/EW0Gx6EsNjfdJSAk73U/HqMEJZ2k/8puxfwHFgDQhBGmS3+sjnGbMdMSV45axPQw==",
+      "dependencies": {
+        "@remix-run/router": "1.13.1",
+        "react-router": "6.20.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
     "node_modules/resolve-from": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -2798,6 +2976,22 @@
         "queue-microtask": "^1.2.2"
       }
     },
+    "node_modules/sass": {
+      "version": "1.69.5",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz",
+      "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==",
+      "dependencies": {
+        "chokidar": ">=3.0.0 <4.0.0",
+        "immutable": "^4.0.0",
+        "source-map-js": ">=0.6.2 <2.0.0"
+      },
+      "bin": {
+        "sass": "sass.js"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/scheduler": {
       "version": "0.23.0",
       "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
@@ -2873,7 +3067,6 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
       "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
-      "dev": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -2933,7 +3126,6 @@
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
       "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
-      "dev": true,
       "dependencies": {
         "is-number": "^7.0.0"
       },

+ 6 - 1
web/package.json

@@ -10,13 +10,18 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@heroicons/react": "^2.0.18",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "react-hook-form": "^7.48.2",
+    "react-router-dom": "^6.20.1",
+    "sass": "^1.69.5"
   },
   "devDependencies": {
     "@types/node": "^20.10.3",
     "@types/react": "^18.2.37",
     "@types/react-dom": "^18.2.15",
+    "@types/react-router-dom": "^5.3.3",
     "@typescript-eslint/eslint-plugin": "^6.10.0",
     "@typescript-eslint/parser": "^6.10.0",
     "@vitejs/plugin-react": "^4.2.0",

+ 0 - 42
web/src/App.css

@@ -1,42 +0,0 @@
-#root {
-  max-width: 1280px;
-  margin: 0 auto;
-  padding: 2rem;
-  text-align: center;
-}
-
-.logo {
-  height: 6em;
-  padding: 1.5em;
-  will-change: filter;
-  transition: filter 300ms;
-}
-.logo:hover {
-  filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
-  filter: drop-shadow(0 0 2em #61dafbaa);
-}
-
-@keyframes logo-spin {
-  from {
-    transform: rotate(0deg);
-  }
-  to {
-    transform: rotate(360deg);
-  }
-}
-
-@media (prefers-reduced-motion: no-preference) {
-  a:nth-of-type(2) .logo {
-    animation: logo-spin infinite 20s linear;
-  }
-}
-
-.card {
-  padding: 2em;
-}
-
-.read-the-docs {
-  color: #888;
-}

+ 0 - 35
web/src/App.tsx

@@ -1,35 +0,0 @@
-import './App.css'
-import reactLogo from '@/assets/react.svg'
-import { useState } from 'react'
-import viteLogo from '/vite.svg'
-
-function App() {
-  const [count, setCount] = useState(0)
-
-  return (
-    <>
-      <div>
-        <a href="https://vitejs.dev" target="_blank">
-          <img src={viteLogo} className="logo" alt="Vite logo" />
-        </a>
-        <a href="https://react.dev" target="_blank">
-          <img src={reactLogo} className="logo react" alt="React logo" />
-        </a>
-      </div>
-      <h1>Vite + React</h1>
-      <div className="card">
-        <button onClick={() => setCount((count) => count + 1)}>
-          count is {count}
-        </button>
-        <p>
-          Edit <code>src/App.tsx</code> and save to test HMR
-        </p>
-      </div>
-      <p className="read-the-docs">
-        Click on the Vite and React logos to learn more
-      </p>
-    </>
-  )
-}
-
-export default App

+ 1 - 1
web/src/api/account.ts

@@ -45,7 +45,7 @@ export interface LoginAccountRequest {
 /** Account login response data. */
 export interface LoginAccountResponse {
   token: string
-  account: Account
+  account: WithId<Account>
 }
 
 /** Update account request data. */

+ 1 - 1
web/src/api/lib.ts

@@ -70,7 +70,7 @@ export interface SearchParams {
 }
 
 export interface SearchResponse<T> {
-  results: T
+  results: T[]
   metadata: {
     limit: number
     page: number

+ 5 - 0
web/src/build.ts

@@ -3,6 +3,11 @@ const build = {
     host: import.meta.env.VITE_API_HOST || 'http://localhost:5001/api',
     timeout: parseInt(import.meta.env.VITE_API_TIMEOUT || '10000'),
   },
+  button: {
+    limit: {
+      limits: [10, 25, 50, 100],
+    },
+  },
   document: {
     titleSuffix: import.meta.env.VITE_DOCUMENT_TITLE_SUFFIX || 'Herda',
   },

+ 6 - 0
web/src/components/Authenticated.scss

@@ -0,0 +1,6 @@
+.authenticating {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  min-height: 100vh;
+}

+ 23 - 0
web/src/components/Authenticated.tsx

@@ -0,0 +1,23 @@
+import './Authenticated.scss'
+import LoadingIndicator from './LoadingIndicator'
+import type { PropsWithChildren } from 'react'
+import { useSession } from '@/hooks'
+import { Navigate, useLocation } from 'react-router-dom'
+
+export default function Authenticated({ children }: PropsWithChildren) {
+  const location = useLocation()
+  const session = useSession()
+
+  if (!session.ready) return (
+    <div className="authenticating">
+      <LoadingIndicator>Checking session</LoadingIndicator>
+    </div>
+  )
+
+  if (session.loggedIn) return children
+
+  const redirect = location.pathname
+  return (
+    <Navigate to={{ pathname: '/login', search: `redirect=${redirect}` }} />
+  )
+}

+ 22 - 0
web/src/components/ButtonSet.scss

@@ -0,0 +1,22 @@
+@import '../vars.scss';
+
+.button-set {
+  display: flex;
+  flex-direction: row;
+
+  &.center {
+    justify-content: center;
+  }
+
+  &.right {
+    justify-content: end;
+  }
+
+  button {
+    margin-left: $space-s;
+
+    &:first-child {
+      margin-left: 0;
+    }
+  }
+}

+ 14 - 0
web/src/components/ButtonSet.tsx

@@ -0,0 +1,14 @@
+import './ButtonSet.scss'
+import type { PropsWithChildren } from 'react'
+
+export interface ButtonSetProps {
+  className?: string
+}
+
+export default function ButtonSet({ className = '', ...props }: PropsWithChildren<ButtonSetProps>) {
+  return (
+    <div className={`button-set ${className}`}>
+      {props.children}
+    </div>
+  )
+}

+ 33 - 0
web/src/components/Chip.scss

@@ -0,0 +1,33 @@
+@import '../vars.scss';
+
+.chip {
+  --bg: var(--color-fg-alt);
+  --fg: var(--color-bg-alt);
+
+  &.negative {
+    --bg: var(--color-negative);
+    --fg: var(--color-negative-fg);
+  }
+
+  &.positive {
+    --bg: var(--color-positive);
+    --fg: var(--color-positive-fg);
+  }
+
+  &.warn {
+    --bg: var(--color-warn);
+    --fg: var(--color-warn-fg);
+  }
+
+  background-color: var(--bg);
+  color: var(--fg);
+
+  border-radius: $radius-s;
+  display: inline-block;
+  padding: 0 $space-s;
+  width: fit-content;
+
+  &.mini {
+    font-size: $font-size-s;
+  }
+}

+ 22 - 0
web/src/components/Chip.tsx

@@ -0,0 +1,22 @@
+import './Chip.scss'
+import type { FieldError } from 'react-hook-form'
+import type { PropsWithChildren } from 'react'
+
+export interface ChipProps {
+  className?: string
+  error?: Error | FieldError
+}
+
+export default function Chip({ className = '', error, ...props }: PropsWithChildren<ChipProps>) {
+  if (error) return (
+    <span className={`chip negative ${className}`}>
+      {error.message || 'Invalid value'}
+    </span>
+  )
+
+  return props.children && (
+    <span className={`chip ${className}`}>
+      {props.children}
+    </span>
+  )
+}

+ 22 - 0
web/src/components/LoadingIndicator.scss

@@ -0,0 +1,22 @@
+@import '../vars.scss';
+
+.loading-indicator {
+  align-items: center;
+  display: inline-flex;
+  flex-direction: row;
+
+  svg {
+    animation: 0.5s linear infinite spin;
+    margin-right: $space-xs;
+    width: 20px;
+  }
+}
+
+@keyframes spin {
+  from {
+    rotate: 0deg;
+  }
+  to {
+    rotate: 180deg;
+  }
+}

+ 14 - 0
web/src/components/LoadingIndicator.tsx

@@ -0,0 +1,14 @@
+import './LoadingIndicator.scss'
+import { ArrowPathIcon } from '@heroicons/react/20/solid'
+import type { PropsWithChildren } from 'react'
+
+export default function LoadingIndicator({ children }: PropsWithChildren) {
+  return (
+    <div className="loading-indicator">
+      <ArrowPathIcon />
+      <slot>
+        {children || <span>Loading</span>}
+      </slot>
+    </div>
+  )
+}

+ 40 - 0
web/src/components/Main.scss

@@ -0,0 +1,40 @@
+@import '../vars.scss';
+
+.main {
+  &.center {
+    align-items: center;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
+    // These direct-child styles make it a little simpler to implement an error page
+    > p,
+    > .button-set,
+    > .notice {
+      margin-bottom: $space-m;
+    }
+  }
+
+  > header,
+  > form > header {
+    align-items: center;
+    display: flex;
+    flex-direction: row;
+    margin-bottom: $space-m;
+
+    &:not(:first-child) {
+      margin-top: $space-m;
+    }
+
+    h1, h2 {
+      flex-grow: 1;
+      font-weight: bold;
+    }
+
+    h1 { font-size: $font-size-l; }
+  }
+
+  > section {
+    margin-bottom: $space-m;
+  }
+}

+ 14 - 0
web/src/components/Main.tsx

@@ -0,0 +1,14 @@
+import './Main.scss'
+import type { PropsWithChildren } from 'react'
+
+export interface MainProps {
+  className?: string
+}
+
+export default function Main({ children, className = '' }: PropsWithChildren<MainProps>) {
+  return (
+    <main className={`main ${className}`}>
+      {children}
+    </main>
+  )
+}

+ 27 - 0
web/src/components/Notice.scss

@@ -0,0 +1,27 @@
+@import '../vars.scss';
+
+.notice {
+  --bg: var(--color-bg-alt);
+  --fg: var(--color-fg-alt);
+
+  &.error, &.negative {
+    --bg: var(--color-negative);
+    --fg: var(--color-negative-fg);
+  }
+
+  &.positive {
+    --bg: var(--color-positive);
+    --fg: var(--color-positive-fg);
+  }
+
+  &.warn {
+    --bg: var(--color-warn);
+    --fg: var(--color-warn-fg);
+  }
+
+  background-color: var(--bg);
+  border-radius: $radius-s;
+  color: var(--fg);
+  margin: $space-s 0;
+  padding: $space-s;
+}

+ 21 - 0
web/src/components/Notice.tsx

@@ -0,0 +1,21 @@
+import './Notice.scss'
+import type { PropsWithChildren } from 'react'
+
+export interface NoticeProps {
+  className?: string
+  error?: Error
+}
+
+export default function Notice({ className = '', error, ...props }: PropsWithChildren<NoticeProps>) {
+  if (error) return (
+    <div className={`notice ${className} error`}>
+      <span>{error.message}</span>
+    </div>
+  )
+
+  return props.children && (
+    <div className={`notice ${className}`}>
+      {props.children}
+    </div>
+  )
+}

+ 11 - 0
web/src/components/Pagination.scss

@@ -0,0 +1,11 @@
+@import '../vars.scss';
+
+.pagination {
+  .info {
+    @include filler;
+    flex-grow: 1;
+    margin-left: $space-s;
+    padding-top: $space-xs;
+    text-align: center;
+  }
+}

+ 51 - 0
web/src/components/Pagination.tsx

@@ -0,0 +1,51 @@
+import './Pagination.scss'
+import Button from './button/Button'
+import ButtonSet from './ButtonSet'
+import LimitButton from './button/LimitButton'
+import type { PropsWithChildren } from 'react'
+import { useRouteSearch } from '@/hooks'
+import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'
+
+export interface PaginationProps {
+  totalCount: number
+}
+
+export default function Pagination({ totalCount }: PropsWithChildren<PaginationProps>) {
+  const { limit, page, ...routeSearch } = useRouteSearch()
+
+  const maxPage = Math.ceil(totalCount / limit)
+
+  const can = {
+    first: page > 1,
+    previous: page > 1,
+    next: page < maxPage,
+    last: page < maxPage,
+  }
+
+  return (
+    <section className="pagination">
+      <ButtonSet>
+        <Button disabled={!can.first} onClick={() => routeSearch.setPage(1)}>
+          <ArrowLeftIcon />
+          <span>First</span>
+        </Button>
+        <Button disabled={!can.previous} onClick={() => routeSearch.setPage(page - 1)}>
+          <ArrowLeftIcon />
+          <span>Previous</span>
+        </Button>
+        <span className="info">
+          <span>Page {page} of {maxPage}</span>
+          <LimitButton className="mini" />
+        </span>
+        <Button disabled={!can.next} onClick={() => routeSearch.setPage(page + 1)}>
+          <ArrowRightIcon />
+          <span>Next</span>
+        </Button>
+        <Button disabled={!can.last} onClick={() => routeSearch.setPage(maxPage)}>
+          <ArrowRightIcon />
+          <span>Last</span>
+        </Button>
+      </ButtonSet>
+    </section>
+  )
+}

+ 5 - 0
web/src/components/Row.scss

@@ -0,0 +1,5 @@
+.row {
+  align-items: center;
+  display: flex;
+  flex-direction: row;
+}

+ 14 - 0
web/src/components/Row.tsx

@@ -0,0 +1,14 @@
+import './Row.scss'
+import type { PropsWithChildren } from 'react'
+
+export interface RowProps {
+  className?: string
+}
+
+export default function Row({ children, className }: PropsWithChildren<RowProps>) {
+  return (
+    <div className={`row ${className}`}>
+      {children}
+    </div>
+  )
+}

+ 5 - 0
web/src/components/SearchForm.scss

@@ -0,0 +1,5 @@
+.search-form {
+  .search-bar {
+    margin-top: 0;
+  }
+}

+ 44 - 0
web/src/components/SearchForm.tsx

@@ -0,0 +1,44 @@
+import './SearchForm.scss'
+import Button from './button/Button'
+import ButtonSet from './ButtonSet'
+import type { FormEvent } from 'react'
+import FormGroup from './form/FormGroup'
+import FormInput from './form/FormInput'
+import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
+import Row from './Row'
+import { useRouteSearch } from '@/hooks'
+import { useState } from 'react'
+
+export default function SearchForm() {
+  const { searchTerms, setSearchTerms } = useRouteSearch()
+
+  const [searchInput, setSearchInput] = useState(searchTerms)
+
+  function submit(e: FormEvent) {
+    e.preventDefault()
+    setSearchTerms(searchInput)
+  }
+
+  return (
+    <form className="search-form" onSubmit={submit}>
+      <FormGroup className="search-bar">
+        <FormInput>
+          <Row>
+            <input
+              type="text"
+              placeholder="Search terms"
+              value={searchInput}
+              onChange={e => setSearchInput(e.target.value)}
+            />
+            <ButtonSet>
+              <Button type="submit">
+                <MagnifyingGlassIcon />
+                <span>Search</span>
+              </Button>
+            </ButtonSet>
+          </Row>
+        </FormInput>
+      </FormGroup>
+    </form>
+  )
+}

+ 20 - 0
web/src/components/button/BackButton.tsx

@@ -0,0 +1,20 @@
+import { ArrowUturnLeftIcon } from '@heroicons/react/20/solid'
+import Button from './Button'
+import type { ButtonProps } from './Button'
+import type { PropsWithChildren } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+export default function BackButton({ className = '', onClick, ...props }: PropsWithChildren<ButtonProps>) {
+  const navigate = useNavigate()
+
+  const click = onClick ? onClick : () => {
+    navigate(-1)
+  }
+
+  return (
+    <Button className={`back ${className}`} onClick={click} {...props}>
+      <ArrowUturnLeftIcon />
+      {props.children ? props.children : <span>Back</span>}
+    </Button>
+  )
+}

+ 113 - 0
web/src/components/button/Button.scss

@@ -0,0 +1,113 @@
+@import '@/vars.scss';
+
+.button {
+  --bg: var(--color-bg-alt);
+  --border: rgba(0, 0, 0, 0);
+  --fg: var(--color-fg);
+
+  &.negative { --fg: var(--color-negative); }
+  &.positive { --fg: var(--color-positive); }
+  &.warn { --fg: var(--color-warn); }
+
+  &.fill, &:hover:not(:disabled) {
+    --bg: var(--color-fg);
+    --border: var(--color-fg);
+    --fg: var(--color-bg);
+
+    &.negative {
+      --bg: var(--color-negative);
+      --border: var(--color-negative);
+      --fg: var(--color-negative-fg);
+    }
+
+    &.positive {
+      --bg: var(--color-positive);
+      --border: var(--color-positive);
+      --fg: var(--color-positive-fg);
+    }
+
+    &.warn {
+      --bg: var(--color-warn);
+      --border: var(--color-warn);
+      --fg: var(--color-warn-fg);
+    }
+  }
+
+  &.outline {
+    --bg: var(--color-bg-alt);
+    --border: var(--color-fg);
+    --fg: var(--color-fg);
+
+    &.negative {
+      --border: var(--color-negative);
+      --fg: var(--color-negative);
+    }
+
+    &.positive {
+      --border: var(--color-positive);
+      --fg: var(--color-positive);
+    }
+
+    &.warn {
+      --border: var(--color-warn);
+      --fg: var(--color-warn);
+    }
+  }
+
+  $move: 2px;
+
+  align-items: center;
+  border-radius: $radius-s;
+  border-style: solid;
+  border-width: $border-width-s;
+  // box-shadow: $move $move var(--color-shadow);
+  box-sizing: border-box;
+  cursor: pointer;
+  display: inline-flex;
+  flex-direction: row;
+  font-size: inherit;
+  height: fit-content;
+  line-height: normal;
+  padding: $space-xs $space-s;
+
+  background-color: var(--bg);
+  border-color: var(--border);
+  color: var(--fg);
+
+  &:hover:not(:disabled) {
+    background-color: var(--bg);
+    border-color: var(--border);
+    color: var(--fg);
+    box-shadow: $move $move var(--color-shadow);
+    position: relative;
+    left: -$move;
+    top: -$move;
+  }
+
+  &:disabled {
+    cursor: not-allowed;
+    opacity: 0.6;
+  }
+
+  &.mini {
+    font-size: $font-size-s;
+    height: fit-content;
+    padding: 0 $space-xs;
+  }
+
+  // Note: using `<Button wide>` may affect the appearance of any icons used within
+  &.wide {
+    display: inline-block;
+    width: 100%;
+  }
+
+  svg {
+    align-self: center;
+    width: 20px;
+  }
+
+  span ~ svg,
+  svg ~ span {
+    margin-left: $space-xs;
+  }
+}

+ 44 - 0
web/src/components/button/Button.tsx

@@ -0,0 +1,44 @@
+import './Button.scss'
+import type { ReactElement } from 'react'
+import { useState } from 'react'
+import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
+
+export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
+  confirm?: ReactElement
+}
+
+/**
+ * General-purpose button component.
+ * Provides base styling, which can be augmented with standard class names:
+ *
+ * ```jsx
+ * <Button className="fill mini negative outline positive wide">My Button</Button>
+ * ```
+ */
+export default function Button({ children, className = '', confirm, onClick, type: typ, ...props }: PropsWithChildren<ButtonProps>) {
+  const [intent, setIntent] = useState(false)
+
+  const click: typeof onClick = e => {
+    if (confirm && !intent) {
+      setIntent(true)
+      return
+    }
+    onClick?.(e)
+  }
+
+  function mouseLeave() {
+    setIntent(false)
+  }
+
+  return (
+    <button
+      className={`button ${className}`}
+      onClick={click}
+      onMouseLeave={mouseLeave}
+      type={typ || 'button'}
+      {...props}
+    >
+      {intent && confirm ? confirm : children}
+    </button>
+  )
+}

+ 13 - 0
web/src/components/button/CreateButton.tsx

@@ -0,0 +1,13 @@
+import Button from './Button'
+import type { ButtonProps } from './Button'
+import { PlusCircleIcon } from '@heroicons/react/20/solid'
+import type { PropsWithChildren } from 'react'
+
+export default function CreateButton({ className = '', ...props }: PropsWithChildren<ButtonProps>) {
+  return (
+    <Button className={`create positive ${className}`} {...props}>
+      <PlusCircleIcon />
+      {props.children ? props.children : <span>Create</span>}
+    </Button>
+  )
+}

+ 13 - 0
web/src/components/button/EditButton.tsx

@@ -0,0 +1,13 @@
+import Button from './Button'
+import type { ButtonProps } from './Button'
+import { PencilSquareIcon } from '@heroicons/react/20/solid'
+import type { PropsWithChildren } from 'react'
+
+export default function EditButton({ className = '', ...props }: PropsWithChildren<ButtonProps>) {
+  return (
+    <Button className={`edit ${className}`} {...props}>
+      <PencilSquareIcon />
+      {props.children ? props.children : <span>Edit</span>}
+    </Button>
+  )
+}

+ 24 - 0
web/src/components/button/HideShowButton.tsx

@@ -0,0 +1,24 @@
+import Button from './Button'
+import type { ButtonProps } from './Button'
+import type { PropsWithChildren } from 'react'
+import { EyeIcon, EyeSlashIcon } from '@heroicons/react/20/solid'
+
+export interface HideShowButtonProps extends ButtonProps {
+  visible: boolean
+}
+
+export default function HideShowButton({ className = '', visible, ...props }: PropsWithChildren<HideShowButtonProps>) {
+  if (visible) return (
+    <Button className={`hide-show ${className}`} {...props}>
+      <EyeSlashIcon />
+      {props.children ? props.children : <span>Hide</span>}
+    </Button>
+  )
+
+  return (
+    <Button className={`hide-show ${className}`} {...props}>
+      <EyeIcon />
+      {props.children ? props.children : <span>Show</span>}
+    </Button>
+  )
+}

+ 31 - 0
web/src/components/button/LimitButton.tsx

@@ -0,0 +1,31 @@
+import Button from './Button'
+import type { ButtonProps } from './Button'
+import { EyeIcon } from '@heroicons/react/20/solid'
+import build from '@/build'
+import { useRouteSearch } from '@/hooks'
+import type { MouseEvent, PropsWithChildren } from 'react'
+
+export interface LimitButtonProps extends Omit<ButtonProps, 'onClick'> {
+  options?: number[]
+}
+
+export default function LimitButton({ className = '', options, ...props }: PropsWithChildren<LimitButtonProps>) {
+  const routeSearch = useRouteSearch()
+
+  const limits = options || build.button.limit.limits
+
+  const limitIndex = limits.indexOf(routeSearch.limit)
+  const nextLimit = limitIndex < 0 || limitIndex === limits.length - 1 ? limits[0] : limits[limitIndex+1]
+
+  function click(e: MouseEvent) {
+    e.preventDefault()
+    routeSearch.setLimit(nextLimit)
+  }
+
+  return (
+    <Button className={`limit ${className}`} onClick={click} {...props}>
+      <EyeIcon />
+      {props.children ? props.children : <span>Show {routeSearch.limit}</span>}
+    </Button>
+  )
+}

+ 13 - 0
web/src/components/button/ResetButton.tsx

@@ -0,0 +1,13 @@
+import { ArrowUturnDownIcon } from '@heroicons/react/20/solid'
+import Button from './Button'
+import type { ButtonProps } from './Button'
+import type { PropsWithChildren } from 'react'
+
+export default function ResetButton({ className = '', ...props }: PropsWithChildren<ButtonProps>) {
+  return (
+    <Button className={`reset ${className}`} {...props}>
+      <ArrowUturnDownIcon />
+      {props.children ? props.children : <span>Reset</span>}
+    </Button>
+  )
+}

+ 13 - 0
web/src/components/button/SaveButton.tsx

@@ -0,0 +1,13 @@
+import Button from './Button'
+import type { ButtonProps } from './Button'
+import { CheckCircleIcon } from '@heroicons/react/20/solid'
+import type { PropsWithChildren } from 'react'
+
+export default function SaveButton({ className = '', ...props }: PropsWithChildren<ButtonProps>) {
+  return (
+    <Button className={`save positive ${className}`} {...props}>
+      <CheckCircleIcon />
+      {props.children ? props.children : <span>Save</span>}
+    </Button>
+  )
+}

+ 29 - 0
web/src/components/form/FormGroup.scss

@@ -0,0 +1,29 @@
+@import '@/vars.scss';
+
+.fieldset {
+  background-color: var(--color-bg-alt);
+  border-width: 0;
+  border-radius: $radius-s;
+  margin: 2rem 0 $space-s;
+  padding: 0 $space-s $space-s;
+
+  > legend {
+    left: -$space-s;
+    font-weight: $font-weight-bold;
+    margin-bottom: -$space-sm;
+    position: relative;
+    top: -1.25rem;
+  }
+
+  > p {
+    margin: $space-s 0;
+  }
+
+  .input {
+    margin-top: $space-s;
+  }
+
+  .button-set {
+    margin-top: $space-m;
+  }
+}

+ 16 - 0
web/src/components/form/FormGroup.tsx

@@ -0,0 +1,16 @@
+import './FormGroup.scss'
+import type { PropsWithChildren } from 'react'
+
+export interface FormGroupProps {
+  className?: string
+  name?: string
+}
+
+export default function FormGroup({ className = '', name, ...props }: PropsWithChildren<FormGroupProps>) {
+  return (
+    <fieldset className={`fieldset ${className}`}>
+      {name && <legend>{name}</legend>}
+      {props.children}
+    </fieldset>
+  )
+}

+ 57 - 0
web/src/components/form/FormInput.scss

@@ -0,0 +1,57 @@
+@import '@/vars.scss';
+@import './mixins.scss';
+
+.input {
+  display: flex;
+  flex-direction: column;
+  font: inherit;
+
+  label {
+    margin-left: $space-s;
+    margin-bottom: $space-xs;
+  }
+
+  input[type="number"],
+  input[type="password"],
+  input[type="search"],
+  input[type="text"],
+  textarea {
+    @include input;
+  }
+
+  select {
+    background-color: var(--color-bg);
+    border: $border-width-s solid var(--color-bg);
+    border-radius: $radius-s;
+    box-sizing: border-box;
+    color: var(--color-fg);
+    font: inherit;
+    padding: $space-xs $space-s;
+    width: 100%;
+
+    &:disabled {
+      color: var(--color-inactive);
+      cursor: not-allowed;
+    }
+
+    &:focus {
+      border-color: var(--color-focus);
+      outline: none;
+    }
+  }
+
+  .chip {
+    margin: $space-s 0 0;
+  }
+
+  .row {
+    input {
+      flex-grow: 1;
+    }
+
+    .button-set {
+      margin-left: $space-s;
+      margin-top: 0;
+    }
+  }
+}

+ 19 - 0
web/src/components/form/FormInput.tsx

@@ -0,0 +1,19 @@
+import './FormInput.scss'
+import type { PropsWithChildren } from 'react'
+
+export interface FormInputProps {
+  className?: string
+  id?: string
+  label?: string
+}
+
+export default function FormInput({ className = '', id, label, ...props }: PropsWithChildren<FormInputProps>) {
+  return (
+    <section className={`input ${className}`}>
+      {label && (
+        <label htmlFor={id}>{label}</label>
+      )}
+      {props.children}
+    </section>
+  )
+}

+ 27 - 0
web/src/components/form/GrowingTextInput.scss

@@ -0,0 +1,27 @@
+@import '@/vars.scss';
+@import './mixins.scss';
+
+/**
+ * Auto-growing textarea.
+ * Based on https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
+ */
+.growing-text-input {
+  display: grid;
+
+  &::after {
+    @include input;
+    content: attr(data-replicated-value) " ";
+    visibility: hidden;
+    white-space: pre-wrap;
+  }
+
+  textarea {
+    resize: none;
+    overflow: hidden;
+  }
+
+  textarea,
+  &::after {
+    grid-area: 1 / 1 / 2 / 2;
+  }
+}

+ 25 - 0
web/src/components/form/GrowingTextInput.tsx

@@ -0,0 +1,25 @@
+import './GrowingTextInput.scss'
+import { forwardRef } from 'react'
+import type { DetailedHTMLProps, KeyboardEvent, TextareaHTMLAttributes } from 'react'
+
+export interface GrowingTextInputProps extends DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement> {}
+
+const GrowingTextInput = forwardRef<HTMLTextAreaElement, GrowingTextInputProps>((props, ref) => {
+  const { className, onInput, ...restProps } = props
+
+  function replicateValue(e: KeyboardEvent<HTMLTextAreaElement>) {
+    if (e.currentTarget.parentElement) {
+      e.currentTarget.parentElement.dataset.replicatedValue = e.currentTarget.value
+    }
+
+    onInput?.(e)
+  }
+
+  return (
+    <div className={`growing-text-input ${className}`}>
+      <textarea {...restProps} onInput={replicateValue} ref={ref} />
+    </div>
+  )
+})
+
+export default GrowingTextInput

+ 20 - 0
web/src/components/form/mixins.scss

@@ -0,0 +1,20 @@
+@mixin input {
+  background-color: var(--color-bg);
+  border: $border-width-s solid var(--color-bg);
+  border-radius: $radius-s;
+  box-sizing: border-box;
+  color: var(--color-fg);
+  font: inherit;
+  line-height: normal;
+  padding: $space-xs $space-s;
+
+  &:disabled {
+    color: var(--color-inactive);
+    cursor: not-allowed;
+  }
+
+  &:focus {
+    border-color: var(--color-focus);
+    outline: none;
+  }
+}

+ 2 - 0
web/src/hooks/index.ts

@@ -3,6 +3,8 @@ import { DocumentContext } from '@/providers/document'
 import { SessionContext } from '@/providers/session'
 import { useContext } from 'react'
 
+export { useRouteSearch } from './routeSearch'
+
 export function useConnection() {
   return useContext(ConnectionContext)
 }

+ 62 - 0
web/src/hooks/routeSearch.ts

@@ -0,0 +1,62 @@
+import api from '@/api'
+import { useMemo } from 'react'
+import { useSearchParams } from 'react-router-dom'
+
+export function useRouteSearch() {
+  const [search, setSearch] = useSearchParams()
+
+  function setLimit(limit: number) {
+    setSearch(prev => {
+      prev.set('limit', limit.toString())
+      return prev
+    })
+  }
+
+  function setPage(page: number) {
+    setSearch(prev => {
+      prev.set('page', page.toString())
+      return prev
+    })
+  }
+
+  function setSearchTerms(terms: string) {
+    setSearch(prev => {
+      if (terms.length) prev.set('search', terms)
+      else prev.delete('search')
+      return prev
+    })
+  }
+
+  function setSorts(sorts: string[]) {
+    setSearch(prev => {
+      prev.delete('sort')
+      for (const sort of sorts) prev.append('sort', sort)
+      return prev
+    })
+  }
+
+  const { searchParams, searchTerms } = useMemo(() => {
+    const searchParams = api.readSearchParams(search)
+    if (!searchParams.limit) searchParams.limit = 10
+    if (!searchParams.page) searchParams.page = 1
+
+    const searchTerms = search.get('search') || ''
+    return { searchParams, searchTerms }
+  }, [search])
+
+  const limit = searchParams.limit || 10
+  const page = searchParams.page || 1
+
+  const sort = searchParams.sort || []
+
+  return {
+    limit, page, searchTerms, sort,
+    searchParams, search,
+
+    setLimit,
+    setPage,
+    setSearch,
+    setSearchTerms,
+    setSorts,
+  }
+}

+ 0 - 68
web/src/index.css

@@ -1,68 +0,0 @@
-:root {
-  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
-  line-height: 1.5;
-  font-weight: 400;
-
-  color-scheme: light dark;
-  color: rgba(255, 255, 255, 0.87);
-  background-color: #242424;
-
-  font-synthesis: none;
-  text-rendering: optimizeLegibility;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-}
-
-a {
-  font-weight: 500;
-  color: #646cff;
-  text-decoration: inherit;
-}
-a:hover {
-  color: #535bf2;
-}
-
-body {
-  margin: 0;
-  display: flex;
-  place-items: center;
-  min-width: 320px;
-  min-height: 100vh;
-}
-
-h1 {
-  font-size: 3.2em;
-  line-height: 1.1;
-}
-
-button {
-  border-radius: 8px;
-  border: 1px solid transparent;
-  padding: 0.6em 1.2em;
-  font-size: 1em;
-  font-weight: 500;
-  font-family: inherit;
-  background-color: #1a1a1a;
-  cursor: pointer;
-  transition: border-color 0.25s;
-}
-button:hover {
-  border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
-  outline: 4px auto -webkit-focus-ring-color;
-}
-
-@media (prefers-color-scheme: light) {
-  :root {
-    color: #213547;
-    background-color: #ffffff;
-  }
-  a:hover {
-    color: #747bff;
-  }
-  button {
-    background-color: #f9f9f9;
-  }
-}

+ 28 - 0
web/src/index.scss

@@ -0,0 +1,28 @@
+@import url('https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap');
+@import 'vars.scss';
+@import 'modern-normalize.min.css';
+
+html {
+  font-size: $font-size;
+}
+
+body {
+  background-color: var(--color-bg);
+  color: var(--color-fg);
+  font-family: $font-primary;
+  font-weight: $font-weight;
+  line-height: 1.4em;
+}
+
+a {
+  color: var(--color-cta);
+  text-decoration: none;
+
+  &:hover {
+    text-decoration: underline;
+  }
+}
+
+em {
+  @include bold;
+}

+ 56 - 0
web/src/layouts/AppLayout.scss

@@ -0,0 +1,56 @@
+@import '@/vars.scss';
+
+.app {
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+}
+
+.top {
+  color: var(--color-fg-alt);
+  display: flex;
+  flex-direction: row;
+  margin: $space-m;
+}
+
+.main {
+  flex-grow: 1;
+  margin: 0 $space-m;
+}
+
+.bottom {
+  display: flex;
+  flex-direction: row;
+  margin: $space-m;
+
+  > *:not(:first-child) {
+    margin-left: $space-s;
+    margin-right: 0;
+  }
+
+  .connection-status, .copyright {
+    background-color: var(--color-bg-alt);
+    border-radius: $radius-s;
+    color: var(--color-fg-alt);
+    padding: $space-xs $space-s;
+  }
+
+  .support {
+    .button-set, button {
+      height: 100%;
+    }
+
+    button span {
+      display: none;
+    }
+  }
+}
+
+.spacer {
+  @include filler;
+
+  background-color: var(--color-bg-alt);
+  border-radius: $radius-s;
+  flex-grow: 1;
+  margin: 0 $space-s;
+}

+ 38 - 0
web/src/layouts/AppLayout.tsx

@@ -0,0 +1,38 @@
+import './AppLayout.scss'
+import AccountButton from './app/AccountButton'
+import ButtonSet from '@/components/ButtonSet'
+import ConnectionStatus from './app/ConnectionStatus'
+import Copyright from './app/Copyright'
+import LoginLogoutButton from './app/LoginLogoutButton'
+import type { PropsWithChildren } from 'react'
+import { useSession } from '@/hooks'
+
+export default function AppLayout({ children }: Pick<PropsWithChildren, 'children'>) {
+  const { loggedIn } = useSession()
+
+  return (
+    <div className="app">
+      <header className="top">
+        {loggedIn && (
+          <ButtonSet>
+            <AccountButton />
+          </ButtonSet>
+        )}
+        <div className="spacer"></div>
+        <nav className="user-menu">
+          <ButtonSet>
+            <LoginLogoutButton />
+          </ButtonSet>
+        </nav>
+      </header>
+
+      {children}
+
+      <footer className="bottom">
+        <ConnectionStatus />
+        <div className="spacer"></div>
+        <Copyright />
+      </footer>
+    </div>
+  )
+}

+ 16 - 0
web/src/layouts/app/AccountButton.tsx

@@ -0,0 +1,16 @@
+import Button from '@/components/button/Button'
+import { UserCircleIcon } from '@heroicons/react/20/solid'
+import { useNavigate } from 'react-router-dom'
+import { useSession } from '@/hooks'
+
+export default function AccountButton() {
+  const navigate = useNavigate()
+  const { account } = useSession()
+
+  return account && (
+    <Button onClick={() => navigate('/account/settings')}>
+      <UserCircleIcon />
+      <span>{account.email}</span>
+    </Button>
+  )
+}

+ 12 - 0
web/src/layouts/app/ConnectionStatus.scss

@@ -0,0 +1,12 @@
+@import '@/vars.scss';
+
+.connection-status {
+  svg {
+    align-self: center;
+    width: 20px;
+  }
+
+  span {
+    margin-left: $space-xs;
+  }
+}

+ 21 - 0
web/src/layouts/app/ConnectionStatus.tsx

@@ -0,0 +1,21 @@
+import './ConnectionStatus.scss'
+import Row from '@/components/Row'
+import { useConnection } from '@/hooks'
+import { SignalIcon, SignalSlashIcon } from '@heroicons/react/20/solid'
+
+export default function ConnectionStatus() {
+  const { available, info, options } = useConnection()
+
+  if (available) return (
+    <Row className="connection-status">
+      <SignalIcon title={`Connected to ${options.host}`} />
+      <span>v{info?.version}</span>
+    </Row>
+  )
+
+  return (
+    <Row className="connection-status">
+      <SignalSlashIcon title={`Not connected to ${options.host}`} />
+    </Row>
+  )
+}

+ 9 - 0
web/src/layouts/app/Copyright.tsx

@@ -0,0 +1,9 @@
+export default function Copyright() {
+  const year = new Date().getFullYear()
+
+  return (
+    <div className="copyright">
+      &copy; <span className="year">{year}</span> Herda
+    </div>
+  )
+}

+ 40 - 0
web/src/layouts/app/LoginLogoutButton.tsx

@@ -0,0 +1,40 @@
+import Button from '@/components/button/Button'
+import { useNavigate } from 'react-router-dom'
+import { useSession } from '@/hooks'
+import { ArrowLeftOnRectangleIcon, ArrowRightOnRectangleIcon } from '@heroicons/react/20/solid'
+
+export default function LoginLogoutButton() {
+  const navigate = useNavigate()
+  const { loggedIn, ...session } = useSession()
+
+  function logout() {
+    session.logout()
+    navigate('/login')
+  }
+
+  if (loggedIn) return (
+    <Button
+      className="negative logout"
+      confirm={(
+        <>
+          <ArrowRightOnRectangleIcon />
+          <span>Really log out?</span>
+        </>
+      )}
+      onClick={logout}
+    >
+      <ArrowRightOnRectangleIcon />
+      <span>Log out</span>
+    </Button>
+  )
+
+  return (
+    <Button
+      className="positive login"
+      onClick={() => navigate('/login')}
+    >
+      <ArrowLeftOnRectangleIcon />
+      <span>Log in</span>
+    </Button>
+  )
+}

+ 7 - 4
web/src/main.tsx

@@ -1,5 +1,4 @@
-import './index.css'
-import App from '@/App.tsx'
+import './index.scss'
 import { ConnectionProvider } from './providers/connection'
 import { DocumentProvider } from './providers/document'
 import React from 'react'
@@ -7,6 +6,8 @@ import ReactDOM from 'react-dom/client'
 import { SessionProvider } from './providers/session'
 import build from './build'
 import { localValueStorage } from './lib/valueStorage'
+import routes from './routes'
+import { RouterProvider, createBrowserRouter } from 'react-router-dom'
 
 const connectionProps = {
   host: build.api.host,
@@ -17,8 +18,10 @@ const documentProps = {
   titleSuffix: build.document.titleSuffix,
 }
 
+const router = createBrowserRouter(routes)
+
 const sessionProps = {
-  authStorage: localValueStorage(`${build.localStorage.prefix}-auth`),
+  authStorage: localValueStorage(`${build.localStorage.prefix}auth`),
 }
 
 ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -26,7 +29,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
     <DocumentProvider value={documentProps}>
       <ConnectionProvider value={connectionProps}>
         <SessionProvider value={sessionProps}>
-          <App />
+          <RouterProvider router={router} />
         </SessionProvider>
       </ConnectionProvider>
     </DocumentProvider>

+ 9 - 0
web/src/modern-normalize.min.css

@@ -0,0 +1,9 @@
+/**
+ * Minified by jsDelivr using clean-css v5.3.2.
+ * Original file: /npm/modern-normalize@2.0.0/modern-normalize.css
+ *
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
+ */
+/*! modern-normalize v2.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */
+*,::after,::before{box-sizing:border-box}html{font-family:system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji';line-height:1.15;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4}body{margin:0}hr{height:0;color:inherit}abbr[title]{text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}::-moz-focus-inner{border-style:none;padding:0}:-moz-focusring{outline:1px dotted ButtonText}:-moz-ui-invalid{box-shadow:none}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}
+/*# sourceMappingURL=/sm/5b7c27b6a0fd11e81f813b36dc26f6049a71a06907ce03d53d65a3bfe866b576.map */

+ 2 - 2
web/src/providers/session.ts

@@ -5,7 +5,7 @@ import { useConnection } from '@/hooks'
 import { createContext, createElement, useLayoutEffect, useState } from 'react'
 
 export interface SessionData {
-  account?: api.Account
+  account?: api.WithId<api.Account>
   loggedIn?: boolean
   ready?: boolean
 }
@@ -23,7 +23,7 @@ export interface SessionState extends SessionData {
 export function SessionProvider({ children, value: { authStorage } }: ProviderProps<SessionProps>) {
   const { options, setToken } = useConnection()
 
-  const [account, setAccount] = useState<api.Account>()
+  const [account, setAccount] = useState<api.WithId<api.Account>>()
   const [loggedIn, setLoggedIn] = useState(false)
   const [ready, setReady] = useState(false)
 

+ 61 - 0
web/src/routes.tsx

@@ -0,0 +1,61 @@
+import AppLayout from './layouts/AppLayout'
+import Authenticated from './components/Authenticated'
+import ErrorView from './views/ErrorView'
+import HerdListView from '@/views/HerdListView'
+import HerdView from './views/HerdView'
+import LoginView from './views/LoginView'
+import { Outlet } from 'react-router-dom'
+import type { RouteObject } from 'react-router-dom'
+
+const coreRoutes: RouteObject[] = [
+  {
+    path: '',
+    element: <HerdListView />,
+  },
+  {
+    path: '/herd/:id',
+    element: <HerdView />,
+  },
+]
+
+const routes: RouteObject[] = [
+  {
+    path: '/',
+    element: (
+      <Authenticated>
+        <AppLayout>
+          <Outlet />
+        </AppLayout>
+      </Authenticated>
+    ),
+    errorElement: (
+      <AppLayout>
+        <ErrorView />
+      </AppLayout>
+    ),
+    children: [
+      ...coreRoutes,
+    ],
+  },
+  {
+    path: '/login',
+    element: (
+      <AppLayout>
+        <Outlet />
+      </AppLayout>
+    ),
+    errorElement: (
+      <AppLayout>
+        <ErrorView />
+      </AppLayout>
+    ),
+    children: [
+      {
+        path: '',
+        element: <LoginView />,
+      },
+    ],
+  },
+]
+
+export default routes

+ 70 - 0
web/src/vars.scss

@@ -0,0 +1,70 @@
+$font-primary: 'Work Sans', Helvetica, Arial, sans-serif;
+
+$font-size: 13pt;
+$font-size-s: 0.8rem;
+$font-size-m: 1rem;
+$font-size-l: 1.4rem;
+$font-size-xl: 1.8rem;
+
+$font-weight: 400;
+$font-weight-bold: 700;
+
+$border-size-input: 0.1rem;
+$border-width-s: 0.1rem;
+
+$padding-box: 0.5rem;
+
+$radius-box: 0.2rem;
+$radius-input: 0.2rem;
+
+$radius-s: 0.2rem;
+$radius-l: 0.6rem;
+
+$space-xs: 0.25rem;
+$space-s: 0.5rem;
+$space-sm: 0.75rem;
+$space-m: 1rem;
+$space-ml: 1.25rem;
+$space-l: 1.5rem;
+$space-ll: 1.75rem;
+$space-xl: 2rem;
+
+:root {
+  --color-bg-alt: #282828;
+  --color-bg: #343434;
+  --color-border: #494949;
+  --color-cta: #449055;
+  --color-focus: #dddddd;
+  --color-inactive: #909090;
+  --color-inactive-fg: #494949;
+  --color-negative-fg: #dddddd;
+  --color-negative: #d84c4c;
+  --color-fg-alt: #b5b5b5;
+  --color-fg: #dddddd;
+  --color-positive-fg: #dddddd;
+  --color-positive: #449055;
+  --color-shadow: #202020;
+  --color-warn-fg: #343434;
+  --color-warn: #f3af2f;
+
+  @media (prefers-color-scheme: light) {
+    // light colours todo
+  }
+}
+
+@mixin bold {
+  font-weight: $font-weight-bold;
+}
+
+@mixin filler {
+  $size: 5px;
+  $thickness: 3px;
+  background-color: var(--color-bg);
+  background: repeating-linear-gradient(
+    45deg,
+    var(--color-bg-alt),
+    var(--color-bg-alt) $thickness,
+    var(--color-bg) $thickness,
+    var(--color-bg) $size
+  );
+}

+ 14 - 0
web/src/views/ErrorView.tsx

@@ -0,0 +1,14 @@
+import Main from '@/components/Main'
+import Notice from '@/components/Notice'
+import { useRouteError } from 'react-router-dom'
+
+export default function ErrorView() {
+  const { error } = useRouteError() as { error: Error }
+
+  if (error) return (
+    <Main>
+      <h1>Error</h1>
+      <Notice error={error as Error} />
+    </Main>
+  )
+}

+ 13 - 0
web/src/views/HerdListView.scss

@@ -0,0 +1,13 @@
+@import '@/vars.scss';
+
+.row.herd {
+  background-color: var(--color-bg-alt);
+  border-width: 0;
+  border-radius: $radius-s;
+  margin: $space-s 0;
+  padding: $space-s $space-m;
+
+  a {
+    color: var(--color-text);
+  }
+}

+ 82 - 0
web/src/views/HerdListView.tsx

@@ -0,0 +1,82 @@
+import './HerdListView.scss'
+import ButtonSet from '@/components/ButtonSet'
+import CreateButton from '@/components/button/CreateButton'
+import { Link } from 'react-router-dom'
+import LoadingIndicator from '@/components/LoadingIndicator'
+import Main from '@/components/Main'
+import Notice from '@/components/Notice'
+import Pagination from '@/components/Pagination'
+import Row from '@/components/Row'
+import SearchForm from '@/components/SearchForm'
+import api from '@/api'
+import { useConnection } from '@/hooks'
+import { useNavigate } from 'react-router-dom'
+import { useRouteSearch } from '@/hooks'
+import { useCallback, useEffect, useState } from 'react'
+
+export default function ListHerds() {
+  const navigate = useNavigate()
+  const { options } = useConnection()
+  const { searchParams } = useRouteSearch()
+
+  const [error, setError] = useState<Error>()
+  const [loading, setLoading] = useState(true)
+  const [result, setResult] = useState<api.SearchResponse<api.GetHerdResponse>>()
+
+  const reload = useCallback(async () => {
+    setLoading(true)
+    setError(undefined)
+    try {
+      const res = await api.searchHerds(options, searchParams)
+      if (res.results.length === 0) throw new Error('No herds')
+      setResult(res)
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setLoading(false)
+    }
+  }, [options, searchParams])
+
+  useEffect(() => {
+    reload()
+  }, [reload])
+
+  function Header() {
+    return (
+      <header>
+        <h1>Herds</h1>
+        <ButtonSet>
+          <CreateButton className="fill" onClick={() => navigate('/herds/create')} />
+        </ButtonSet>
+      </header>
+    )
+  }
+
+  return result && (
+    <Main>
+      <Header />
+
+      <SearchForm />
+
+      {loading ? (
+        <LoadingIndicator />
+      ) : (
+        <Notice error={error} />
+      )}
+
+      {result && (
+        <>
+          {result.results.map(({ herd }) => (
+            <Row key={herd._id} className="herd">
+              <div>
+                <Link to={`/herd/${herd._id}`}>{herd.name}</Link>
+              </div>
+            </Row>
+          ))}
+
+          <Pagination totalCount={result.metadata.totalCount} />
+        </>
+      )}
+    </Main>
+  )
+}

+ 21 - 0
web/src/views/HerdView.scss

@@ -0,0 +1,21 @@
+@import '@/vars.scss';
+
+.row.task {
+  background-color: var(--color-bg-alt);
+  border-width: 0;
+  border-radius: $radius-s;
+  margin: $space-s 0;
+  padding: $space-s $space-m;
+
+  .position {
+    background: var(--color-bg);
+    border-radius: $radius-l;
+    margin-right: $space-s;
+    padding: 0 $space-s;
+    text-align: center;
+  }
+
+  .description {
+    flex-grow: 1;
+  }
+}

+ 228 - 0
web/src/views/HerdView.tsx

@@ -0,0 +1,228 @@
+import './HerdView.scss'
+import BackButton from '@/components/button/BackButton'
+import Button from '@/components/button/Button'
+import ButtonSet from '@/components/ButtonSet'
+import CreateButton from '@/components/button/CreateButton'
+import FormGroup from '@/components/form/FormGroup'
+import FormInput from '@/components/form/FormInput'
+import LoadingIndicator from '@/components/LoadingIndicator'
+import Main from '@/components/Main'
+import Notice from '@/components/Notice'
+import Pagination from '@/components/Pagination'
+import ResetButton from '@/components/button/ResetButton'
+import Row from '@/components/Row'
+import SaveButton from '@/components/button/SaveButton'
+import SearchForm from '@/components/SearchForm'
+import api from '@/api'
+import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/20/solid'
+import { useCallback, useEffect, useState } from 'react'
+import { useConnection, useRouteSearch, useSession } from '@/hooks'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useForm } from 'react-hook-form'
+import Chip from '@/components/Chip'
+
+interface HerdUpdateFormData extends Pick<api.Herd, 'name'> {}
+
+interface TaskCreateFormData extends Pick<api.Task, 'description'> {}
+
+function useHerdUpdateForm() {
+  const form = useForm<HerdUpdateFormData>({ mode: 'onBlur' })
+
+  const inputs = {
+    name: form.register('name', { validate: value => {
+      if (value.length < 1) return 'Required'
+    }}),
+  }
+
+  return { ...form, inputs }
+}
+
+function useTaskCreateForm() {
+  const form = useForm<TaskCreateFormData>({ mode: 'onBlur' })
+
+  const inputs = {
+    description: form.register('description', { validate: value => {
+      if (value.length < 1) return 'Required'
+    }}),
+  }
+
+  return { ...form, inputs }
+}
+
+export default function HerdView() {
+  const { account } = useSession()
+  const { id } = useParams()
+  const createTaskForm = useTaskCreateForm()
+  const updateHerdForm = useHerdUpdateForm()
+  const navigate = useNavigate()
+  const { options } = useConnection()
+  const { limit, page, searchParams, setPage } = useRouteSearch()
+
+  const [busy, setBusy] = useState(false)
+  const [data, setData] = useState<api.GetHerdResponse>()
+  const [taskData, setTaskData] = useState<api.SearchResponse<api.GetTaskResponse>>()
+  const [error, setError] = useState<Error>()
+  const [loading, setLoading] = useState(false)
+
+  async function createTask(data: TaskCreateFormData) {
+    if (busy) return
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      await api.createTask(options, {
+        task: {
+          _herd: id || '',
+          _account: account?._id || '',
+          description: data.description,
+        },
+      })
+      createTaskForm.reset({ description: '' })
+      if (taskData && taskData.results.length >= limit) {
+        // New task will be on a new page; change page to display it
+        setPage(page + 1)
+      } else {
+        // Reload current page
+        const taskRes = await api.searchTasks(options, id, searchParams)
+        setTaskData(taskRes)
+      }
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  const reload = useCallback(async () => {
+    if (id) {
+      setError(undefined)
+      setLoading(true)
+      try {
+        const res = await api.getHerd(options, id)
+        setData(res)
+        const taskRes = await api.searchTasks(options, id, searchParams)
+        setTaskData(taskRes)
+      } catch (err) {
+        setError(err as Error)
+      } finally {
+        setLoading(false)
+      }
+    }
+  }, [id, options, searchParams])
+
+  async function updateHerd(data: HerdUpdateFormData) {
+    if (busy) return
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      const res = await api.updateHerd(options, id as string, {
+        herd: data,
+      })
+      setData(res)
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  useEffect(() => {
+    reload()
+  }, [reload])
+
+  if (loading) return (
+    <Main>
+      <header>
+        {id ? <h1>Loading Herd...</h1> : <h1>Create Herd</h1>}
+      </header>
+
+      <LoadingIndicator />
+    </Main>
+  )
+
+  if (error) return (
+    <Main>
+      <header>
+        {id ? <h1>Loading Herd...</h1> : <h1>Create Herd</h1>}
+
+        <ButtonSet>
+          <BackButton />
+        </ButtonSet>
+      </header>
+
+      <Notice error={error} />
+    </Main>
+  )
+
+  return data && (
+    <Main>
+      <header>
+        <h1>{data.herd.name}</h1>
+
+        <ButtonSet>
+          <BackButton onClick={() => navigate('/')} />
+        </ButtonSet>
+      </header>
+
+      <SearchForm />
+
+      <Notice error={error} />
+
+      {taskData && (
+        <>
+          {taskData.results.map(({ task }) => (
+            <Row key={task._id} className={`task ${task.done ? 'done' : 'not-done'}`}>
+              <div className="position">{task.position}</div>
+              <div className="description">{task.description}</div>
+              <ButtonSet>
+                {task.done ? (
+                  <Button className="positive mini fill">
+                    <CheckCircleIcon />
+                    <span>Done</span>
+                  </Button>
+                ) : (
+                  <Button className="negative mini">
+                    <XCircleIcon />
+                    <span>Not done</span>
+                  </Button>
+                )}
+              </ButtonSet>
+            </Row>
+          ))}
+
+          <form onSubmit={createTaskForm.handleSubmit(createTask)}>
+            <FormGroup name="Add a task">
+              <FormInput>
+                <Row>
+                  <input id="description" type="text" {...createTaskForm.inputs.description} />
+                  <ButtonSet>
+                    <CreateButton type="submit" className="fill" />
+                  </ButtonSet>
+                </Row>
+                <Chip className="mini" error={createTaskForm.formState.errors.description} />
+              </FormInput>
+            </FormGroup>
+          </form>
+
+          <Pagination totalCount={taskData.metadata.totalCount} />
+
+          <form onSubmit={updateHerdForm.handleSubmit(updateHerd)}>
+            <FormGroup name="Edit herd">
+              <FormInput id="herd:name" label="Name">
+                <input id="herd:name" type="text" {...updateHerdForm.inputs.name} />
+                <Chip className="mini" error={updateHerdForm.formState.errors.name} />
+              </FormInput>
+
+              <ButtonSet>
+                <SaveButton type="submit" />
+                <ResetButton type="reset" />
+              </ButtonSet>
+            </FormGroup>
+          </form>
+        </>
+      )}
+
+    </Main>
+  )
+}

+ 105 - 0
web/src/views/LoginView.tsx

@@ -0,0 +1,105 @@
+import Button from '@/components/button/Button'
+import ButtonSet from '@/components/ButtonSet'
+import Chip from '@/components/Chip'
+import FormGroup from '@/components/form/FormGroup'
+import FormInput from '@/components/form/FormInput'
+import HideShowButton from '@/components/button/HideShowButton'
+import Main from '@/components/Main'
+import Notice from '@/components/Notice'
+import Row from '@/components/Row'
+import type { SubmitHandler } from 'react-hook-form'
+import { useDocument } from '@/hooks'
+import { useForm } from 'react-hook-form'
+import { useSession } from '@/hooks'
+import { Navigate, useSearchParams } from 'react-router-dom'
+import { useEffect, useState } from 'react'
+
+interface LoginFormData {
+  email: string
+  password: string
+}
+
+function useLoginForm() {
+  const form = useForm<LoginFormData>()
+
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState<Error>()
+
+  const inputs = {
+    email: form.register('email', { validate: {
+      required: value => value.length >= 1 || 'Required',
+    }}),
+    password: form.register('password', { validate: {
+      minLength: value => value.length >= 8 || 'Must be at least 8 characters',
+    }}),
+  }
+
+  return {
+    ...form,
+    inputs,
+    busy, setBusy,
+    error, setError,
+  }
+}
+
+export default function LoginView() {
+  const doc = useDocument()
+  const [params] = useSearchParams()
+  const session = useSession()
+
+  const { formState: { errors }, ...form } = useLoginForm()
+
+  const [passwordVisible, setPasswordVisible] = useState(false)
+  const redirectTo = params.get('redirect') || '/'
+
+  const submit: SubmitHandler<LoginFormData> = async ({ email, password }) => {
+    form.setError(undefined)
+    form.setBusy(true)
+    try {
+      await session.login(email, password)
+    } catch (err) {
+      form.setError(err as Error)
+    } finally {
+      form.setBusy(false)
+    }
+  }
+
+  useEffect(() => {
+    doc.setTitle('Login')
+  }, [doc])
+
+  if (session.ready && session.loggedIn) {
+    return (
+      <Navigate to={redirectTo} />
+    )
+  }
+
+  return (
+    <Main className="center">
+      <form onSubmit={form.handleSubmit(submit)}>
+        <FormGroup name="Login">
+          <FormInput id="email" label="Username">
+            <input id="email" type="text" {...form.inputs.email} />
+            {errors.email && <Chip className="mini" error={errors.email} />}
+          </FormInput>
+
+          <FormInput id="password" label="Password">
+            <Row className="hidden">
+              <input id="password" type={passwordVisible ? 'text' : 'password'} {...form.inputs.password} />
+              <ButtonSet>
+                <HideShowButton visible={passwordVisible} onClick={() => setPasswordVisible(!passwordVisible)} />
+              </ButtonSet>
+            </Row>
+            {errors.password && <Chip className="mini" error={errors.password} />}
+          </FormInput>
+
+          <ButtonSet>
+            <Button className="wide fill positive" disabled={form.busy} type="submit">Log in</Button>
+          </ButtonSet>
+
+          <Notice error={form.error} />
+        </FormGroup>
+      </form>
+    </Main>
+  )
+}