Преглед изворни кода

Merge pull request #11 from annybs/frontend

Frontend
Anny пре 1 година
родитељ
комит
3e5e79a28e
81 измењених фајлова са 3462 додато и 161 уклоњено
  1. 2 1
      src/auth.ts
  2. 1 1
      src/task/api.ts
  3. 273 10
      web/package-lock.json
  4. 10 1
      web/package.json
  5. 0 42
      web/src/App.css
  6. 0 35
      web/src/App.tsx
  7. 84 0
      web/src/api/account.ts
  8. 68 0
      web/src/api/herd.ts
  9. 4 0
      web/src/api/index.ts
  10. 153 0
      web/src/api/lib.ts
  11. 18 0
      web/src/api/misc.ts
  12. 94 0
      web/src/api/task.ts
  13. 19 0
      web/src/build.ts
  14. 6 0
      web/src/components/Authenticated.scss
  15. 23 0
      web/src/components/Authenticated.tsx
  16. 22 0
      web/src/components/ButtonSet.scss
  17. 14 0
      web/src/components/ButtonSet.tsx
  18. 33 0
      web/src/components/Chip.scss
  19. 22 0
      web/src/components/Chip.tsx
  20. 22 0
      web/src/components/LoadingIndicator.scss
  21. 14 0
      web/src/components/LoadingIndicator.tsx
  22. 40 0
      web/src/components/Main.scss
  23. 14 0
      web/src/components/Main.tsx
  24. 27 0
      web/src/components/Notice.scss
  25. 21 0
      web/src/components/Notice.tsx
  26. 11 0
      web/src/components/Pagination.scss
  27. 51 0
      web/src/components/Pagination.tsx
  28. 13 0
      web/src/components/Placeholder.scss
  29. 14 0
      web/src/components/Placeholder.tsx
  30. 5 0
      web/src/components/Row.scss
  31. 14 0
      web/src/components/Row.tsx
  32. 5 0
      web/src/components/SearchForm.scss
  33. 44 0
      web/src/components/SearchForm.tsx
  34. 21 0
      web/src/components/SortableRow.scss
  35. 29 0
      web/src/components/SortableRow.tsx
  36. 20 0
      web/src/components/button/BackButton.tsx
  37. 113 0
      web/src/components/button/Button.scss
  38. 44 0
      web/src/components/button/Button.tsx
  39. 13 0
      web/src/components/button/CreateButton.tsx
  40. 20 0
      web/src/components/button/DeleteButton.tsx
  41. 13 0
      web/src/components/button/EditButton.tsx
  42. 24 0
      web/src/components/button/HideShowButton.tsx
  43. 31 0
      web/src/components/button/LimitButton.tsx
  44. 13 0
      web/src/components/button/ResetButton.tsx
  45. 13 0
      web/src/components/button/SaveButton.tsx
  46. 29 0
      web/src/components/form/FormGroup.scss
  47. 16 0
      web/src/components/form/FormGroup.tsx
  48. 57 0
      web/src/components/form/FormInput.scss
  49. 19 0
      web/src/components/form/FormInput.tsx
  50. 27 0
      web/src/components/form/GrowingTextInput.scss
  51. 25 0
      web/src/components/form/GrowingTextInput.tsx
  52. 20 0
      web/src/components/form/mixins.scss
  53. 18 0
      web/src/hooks/index.ts
  54. 62 0
      web/src/hooks/routeSearch.ts
  55. 0 68
      web/src/index.css
  56. 28 0
      web/src/index.scss
  57. 56 0
      web/src/layouts/AppLayout.scss
  58. 38 0
      web/src/layouts/AppLayout.tsx
  59. 16 0
      web/src/layouts/app/AccountButton.tsx
  60. 12 0
      web/src/layouts/app/ConnectionStatus.scss
  61. 21 0
      web/src/layouts/app/ConnectionStatus.tsx
  62. 9 0
      web/src/layouts/app/Copyright.tsx
  63. 40 0
      web/src/layouts/app/LoginLogoutButton.tsx
  64. 30 0
      web/src/lib/valueStorage.ts
  65. 30 3
      web/src/main.tsx
  66. 9 0
      web/src/modern-normalize.min.css
  67. 76 0
      web/src/providers/connection.ts
  68. 33 0
      web/src/providers/document.ts
  69. 95 0
      web/src/providers/session.ts
  70. 86 0
      web/src/routes.tsx
  71. 93 0
      web/src/vars.scss
  72. 170 0
      web/src/views/AccountSettingsView.tsx
  73. 7 0
      web/src/views/CreateAccountView.scss
  74. 116 0
      web/src/views/CreateAccountView.tsx
  75. 14 0
      web/src/views/ErrorView.tsx
  76. 13 0
      web/src/views/HerdListView.scss
  77. 145 0
      web/src/views/HerdListView.tsx
  78. 40 0
      web/src/views/HerdView.scss
  79. 399 0
      web/src/views/HerdView.tsx
  80. 7 0
      web/src/views/LoginView.scss
  81. 111 0
      web/src/views/LoginView.tsx

+ 2 - 1
src/auth.ts

@@ -2,6 +2,7 @@ import type { Account } from './account/types'
 import type { Context } from './types'
 import { ObjectId } from 'mongodb'
 import type { WithId } from 'mongodb'
+import { http } from '@edge/misc-utils'
 import jwt from 'jsonwebtoken'
 import type { NextFunction, Request, Response } from 'express'
 
@@ -91,7 +92,7 @@ function createAuth(ctx: Context) {
 
       // Load account
       const account = await ctx.model.account.collection.findOne({ _id })
-      if (!account) throw new Error(`account ${_id.toString()} not found`)
+      if (!account) return http.unauthorized(res, next)
       req.account = account
       next()
     } catch (err) {

+ 1 - 1
src/task/api.ts

@@ -194,7 +194,7 @@ export function searchTasks({ model }: Context): AuthRequestHandler {
 
     try {
       // Get total documents count for filter
-      const totalCount = await model.herd.collection.countDocuments(filter)
+      const totalCount = await model.task.collection.countDocuments(filter)
 
       // Build cursor
       let cursor = model.task.collection.find(filter)

+ 273 - 10
web/package-lock.json

@@ -8,13 +8,22 @@
       "name": "web",
       "version": "0.0.0",
       "dependencies": {
+        "@dnd-kit/core": "^6.1.0",
+        "@dnd-kit/modifiers": "^7.0.0",
+        "@dnd-kit/sortable": "^8.0.0",
+        "@dnd-kit/utilities": "^3.2.2",
+        "@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",
@@ -392,6 +401,68 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@dnd-kit/accessibility": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
+      "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/core": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz",
+      "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==",
+      "dependencies": {
+        "@dnd-kit/accessibility": "^3.1.0",
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/modifiers": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz",
+      "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==",
+      "dependencies": {
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@dnd-kit/core": "^6.1.0",
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/sortable": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
+      "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
+      "dependencies": {
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@dnd-kit/core": "^6.1.0",
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/utilities": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
     "node_modules/@esbuild/android-arm": {
       "version": "0.19.8",
       "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.8.tgz",
@@ -815,6 +886,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 +1010,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 +1215,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 +1262,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 +1567,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 +1600,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 +1622,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 +1704,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 +2227,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 +2280,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 +2383,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 +2429,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 +2452,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 +2463,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 +2674,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 +2796,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 +2892,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 +2916,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 +3042,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 +3133,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 +3192,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"
       },
@@ -2953,6 +3211,11 @@
         "typescript": ">=4.2.0"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+      "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

+ 10 - 1
web/package.json

@@ -10,13 +10,22 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@dnd-kit/core": "^6.1.0",
+    "@dnd-kit/modifiers": "^7.0.0",
+    "@dnd-kit/sortable": "^8.0.0",
+    "@dnd-kit/utilities": "^3.2.2",
+    "@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

+ 84 - 0
web/src/api/account.ts

@@ -0,0 +1,84 @@
+import { request } from './lib'
+import type { Options, SomeRequired, WithId } from './lib'
+
+/** Account data. */
+export interface Account {
+  /** Email address. Used for authentication. */
+  email: string
+  /** Password. Used for authentication. */
+  password: string
+  /** Password salt. Used for authentication. */
+  passwordSalt: string
+}
+
+/** Create account request data. */
+export interface CreateAccountRequest {
+  account: SomeRequired<Account, 'email' | 'password'>
+}
+
+/** Create account response data. */
+export interface CreateAccountResponse {
+  account: WithId<Account>
+}
+
+/** Delete account response data. */
+export interface DeleteAccountResponse {
+  account: WithId<Account>
+  herds: {
+    deletedCount: number
+  }
+  tasks: {
+    deletedCount: number
+  }
+}
+
+/** Get account response data. */
+export interface GetAccountResponse {
+  account: WithId<Account>
+}
+
+/** Account login request data. */
+export interface LoginAccountRequest {
+  account: Pick<Account, 'email' | 'password'>
+}
+
+/** Account login response data. */
+export interface LoginAccountResponse {
+  token: string
+  account: WithId<Account>
+}
+
+/** Update account request data. */
+export interface UpdateAccountRequest {
+  account: Partial<Account>
+}
+
+/** Update account response data. */
+export interface UpdateAccountResponse {
+  account: WithId<Account>
+}
+
+/** Create an account. */
+export async function createAccount(opt: Options, data: CreateAccountRequest): Promise<CreateAccountResponse> {
+  return request(opt, 'POST', '/account', undefined, data)
+}
+
+/** Delete an account. */
+export async function deleteAccount(opt: Options, id?: string): Promise<DeleteAccountResponse> {
+  return request(opt, 'DELETE', id ? `/account/${id}` : '/account')
+}
+
+/** Get an account. */
+export async function getAccount(opt: Options, id?: string): Promise<GetAccountResponse> {
+  return request(opt, 'GET', id ? `/account/${id}` : '/account')
+}
+
+/** Log in to an account. */
+export async function loginAccount(opt: Options, data: LoginAccountRequest): Promise<LoginAccountResponse> {
+  return request(opt, 'POST', '/login/account', undefined, data)
+}
+
+/** Update an account. */
+export async function updateAccount(opt: Options, id: string | undefined, data: UpdateAccountRequest): Promise<UpdateAccountResponse> {
+  return request(opt, 'PUT', id ? `/account/${id}` : '/account', undefined, data)
+}

+ 68 - 0
web/src/api/herd.ts

@@ -0,0 +1,68 @@
+import type { Options, SearchParams, SearchResponse, SomeRequired, WithId } from './lib'
+import { request, writeSearchParams } from './lib'
+
+/** Herd data. */
+export interface Herd {
+  /** Account ID. */
+  _account: string
+  /** Name. */
+  name: string
+}
+
+/** Create herd request data. */
+export interface CreateHerdRequest {
+  herd: SomeRequired<Herd, '_account' | 'name'>
+}
+
+/** Create herd response data. */
+export interface CreateHerdResponse {
+  herd: WithId<Herd>
+}
+
+/** Delete herd response data. */
+export interface DeleteHerdResponse {
+  herd: WithId<Herd>
+  tasks: {
+    deletedCount: number
+  }
+}
+
+/** Get herd response data. */
+export interface GetHerdResponse {
+  herd: WithId<Herd>
+}
+
+/** Update herd request data. */
+export interface UpdateHerdRequest {
+  herd: Partial<Herd>
+}
+
+/** Update herd response data. */
+export interface UpdateHerdResponse {
+  herd: WithId<Herd>
+}
+
+/** Create a herd. */
+export async function createHerd(opt: Options, data: CreateHerdRequest): Promise<CreateHerdResponse> {
+  return request(opt, 'POST', '/herd', undefined, data)
+}
+
+/** Delete a herd. */
+export async function deleteHerd(opt: Options, id: string): Promise<DeleteHerdResponse> {
+  return request(opt, 'DELETE', `/herd/${id}`)
+}
+
+/** Get a herd. */
+export async function getHerd(opt: Options, id: string): Promise<GetHerdResponse> {
+  return request(opt, 'GET', `/herd/${id}`)
+}
+
+/** Search herds. */
+export async function searchHerds(opt: Options, params?: SearchParams): Promise<SearchResponse<GetHerdResponse>> {
+  return request(opt, 'GET', '/herds', params && writeSearchParams(params))
+}
+
+/** Update a herd. */
+export async function updateHerd(opt: Options, id: string, data: UpdateHerdRequest): Promise<UpdateHerdResponse> {
+  return request(opt, 'PUT', `/herd/${id}`, undefined, data)
+}

+ 4 - 0
web/src/api/index.ts

@@ -0,0 +1,4 @@
+export * from './lib'
+
+import * as api from './lib'
+export default api

+ 153 - 0
web/src/api/lib.ts

@@ -0,0 +1,153 @@
+export * from './account'
+export * from './herd'
+export * from './misc'
+export * from './task'
+
+/** Document with an unique ID. */
+export type WithId<T> = T & {
+  /** Document ID. */
+  _id: string
+}
+
+/** HTTP request method. */
+export type Method = 'DELETE' | 'GET' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT'
+
+/** Request options. */
+export interface Options {
+  /** HTTP base URL for Herda Server API. */
+  host: string
+  /** Bearer token for authentication. */
+  token?: string
+  /** HTTP request timeout. */
+  timeout?: number
+}
+
+/**
+ * Request API error.
+ * Corresponds to https://github.com/edge/misc-utils/blob/master/lib/http.ts
+ */
+export class RequestError extends Error {
+  data
+  xhr
+
+  /** Create a request API error. */
+  constructor(message: string, data?: Record<string, unknown>, xhr?: XMLHttpRequest) {
+    super(message)
+    this.name = 'RequestError'
+    this.data = data
+    this.xhr = xhr
+  }
+
+  /**
+   * Create a request API error by parsing a XMLHTTPRequest (which is presumed to have completed).
+   *
+   * If the response is a standard REST error, its message and any additional data will be attached automatically to the RequestError.
+   * See `error` in <https://github.com/edge/misc-utils/blob/master/lib/http.ts> for more detail.
+   *
+   * If the response is not a standard REST error then YMMV.
+   */
+  static parse(xhr: XMLHttpRequest) {
+    let message = xhr.status.toString()
+    let data = undefined
+
+    if (xhr.getResponseHeader('Content-Type')?.startsWith('application/json')) {
+      const res = JSON.parse(xhr.response)
+      if (isObject(res)) {
+        if (res.message && typeof res.message === 'string') message = res.message
+        if (isObject(res.data)) data = res.data
+      }
+    }
+
+    return new this(message, data, xhr)
+  }
+}
+
+export interface SearchParams {
+  limit?: number
+  page?: number
+  search?: string
+  sort?: string[]
+}
+
+export interface SearchResponse<T> {
+  results: T[]
+  metadata: {
+    limit: number
+    page: number
+    totalCount: number
+  }
+}
+
+/** Make properties of T in the union K required, while making other properties optional. */
+export type SomeRequired<T, K extends keyof T> = Partial<T> & Required<Pick<T, K>>
+
+/** Simple object check for internal use only. */
+function isObject(data: unknown) {
+  return typeof data === 'object' && !(data instanceof Array) && data !== null
+}
+
+/** Convert URLSearch params to named search parameters. */
+export function readSearchParams(up: URLSearchParams): SearchParams {
+  const params: SearchParams = {}
+
+  if (up.has('limit')) params.limit = parseInt(up.get('limit') as string)
+  if (up.has('page')) params.page = parseInt(up.get('page') as string)
+  if (up.has('search')) params.search = up.get('search') as string
+
+  if (up.has('sort')) params.sort = up.getAll('sort')
+
+  return params
+}
+
+/**
+ * Perform an HTTP request to Herda Server REST API.
+ * This method should not normally be used directly outside of this package.
+ */
+export function request<T>(opt: Options, method: Method, path: string, params?: URLSearchParams, body?: unknown): Promise<T> {
+  let url = `${opt.host}${path}`
+  if (params) url = `${url}?${params.toString()}`
+
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest()
+
+    xhr.addEventListener('load', () => {
+      if (xhr.status >= 200 && xhr.status < 300) {
+        if (xhr.getResponseHeader('Content-Type')?.startsWith('application/json')) {
+          resolve(JSON.parse(xhr.response))
+        } else resolve(xhr.response)
+      } else reject(RequestError.parse(xhr))
+    })
+
+    xhr.addEventListener('error', reject)
+
+    if (opt.timeout) xhr.timeout = opt.timeout
+
+    xhr.open(method, url)
+
+    xhr.setRequestHeader('Accept', 'application/json')
+    if (opt.token) xhr.setRequestHeader('Authorization', `Bearer ${opt.token}`)
+
+    if (typeof body === 'string') xhr.send(body)
+    else if (typeof body === 'object') {
+      xhr.setRequestHeader('Content-Type', 'application/json')
+      xhr.send(JSON.stringify(body))
+    } else if (body !== undefined) {
+      reject(new Error('invalid body'))
+    } else xhr.send()
+  })
+}
+
+/** Convert named search parameters object to URLSearchParams. */
+export function writeSearchParams(params: SearchParams): URLSearchParams {
+  const up = new URLSearchParams()
+
+  if (params.limit !== undefined) up.append('limit', `${params.limit}`)
+  if (params.page !== undefined) up.append('page', `${params.page}`)
+  if (params.search !== undefined) up.append('search', params.search)
+
+  if (params.sort !== undefined) {
+    for (const sort of params.sort) up.append('sort', sort)
+  }
+
+  return up
+}

+ 18 - 0
web/src/api/misc.ts

@@ -0,0 +1,18 @@
+import type { Options } from './lib'
+import { request } from './lib'
+
+/** Herda Server information. */
+export interface ServerInfo {
+  /** Server name. */
+  product: string
+  /** Server version. */
+  version: string
+}
+
+/**
+ * Get information about Herda Server.
+ * The API is static, so this function can be used to check whether the server is reachable.
+ */
+export function getServerInfo(opt: Options): Promise<ServerInfo> {
+  return request(opt, 'GET', '/')
+}

+ 94 - 0
web/src/api/task.ts

@@ -0,0 +1,94 @@
+import type { Options, SearchParams, SearchResponse, SomeRequired, WithId } from './lib'
+import { request, writeSearchParams } from './lib'
+
+/** Task data. */
+export interface Task {
+  /** Herd ID. */
+  _herd: string
+  /** Account ID reflecting the task assignee. */
+  _account: string
+  /** Description. */
+  description: string
+  /** Position in herd. */
+  position: number
+  /** Flag signifying whether the task is done. */
+  done: boolean
+}
+
+/** Create task request data. */
+export interface CreateTaskRequest {
+  task: SomeRequired<Task, '_herd' | '_account' | 'description'>
+}
+
+/** Create task response data. */
+export interface CreateTaskResponse {
+  task: WithId<Task>
+}
+
+/** Delete task response data. */
+export interface DeleteTaskResponse {
+  task: WithId<Task>
+}
+
+/** Get task response data. */
+export interface GetTaskResponse {
+  task: WithId<Task>
+}
+
+/** Move task response data. */
+export interface MoveTaskResponse {
+  task: WithId<Task>
+  tasks: {
+    affectedCount: number
+  }
+}
+
+/** Toggle task done response data. */
+export interface ToggleTaskDoneResponse {
+  task: WithId<Task>
+}
+
+/** Update task request data. */
+export interface UpdateTaskRequest {
+  task: Partial<Task>
+}
+
+/** Update task response data. */
+export interface UpdateTaskResponse {
+  task: WithId<Task>
+}
+
+/** Create a task. */
+export async function createTask(opt: Options, data: CreateTaskRequest): Promise<CreateTaskResponse> {
+  return request(opt, 'POST', '/task', undefined, data)
+}
+
+/** Delete a task. */
+export async function deleteTask(opt: Options, id: string): Promise<DeleteTaskResponse> {
+  return request(opt, 'DELETE', `/task/${id}`)
+}
+
+/** Get a task. */
+export async function getTask(opt: Options, id: string): Promise<GetTaskResponse> {
+  return request(opt, 'GET', `/task/${id}`)
+}
+
+/** Move a task. */
+export async function moveTask(opt: Options, id: string, position: number): Promise<MoveTaskResponse> {
+  return request(opt, 'PATCH', `/task/${id}/move/${position}`)
+}
+
+/** Search tasks. */
+export async function searchTasks(opt: Options, herd?: string, params?: SearchParams): Promise<SearchResponse<GetTaskResponse>> {
+  return request(opt, 'GET', herd ? `/herd/${herd}/tasks` : '/tasks', params && writeSearchParams(params))
+}
+
+/** Toggle task done status. */
+export async function toggleTaskDone(opt: Options, id: string): Promise<ToggleTaskDoneResponse> {
+  return request(opt, 'PATCH', `/task/${id}/done`)
+}
+
+/** Update a task. */
+export async function updateTask(opt: Options, id: string, data: UpdateTaskRequest): Promise<UpdateTaskResponse> {
+  return request(opt, 'PUT', `/task/${id}`, undefined, data)
+}

+ 19 - 0
web/src/build.ts

@@ -0,0 +1,19 @@
+const build = {
+  api: {
+    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',
+  },
+  localStorage: {
+    prefix: import.meta.env.VITE_LOCAL_STORAGE_PREFIX || 'herda-',
+  },
+}
+
+export default build

+ 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 {
+    @include icon-s;
+    animation: 0.5s linear infinite spin;
+    margin-right: $space-xs;
+  }
+}
+
+@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.max(Math.ceil(totalCount / limit), 1)
+
+  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>
+  )
+}

+ 13 - 0
web/src/components/Placeholder.scss

@@ -0,0 +1,13 @@
+@import '@/vars.scss';
+
+.placeholder {
+  align-items: center;
+  display: flex;
+  flex-direction: column;
+  padding: $space-l;
+  vertical-align: center;
+
+  svg {
+    @include icon-m;
+  }
+}

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

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

+ 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>
+  )
+}

+ 21 - 0
web/src/components/SortableRow.scss

@@ -0,0 +1,21 @@
+@import "@/vars.scss";
+
+.sortable-row {
+  .drag-handle {
+    cursor: move;
+    display: block;
+    margin-right: $space-s;
+
+    svg {
+      @include icon-s;
+      vertical-align: text-top;
+    }
+  }
+
+  &.disabled {
+    .drag-handle svg {
+      fill: var(--color-inactive-fg);
+      cursor: default;
+    }
+  }
+}

+ 29 - 0
web/src/components/SortableRow.tsx

@@ -0,0 +1,29 @@
+import './SortableRow.scss'
+import { Bars3Icon } from '@heroicons/react/20/solid'
+import { CSS } from '@dnd-kit/utilities'
+import type { PropsWithChildren } from 'react'
+import type { RowProps } from './Row'
+import { useSortable } from '@dnd-kit/sortable'
+
+export interface SortableRowProps extends RowProps {
+  disabled?: boolean
+  id: string
+}
+
+export default function SortableRow({ children, className = '', disabled, id }: PropsWithChildren<SortableRowProps>) {
+  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id, disabled })
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+  }
+
+  return (
+    <div className={`row sortable-row ${className} ${disabled ? 'disabled' : ''}`} style={style} {...attributes}>
+      <div className="drag-handle" ref={setNodeRef} {...listeners}>
+        <Bars3Icon/>
+      </div>
+      {children}
+    </div>
+  )
+}

+ 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 {
+    @include icon-s;
+    align-self: center;
+  }
+
+  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>
+  )
+}

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

@@ -0,0 +1,20 @@
+import Button from './Button'
+import { type ButtonProps } from './Button'
+import type { PropsWithChildren } from 'react'
+import { TrashIcon } from '@heroicons/react/20/solid'
+
+export default function DeleteButton({ className = '', confirm, ...props }: PropsWithChildren<ButtonProps>) {
+  const _confirm = confirm || (
+    <>
+      <TrashIcon />
+      <span>Really delete?</span>
+    </>
+  )
+
+  return (
+    <Button className={`delete negative ${className}`} confirm={_confirm} {...props}>
+      <TrashIcon />
+      {props.children ? props.children : <span>Delete</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;
+  }
+}

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

@@ -0,0 +1,18 @@
+import { ConnectionContext } from '@/providers/connection'
+import { DocumentContext } from '@/providers/document'
+import { SessionContext } from '@/providers/session'
+import { useContext } from 'react'
+
+export { useRouteSearch } from './routeSearch'
+
+export function useConnection() {
+  return useContext(ConnectionContext)
+}
+
+export function useDocument() {
+  return useContext(DocumentContext)
+}
+
+export function useSession() {
+  return useContext(SessionContext)
+}

+ 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 {
+    @include icon-s;
+    align-self: center;
+  }
+
+  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>
+  )
+}

+ 30 - 0
web/src/lib/valueStorage.ts

@@ -0,0 +1,30 @@
+export interface ValueStorage<T> {
+  del(): void
+  exists(): boolean
+  get(): T | undefined
+  set(value: T): void
+}
+
+export function localValueStorage(key: string) {
+  function del() {
+    localStorage.removeItem(key)
+  }
+
+  function exists() {
+    return localStorage.getItem(key) !== null
+  }
+
+  function get() {
+    const data = localStorage.getItem(key)
+    if (data === null) return undefined
+    const value = JSON.parse(data)
+    return value
+  }
+
+  function set<T>(value: T) {
+    const data = JSON.stringify(value)
+    localStorage.setItem(key, data)
+  }
+
+  return { del, exists, get, set }
+}

+ 30 - 3
web/src/main.tsx

@@ -1,10 +1,37 @@
-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'
 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,
+  timeout: build.api.timeout,
+}
+
+const documentProps = {
+  titleSuffix: build.document.titleSuffix,
+}
+
+const router = createBrowserRouter(routes)
+
+const sessionProps = {
+  authStorage: localValueStorage(`${build.localStorage.prefix}auth`),
+}
 
 ReactDOM.createRoot(document.getElementById('root')!).render(
   <React.StrictMode>
-    <App />
+    <DocumentProvider value={documentProps}>
+      <ConnectionProvider value={connectionProps}>
+        <SessionProvider value={sessionProps}>
+          <RouterProvider router={router} />
+        </SessionProvider>
+      </ConnectionProvider>
+    </DocumentProvider>
   </React.StrictMode>,
 )

+ 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 */

+ 76 - 0
web/src/providers/connection.ts

@@ -0,0 +1,76 @@
+import type { ProviderProps } from 'react'
+import api from '@/api'
+import { createContext, createElement, useEffect, useReducer, useState } from 'react'
+
+/**
+ * API connection state.
+ */
+export interface ConnectionState {
+  /** API server availability. */
+  available: boolean
+  /** API connection error. */
+  error: Error | undefined
+  /** API server information. */
+  info: api.ServerInfo | undefined
+  /** API client options. Should be used in all API requests. */
+  options: api.Options
+
+  /** Connect to the server and store product information. */
+  connect(): Promise<void>
+  /** Set the bearer token for use in API requests. Pass undefined to clear the bearer token. */
+  setToken(token: string | undefined): void
+}
+
+/**
+ * Action to mutate API client options.
+ */
+export type OptionsAction = ['setToken', string | undefined]
+
+/** API client options context. */
+export const ConnectionContext = createContext({} as ConnectionState)
+
+/**
+ * API connection provider.
+ * This should be one of the root components of the application.
+ */
+export function ConnectionProvider({ children, value: props }: ProviderProps<api.Options>) {
+  const [available, setAvailable] = useState(false)
+  const [error, setError] = useState<Error>()
+  const [info, setInfo] = useState<api.ServerInfo>()
+  const [options, dispatchOptions] = useReducer(OptionsReducer, props)
+
+  async function connect() {
+    try {
+      const res = await api.getServerInfo(options)
+      setInfo(res)
+      setAvailable(true)
+    } catch (err) {
+      setError(err as Error)
+      setAvailable(false)
+    }
+  }
+
+  function setToken(token: string | undefined) {
+    return dispatchOptions(['setToken', token])
+  }
+
+  useEffect(() => {
+    connect()
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  const value = {
+    available, error, info, options,
+    connect, setToken,
+  }
+  return createElement(ConnectionContext.Provider, { value }, children)
+}
+
+/** API client options reducer. */
+function OptionsReducer(state: api.Options, [task, data]: OptionsAction) {
+  if (task === 'setToken') {
+    state.token = data
+    return state
+  }
+  throw new Error('Invalid OptionsAction')
+}

+ 33 - 0
web/src/providers/document.ts

@@ -0,0 +1,33 @@
+import type { ProviderProps} from 'react'
+import { createContext, createElement, useEffect, useState } from 'react'
+
+export interface DocumentProps {
+  titleSuffix: string
+}
+
+export interface DocumentState {
+  clearTitle(): void
+  setTitle(title: string): void
+}
+
+export const DocumentContext = createContext({} as DocumentState)
+
+export function DocumentProvider({ children, value: params }: ProviderProps<DocumentProps>) {
+  const [title, setTitle] = useState<string>()
+
+  function clearTitle() {
+    setTitle(undefined)
+  }
+
+  useEffect(() => {
+    if (title) {
+      document.title = `${title} - ${params.titleSuffix}`
+    } else {
+      document.title = params.titleSuffix
+    }
+  }, [params.titleSuffix, title])
+
+  const value = { clearTitle, setTitle }
+
+  return createElement(DocumentContext.Provider, { value }, children)
+}

+ 95 - 0
web/src/providers/session.ts

@@ -0,0 +1,95 @@
+import type { ProviderProps } from 'react'
+import type { ValueStorage } from '@/lib/valueStorage'
+import api from '@/api'
+import { useConnection } from '@/hooks'
+import { createContext, createElement, useLayoutEffect, useState } from 'react'
+
+export interface SessionData {
+  account?: api.WithId<api.Account>
+  loggedIn?: boolean
+  ready?: boolean
+}
+
+export interface SessionProps {
+  authStorage: ValueStorage<api.LoginAccountResponse>
+}
+
+export interface SessionState extends SessionData {
+  heartbeat(): Promise<void>
+  login(email: string, password: string): Promise<void>
+  logout(): void
+}
+
+export function SessionProvider({ children, value: { authStorage } }: ProviderProps<SessionProps>) {
+  const { options, setToken } = useConnection()
+
+  const [account, setAccount] = useState<api.WithId<api.Account>>()
+  const [loggedIn, setLoggedIn] = useState(false)
+  const [ready, setReady] = useState(false)
+
+  function reset() {
+    authStorage.del()
+    setAccount(undefined)
+    setToken(undefined)
+    setLoggedIn(false)
+  }
+
+  async function heartbeat(token?: string) {
+    try {
+      const res = await api.getAccount({ ...options, token: token || options.token })
+      setAccount(res.account)
+      if (token) setToken(token)
+      setLoggedIn(true)
+    } catch (err) {
+      const status = (err as api.RequestError).xhr?.status || 500
+      // If the response code indicates a client-side issue, reset session state
+      if (status >= 400 && status < 500) {
+        reset()
+      }
+      throw err
+    }
+  }
+
+  async function login(email: string, password: string) {
+    const res = await api.loginAccount(options, {
+      account: { email, password },
+    })
+
+    authStorage.set(res)
+
+    setAccount(res.account)
+    setToken(res.token)
+    setLoggedIn(true)
+  }
+
+  function logout() {
+    reset()
+  }
+
+  // Check whether a session exists in storage, and if so, send a heartbeat.
+  // This effect triggers once only when the component is mounted.
+  useLayoutEffect(() => {
+    if (!ready) {
+      const auth = authStorage.get()
+      if (auth?.token) {
+        heartbeat(auth.token)
+          .catch(err => {
+            console.log(err)
+          })
+          .finally(() => {
+            setReady(true)
+          })
+      } else setReady(true)
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  const value = {
+    account, loggedIn, ready,
+    heartbeat, login, logout,
+  }
+
+  return createElement(SessionContext.Provider, { value }, children)
+}
+
+export const SessionContext = createContext({} as SessionState)

+ 86 - 0
web/src/routes.tsx

@@ -0,0 +1,86 @@
+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'
+import CreateAccountView from './views/CreateAccountView'
+import AccountSettingsView from './views/AccountSettingsView'
+
+const coreRoutes: RouteObject[] = [
+  {
+    path: '',
+    element: <HerdListView />,
+  },
+  {
+    path: '/account/settings',
+    element: <AccountSettingsView />,
+  },
+  {
+    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 />,
+      },
+    ],
+  },
+  {
+    path: '/account/create',
+    element: (
+      <AppLayout>
+        <Outlet />
+      </AppLayout>
+    ),
+    errorElement: (
+      <AppLayout>
+        <ErrorView />
+      </AppLayout>
+    ),
+    children: [
+      {
+        path: '',
+        element: <CreateAccountView />,
+      },
+    ],
+  },
+]
+
+export default routes

+ 93 - 0
web/src/vars.scss

@@ -0,0 +1,93 @@
+$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) {
+    --color-bg-alt: #d8d8d8;
+    --color-bg: #ececec;
+    --color-border: #b5b5b5;
+    --color-cta: #449055;
+    --color-focus: #222222;
+    --color-inactive: #494949;
+    --color-inactive-fg: #909090;
+    --color-negative-fg: #dddddd;
+    --color-negative: #d84c4c;
+    --color-fg-alt: #494949;
+    --color-fg: #343434;
+    --color-positive-fg: #dddddd;
+    --color-positive: #449055;
+    --color-shadow: #cccccc;
+    --color-warn-fg: #343434;
+    --color-warn: #f3af2f;
+  }
+}
+
+@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
+  );
+}
+
+@mixin icon-s {
+  width: 20px;
+}
+
+@mixin icon-m {
+  width: 40px;
+}

+ 170 - 0
web/src/views/AccountSettingsView.tsx

@@ -0,0 +1,170 @@
+import ButtonSet from '@/components/ButtonSet'
+import Chip from '@/components/Chip'
+import CreateButton from '@/components/button/CreateButton'
+import FormGroup from '@/components/form/FormGroup'
+import FormInput from '@/components/form/FormInput'
+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 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 { useForm } from 'react-hook-form'
+import { useNavigate } from 'react-router-dom'
+import { useRouteSearch } from '@/hooks'
+import { useCallback, useEffect, useState } from 'react'
+import { useConnection, useSession } from '@/hooks'
+import Placeholder from '@/components/Placeholder'
+import { CloudIcon, InboxIcon, TrashIcon } from '@heroicons/react/20/solid'
+import HideShowButton from '@/components/button/HideShowButton'
+import Button from '@/components/button/Button'
+import DeleteButton from '@/components/button/DeleteButton'
+
+interface AccountUpdateFormData extends Pick<api.Account, 'email' | 'password'> {}
+
+function useHerdCreateForm() {
+  const form = useForm<AccountUpdateFormData>({ mode: 'onBlur' })
+
+  const inputs = {
+    email: form.register('email', { validate: {
+      required: value => value.length >= 1 || 'Required',
+    }}),
+    password: form.register('password', { validate: value => {
+      if (!value) return
+      if (value.length < 8) return 'Must be at least 8 characters'
+    }}),
+  }
+
+  return { ...form, inputs }
+}
+
+export default function AccountSettingsView() {
+  const { account, ...session } = useSession()
+  const updateAccountForm = useHerdCreateForm()
+  const navigate = useNavigate()
+  const { options } = useConnection()
+
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState<Error>()
+  const [passwordVisible, setPasswordVisible] = useState(false)
+
+  async function deleteAccount() {
+    if (busy || !account) return
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      await api.deleteAccount(options)
+      session.logout()
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  function resetAccountUpdate() {
+    if (!account) return
+
+    updateAccountForm.reset({
+      email: account.email,
+      password: '',
+    })
+  }
+
+  async function updateAccount(data: AccountUpdateFormData) {
+    if (busy || !account) return
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      await api.updateAccount(options, account._id, {
+        account: {
+          email: data.email || undefined,
+          password: data.password || undefined,
+        },
+      })
+      await session.heartbeat()
+      resetAccountUpdate()
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  useEffect(() => {
+    resetAccountUpdate()
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  return account && (
+    <Main>
+      <header>
+        <h1>Your account</h1>
+
+        <ButtonSet>
+          <Button className="fill" onClick={() => navigate('/')}>
+            <InboxIcon />
+            <span>Back to Herds</span>
+          </Button>
+        </ButtonSet>
+      </header>
+
+      <Notice error={error} />
+
+      <form onSubmit={updateAccountForm.handleSubmit(updateAccount)}>
+        <FormGroup name="Settings">
+          <p>You can update your login details below.</p>
+
+          <FormInput id="email" label="Email address">
+            <input id="email" type="text" {...updateAccountForm.inputs.email} />
+            <Chip className="mini" error={updateAccountForm.formState.errors.email} />
+          </FormInput>
+
+          <FormInput id="password" label="Password">
+            <Row className="hidden">
+              <input id="password" type={passwordVisible ? 'text' : 'password'} {...updateAccountForm.inputs.password} />
+              <ButtonSet>
+                <HideShowButton visible={passwordVisible} onClick={() => setPasswordVisible(!passwordVisible)} />
+              </ButtonSet>
+            </Row>
+            {updateAccountForm.formState.errors.password && <Chip className="mini" error={updateAccountForm.formState.errors.password} />}
+          </FormInput>
+
+          <ButtonSet>
+            <SaveButton type="submit" className="fill" />
+            <ResetButton onClick={resetAccountUpdate} />
+          </ButtonSet>
+        </FormGroup>
+      </form>
+
+      <FormGroup name="Danger zone">
+        <p>
+          If you would like to stop using Herda, you can delete your account and all your data.
+        </p>
+
+        <Notice className="warn">Account deletion is not reversible. Proceed with care.</Notice>
+
+        <ButtonSet>
+          <DeleteButton
+            className="fill"
+            confirm={(
+              <>
+                <TrashIcon />
+                <span>Yes, really delete my account!</span>
+              </>
+            )}
+            onClick={deleteAccount}
+          >
+            <span>Delete my account</span>
+          </DeleteButton>
+        </ButtonSet>
+      </FormGroup>
+    </Main>
+  )
+}

+ 7 - 0
web/src/views/CreateAccountView.scss

@@ -0,0 +1,7 @@
+@import '@/vars.scss';
+
+.create-account {
+  font-size: $font-size-s;
+  margin-top: $space-l;
+  text-align: center;
+}

+ 116 - 0
web/src/views/CreateAccountView.tsx

@@ -0,0 +1,116 @@
+import './CreateAccountView.scss'
+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 { Link } from 'react-router-dom'
+import Main from '@/components/Main'
+import Notice from '@/components/Notice'
+import Row from '@/components/Row'
+import type { SubmitHandler } from 'react-hook-form'
+import api from '@/api'
+import { useForm } from 'react-hook-form'
+import { useSession } from '@/hooks'
+import { Navigate, useSearchParams } from 'react-router-dom'
+import { useConnection, useDocument } from '@/hooks'
+import { useEffect, useState } from 'react'
+
+interface AccountCreateFormData {
+  email: string
+  password: string
+}
+
+function useAccountCreateForm() {
+  const form = useForm<AccountCreateFormData>()
+
+  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 CreateAccountView() {
+  const doc = useDocument()
+  const [params] = useSearchParams()
+  const { options } = useConnection()
+  const session = useSession()
+
+  const { formState: { errors }, ...form } = useAccountCreateForm()
+
+  const [passwordVisible, setPasswordVisible] = useState(false)
+  const redirectTo = params.get('redirect') || '/'
+
+  const submit: SubmitHandler<AccountCreateFormData> = async ({ email, password }) => {
+    form.setError(undefined)
+    form.setBusy(true)
+    try {
+      await api.createAccount(options, {
+        account: { email, password },
+      })
+      await session.login(email, password)
+    } catch (err) {
+      form.setError(err as Error)
+    } finally {
+      form.setBusy(false)
+    }
+  }
+
+  useEffect(() => {
+    doc.setTitle('Create Account')
+  }, [doc])
+
+  if (session.ready && session.loggedIn) {
+    return (
+      <Navigate to={redirectTo} />
+    )
+  }
+
+  return (
+    <Main className="center">
+      <form onSubmit={form.handleSubmit(submit)}>
+        <FormGroup name="Create Account">
+          <FormInput id="email" label="Email address">
+            <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">Create and log in</Button>
+          </ButtonSet>
+
+          <Notice error={form.error} />
+
+          <section className="create-account">
+            Have an account already? <Link to="/login">Log in</Link>
+          </section>
+        </FormGroup>
+      </form>
+    </Main>
+  )
+}

+ 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);
+  }
+}

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

@@ -0,0 +1,145 @@
+import './HerdListView.scss'
+import ButtonSet from '@/components/ButtonSet'
+import Chip from '@/components/Chip'
+import CreateButton from '@/components/button/CreateButton'
+import FormGroup from '@/components/form/FormGroup'
+import FormInput from '@/components/form/FormInput'
+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 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 { useForm } from 'react-hook-form'
+import { useNavigate } from 'react-router-dom'
+import { useRouteSearch } from '@/hooks'
+import { useCallback, useEffect, useState } from 'react'
+import { useConnection, useSession } from '@/hooks'
+import Placeholder from '@/components/Placeholder'
+import { CloudIcon } from '@heroicons/react/20/solid'
+
+interface HerdCreateFormData extends Pick<api.Herd, 'name'> {}
+
+function useHerdCreateForm() {
+  const form = useForm<HerdCreateFormData>({ mode: 'onBlur' })
+
+  const inputs = {
+    name: form.register('name', { validate: value => {
+      if (value.length < 1) return 'Required'
+    }}),
+  }
+
+  return { ...form, inputs }
+}
+
+export default function ListHerds() {
+  const { account } = useSession()
+  const createHerdForm = useHerdCreateForm()
+  const navigate = useNavigate()
+  const { options } = useConnection()
+  const { searchParams } = useRouteSearch()
+
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState<Error>()
+  const [loading, setLoading] = useState(true)
+  const [result, setResult] = useState<api.SearchResponse<api.GetHerdResponse>>()
+
+  async function createHerd(data: HerdCreateFormData) {
+    if (busy || !account) return
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      const res = await api.createHerd(options, {
+        herd: {
+          _account: account._id,
+          name: data.name,
+        },
+      })
+      navigate(`/herd/${res.herd._id}`)
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  const reload = useCallback(async () => {
+    setLoading(true)
+    setError(undefined)
+    try {
+      const res = await api.searchHerds(options, searchParams)
+      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.metadata.totalCount > 0 ? (
+        <>
+          {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} />
+        </>
+      ) : (
+        <Placeholder>
+          <CloudIcon />
+          <span>No herds!</span>
+        </Placeholder>
+      )}
+
+      <form onSubmit={createHerdForm.handleSubmit(createHerd)}>
+        <FormGroup name="Create a new herd">
+          <FormInput id="herd:name" label="Name">
+            <input id="herd:name" type="text" {...createHerdForm.inputs.name} />
+            <Chip className="mini" error={createHerdForm.formState.errors.name} />
+          </FormInput>
+
+          <ButtonSet>
+            <SaveButton type="submit" className="fill" />
+            <ResetButton type="reset" />
+          </ButtonSet>
+        </FormGroup>
+      </form>
+    </Main>
+  )
+}

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

@@ -0,0 +1,40 @@
+@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 $space-s $space-sm;
+
+  .position {
+    background: var(--color-bg);
+    border-radius: $radius-l;
+    margin-right: $space-s;
+    padding: 0 $space-s;
+    text-align: center;
+  }
+
+  .description {
+    flex-grow: 1;
+  }
+
+  &.done {
+    .description {
+      color: var(--color-inactive-fg);
+    }
+  }
+
+  .edit-task {
+    margin-right: $space-m;
+
+    .input {
+      flex-grow: 1;
+      margin-right: $space-s;
+    }
+  }
+
+  button span {
+    display: none;
+  }
+}

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

@@ -0,0 +1,399 @@
+import './HerdView.scss'
+import BackButton from '@/components/button/BackButton'
+import Button from '@/components/button/Button'
+import ButtonSet from '@/components/ButtonSet'
+import Chip from '@/components/Chip'
+import CreateButton from '@/components/button/CreateButton'
+import DeleteButton from '@/components/button/DeleteButton'
+import { DndContext } from '@dnd-kit/core'
+import type { DragEndEvent } from '@dnd-kit/core'
+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 Placeholder from '@/components/Placeholder'
+import ResetButton from '@/components/button/ResetButton'
+import Row from '@/components/Row'
+import SaveButton from '@/components/button/SaveButton'
+import SearchForm from '@/components/SearchForm'
+import SortableRow from '@/components/SortableRow'
+import type { SubmitHandler } from 'react-hook-form'
+import api from '@/api'
+import { useForm } from 'react-hook-form'
+import { CheckCircleIcon, CloudIcon, XCircleIcon, XMarkIcon } from '@heroicons/react/20/solid'
+import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
+import { useCallback, useEffect, useState } from 'react'
+import { useConnection, useRouteSearch, useSession } from '@/hooks'
+import { useNavigate, useParams } from 'react-router-dom'
+
+interface HerdUpdateFormData extends Pick<api.Herd, 'name'> {}
+
+interface TaskCreateFormData extends Pick<api.Task, 'description'> {}
+
+interface TaskUpdateFormData 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 }
+}
+
+function useTaskUpdateForm() {
+  const form = useForm<TaskUpdateFormData>({ 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 createTaskForm = useTaskCreateForm()
+  const { id } = useParams()
+  const navigate = useNavigate()
+  const { options } = useConnection()
+  const updateHerdForm = useHerdUpdateForm()
+  const updateTaskForm = useTaskUpdateForm()
+  const { limit, page, searchParams, setPage } = useRouteSearch()
+
+  const [data, setData] = useState<api.GetHerdResponse>()
+  const [taskData, setTaskData] = useState<api.SearchResponse<api.GetTaskResponse>>()
+
+  const [busy, setBusy] = useState(false)
+  const [editing, setEditing] = useState<string>()
+  const [error, setError] = useState<Error>()
+  const [loading, setLoading] = useState(false)
+
+  const disableSorting = Boolean(searchParams.search)
+
+  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)
+    }
+  }
+
+  async function deleteHerd() {
+    if (busy || !data) return
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      await api.deleteHerd(options, data.herd._id)
+      navigate('/')
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function deleteTask(task: api.WithId<api.Task>) {
+    if (busy) return
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      await api.deleteTask(options, task._id)
+      // Reload current page
+      const taskRes = await api.searchTasks(options, id, searchParams)
+      setTaskData(taskRes)
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function moveTask({ active, over }: DragEndEvent) {
+    if (busy || !taskData || !over || active.id === over.id) return
+
+    const activeIdx = taskData.results.findIndex(({ task }) => task._id === active.id)
+    const overIdx = taskData.results.findIndex(({ task }) => task._id === over.id)
+    if (activeIdx < 0 || overIdx < 0) return
+    const target = taskData.results[overIdx]
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      const update = await api.moveTask(options, active.id.toString(), target.task.position)
+      // Hot reorder tasks
+      const results = activeIdx < overIdx
+        // Task moved down
+        ? [
+          ...taskData.results.slice(0, overIdx + 1).filter(({ task }) => task._id !== update.task._id),
+          update,
+          ...taskData.results.slice(overIdx +1),
+        ]
+        // Task moved up
+        : [
+          ...taskData.results.slice(0, overIdx),
+          update,
+          ...taskData.results.slice(overIdx).filter(({ task }) => task._id !== update.task._id),
+        ]
+      setTaskData({ ...taskData, results })
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function toggleTaskDone(task: api.WithId<api.Task>) {
+    try {
+      setBusy(true)
+      setError(undefined)
+      const update = await api.toggleTaskDone(options, task._id)
+      const inPageTask = taskData?.results.find(({ task }) => task._id === update.task._id)
+      if (inPageTask) inPageTask.task.done = update.task.done
+    } 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])
+
+  function setTaskToEdit(task?: api.WithId<api.Task>) {
+    if (task) {
+      setEditing(task._id)
+      updateTaskForm.reset({ description: task.description })
+    } else {
+      setEditing(undefined)
+    }
+  }
+
+  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)
+    }
+  }
+
+  function updateTask(task: api.WithId<api.Task>): SubmitHandler<TaskUpdateFormData> {
+    return async function(data) {
+      if (busy) return
+
+      try {
+        setBusy(true)
+        setError(undefined)
+        const update = await api.updateTask(options, task._id, { task: data })
+        const inPageTask = taskData?.results.find(({ task }) => task._id === update.task._id)
+        if (inPageTask) {
+          inPageTask.task.description = update.task.description
+        }
+        setEditing(undefined)
+      } 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('/')} />
+          <DeleteButton onClick={deleteHerd} />
+        </ButtonSet>
+      </header>
+
+      <SearchForm />
+
+      <Notice error={error} />
+
+      {taskData && (
+        <>
+          {taskData.metadata.totalCount > 0 ? (
+            <DndContext onDragEnd={moveTask}>
+              <SortableContext
+                items={taskData.results.map(({ task }) => task._id)}
+                strategy={verticalListSortingStrategy}
+              >
+                {taskData.results.map(({ task }, i) => (
+                  <SortableRow key={task._id} id={task._id} className={`task ${task.done ? 'done' : 'not-done'}`} disabled={disableSorting}>
+                    <div className="position">{i + 1 + ((page - 1) * limit)}</div>
+                    <div className="description">
+                      {editing === task._id ? (
+                        <form onSubmit={updateTaskForm.handleSubmit(updateTask(task))}>
+                          <Row className="edit-task">
+                            <FormInput>
+                              <input type="text" autoFocus {...updateTaskForm.inputs.description} />
+                            </FormInput>
+                            <ButtonSet>
+                              <SaveButton type="submit" className="mini" />
+                              <Button onClick={() => setTaskToEdit(undefined)} className="mini">
+                                <XMarkIcon />
+                                <span>Cancel</span>
+                              </Button>
+                            </ButtonSet>
+                          </Row>
+                        </form>
+                      ) : (
+                        <span onClick={() => setTaskToEdit(task)}>{task.description}</span>
+                      )}
+                    </div>
+                    <ButtonSet>
+                      {task.done ? (
+                        <Button className="positive mini fill" onClick={() => toggleTaskDone(task)}>
+                          <CheckCircleIcon />
+                          <span>Done</span>
+                        </Button>
+                      ) : (
+                        <Button className="negative mini" onClick={() => toggleTaskDone(task)}>
+                          <XCircleIcon />
+                          <span>Not done</span>
+                        </Button>
+                      )}
+                      <DeleteButton className="mini" onClick={() => deleteTask(task)} />
+                    </ButtonSet>
+                  </SortableRow>
+                ))}
+              </SortableContext>
+            </DndContext>
+          ) : (
+            <Placeholder>
+              <CloudIcon />
+              <span>No tasks!</span>
+            </Placeholder>
+          )}
+
+          <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" className="fill" />
+                <ResetButton type="reset" />
+              </ButtonSet>
+            </FormGroup>
+          </form>
+        </>
+      )}
+
+    </Main>
+  )
+}

+ 7 - 0
web/src/views/LoginView.scss

@@ -0,0 +1,7 @@
+@import '@/vars.scss';
+
+.create-account {
+  font-size: $font-size-s;
+  margin-top: $space-l;
+  text-align: center;
+}

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

@@ -0,0 +1,111 @@
+import './LoginView.scss'
+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 { Link } from 'react-router-dom'
+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="Email address">
+            <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} />
+
+          <section className="create-account">
+            Don't have an account yet? <Link to="/account/create">Create one</Link>
+          </section>
+        </FormGroup>
+      </form>
+    </Main>
+  )
+}