1
0

12 Коммиты 8f504452e6 ... 01241a90d2

Автор SHA1 Сообщение Дата
  Lee Morgan 01241a90d2 Create the layout and style for the user dashboard page 2 недель назад
  Lee Morgan 67c08147fc Add styling for new recipe page 2 недель назад
  Lee Morgan 3816e025ce Create the html and some code for the new recipe page 2 недель назад
  Lee Morgan 71b2fe3894 Fetch recipes from the database 3 недель назад
  Lee Morgan 9f58124051 Create recipe schema 3 недель назад
  Lee Morgan 0889d007a8 Autofocus search and login inputs on page load 3 недель назад
  Lee Morgan 3be220daa5 Add logout route/button 3 недель назад
  Lee Morgan 37c8091262 Add dashboard button to home page if already logged in 3 недель назад
  Lee Morgan b39023d051 Create login, create 404 page 3 недель назад
  Lee Morgan 7f33191361 Create notifier 3 недель назад
  Lee Morgan b1c33b1e87 Create notifications system without style 3 недель назад
  Lee Morgan d2a387e8d5 New user creation now functional 3 недель назад

+ 2 - 0
example.env

@@ -0,0 +1,2 @@
+MONGODB_URI
+JWT_SECRET

+ 392 - 1
package-lock.json

@@ -7,6 +7,12 @@
 		"": {
 			"name": "recipes-leemorgan-dev",
 			"version": "0.0.1",
+			"dependencies": {
+				"bcrypt": "^6.0.0",
+				"jsonwebtoken": "^9.0.3",
+				"mongoose": "^9.9.4",
+				"validator": "^13.15.35"
+			},
 			"devDependencies": {
 				"@sveltejs/adapter-auto": "^7.0.1",
 				"@sveltejs/kit": "^2.63.0",
@@ -65,6 +71,15 @@
 				"@jridgewell/sourcemap-codec": "^1.4.14"
 			}
 		},
+		"node_modules/@mongodb-js/saslprep": {
+			"version": "1.5.0",
+			"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.0.tgz",
+			"integrity": "sha512-Hk1SKJCMcCos38+vqDnZzlIo4XRj9yCGzYkjB4LcqpeXRIYfia1UWTz+VrueLxoU+uSRJzgkufxoRZg8gi52YA==",
+			"license": "MIT",
+			"dependencies": {
+				"sparse-bitfield": "^3.0.3"
+			}
+		},
 		"node_modules/@oxc-project/types": {
 			"version": "0.147.0",
 			"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
@@ -366,7 +381,6 @@
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
 			"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
-			"dev": true,
 			"license": "MIT"
 		},
 		"node_modules/@sveltejs/acorn-typescript": {
@@ -482,6 +496,21 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/@types/webidl-conversions": {
+			"version": "7.0.3",
+			"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
+			"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
+			"license": "MIT"
+		},
+		"node_modules/@types/whatwg-url": {
+			"version": "13.0.0",
+			"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
+			"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
+			"license": "MIT",
+			"dependencies": {
+				"@types/webidl-conversions": "*"
+			}
+		},
 		"node_modules/acorn": {
 			"version": "8.18.0",
 			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -515,6 +544,35 @@
 				"node": ">= 0.4"
 			}
 		},
+		"node_modules/bcrypt": {
+			"version": "6.0.0",
+			"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+			"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+			"hasInstallScript": true,
+			"license": "MIT",
+			"dependencies": {
+				"node-addon-api": "^8.3.0",
+				"node-gyp-build": "^4.8.4"
+			},
+			"engines": {
+				"node": ">= 18"
+			}
+		},
+		"node_modules/bson": {
+			"version": "7.3.2",
+			"resolved": "https://registry.npmjs.org/bson/-/bson-7.3.2.tgz",
+			"integrity": "sha512-1w0ra+ho1cuE+w8jzwgzTFIimFtCfZeCoOsvIPQg6uyFCsp8M29U7bNNf5GrFh88TXbYg1g3TyKUGpd6O7q6zA==",
+			"license": "Apache-2.0",
+			"engines": {
+				"node": ">=20.19.0"
+			}
+		},
+		"node_modules/buffer-equal-constant-time": {
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+			"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+			"license": "BSD-3-Clause"
+		},
 		"node_modules/clsx": {
 			"version": "2.1.1",
 			"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -562,6 +620,15 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/ecdsa-sig-formatter": {
+			"version": "1.0.11",
+			"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+			"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+			"license": "Apache-2.0",
+			"dependencies": {
+				"safe-buffer": "^5.0.1"
+			}
+		},
 		"node_modules/esm-env": {
 			"version": "1.2.2",
 			"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -630,6 +697,58 @@
 				"@types/estree": "^1.0.6"
 			}
 		},
+		"node_modules/jsonwebtoken": {
+			"version": "9.0.3",
+			"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+			"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+			"license": "MIT",
+			"dependencies": {
+				"jws": "^4.0.1",
+				"lodash.includes": "^4.3.0",
+				"lodash.isboolean": "^3.0.3",
+				"lodash.isinteger": "^4.0.4",
+				"lodash.isnumber": "^3.0.3",
+				"lodash.isplainobject": "^4.0.6",
+				"lodash.isstring": "^4.0.1",
+				"lodash.once": "^4.0.0",
+				"ms": "^2.1.1",
+				"semver": "^7.5.4"
+			},
+			"engines": {
+				"node": ">=12",
+				"npm": ">=6"
+			}
+		},
+		"node_modules/jwa": {
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+			"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+			"license": "MIT",
+			"dependencies": {
+				"buffer-equal-constant-time": "^1.0.1",
+				"ecdsa-sig-formatter": "1.0.11",
+				"safe-buffer": "^5.0.1"
+			}
+		},
+		"node_modules/jws": {
+			"version": "4.0.1",
+			"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+			"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+			"license": "MIT",
+			"dependencies": {
+				"jwa": "^2.0.1",
+				"safe-buffer": "^5.0.1"
+			}
+		},
+		"node_modules/kareem": {
+			"version": "3.3.0",
+			"resolved": "https://registry.npmjs.org/kareem/-/kareem-3.3.0.tgz",
+			"integrity": "sha512-kpSuLD3/7RenBnjnJdOHXCKC8dTd1JzeOiJhN0necWWci6cC+qX+VuwPnMVgb+a4+KNJSfgqahpnfWaeDXCimw==",
+			"license": "Apache-2.0",
+			"engines": {
+				"node": ">=18.0.0"
+			}
+		},
 		"node_modules/kleur": {
 			"version": "4.1.5",
 			"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -920,6 +1039,48 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/lodash.includes": {
+			"version": "4.3.0",
+			"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+			"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isboolean": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+			"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isinteger": {
+			"version": "4.0.4",
+			"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+			"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isnumber": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+			"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isplainobject": {
+			"version": "4.0.6",
+			"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+			"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.isstring": {
+			"version": "4.0.1",
+			"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+			"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+			"license": "MIT"
+		},
+		"node_modules/lodash.once": {
+			"version": "4.1.1",
+			"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+			"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+			"license": "MIT"
+		},
 		"node_modules/magic-string": {
 			"version": "0.30.21",
 			"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -930,6 +1091,111 @@
 				"@jridgewell/sourcemap-codec": "^1.5.5"
 			}
 		},
+		"node_modules/memory-pager": {
+			"version": "1.5.0",
+			"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+			"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+			"license": "MIT"
+		},
+		"node_modules/mongodb": {
+			"version": "7.5.0",
+			"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz",
+			"integrity": "sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==",
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@mongodb-js/saslprep": "^1.4.11",
+				"bson": "^7.2.0",
+				"mongodb-connection-string-url": "^7.0.1"
+			},
+			"engines": {
+				"node": ">=20.19.0"
+			},
+			"peerDependencies": {
+				"@aws-sdk/credential-providers": "^3.806.0",
+				"@mongodb-js/zstd": "^7.0.0",
+				"gcp-metadata": "^7.0.1",
+				"kerberos": "^7.0.0",
+				"mongodb-client-encryption": "^7.2.0",
+				"snappy": "^7.3.2",
+				"socks": "^2.8.6"
+			},
+			"peerDependenciesMeta": {
+				"@aws-sdk/credential-providers": {
+					"optional": true
+				},
+				"@mongodb-js/zstd": {
+					"optional": true
+				},
+				"gcp-metadata": {
+					"optional": true
+				},
+				"kerberos": {
+					"optional": true
+				},
+				"mongodb-client-encryption": {
+					"optional": true
+				},
+				"snappy": {
+					"optional": true
+				},
+				"socks": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/mongodb-connection-string-url": {
+			"version": "7.0.2",
+			"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz",
+			"integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==",
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@types/whatwg-url": "^13.0.0",
+				"whatwg-url": "^14.1.0"
+			},
+			"engines": {
+				"node": ">=20.19.0"
+			}
+		},
+		"node_modules/mongoose": {
+			"version": "9.9.4",
+			"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.9.4.tgz",
+			"integrity": "sha512-Gc7buf0ExrOZ3t8MD8tVzTek7Qr5d+tEwj4GRFmHCtTZvpaDb38EVsXgCkYacPL9ICAftgS+FGe+dQHLRLHhcw==",
+			"license": "MIT",
+			"dependencies": {
+				"@standard-schema/spec": "^1.1.0",
+				"kareem": "3.3.0",
+				"mongodb": "~7.5",
+				"mpath": "0.9.0",
+				"mquery": "6.0.0",
+				"ms": "2.1.3",
+				"sift": "17.1.3"
+			},
+			"engines": {
+				"node": ">=20.19.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/mongoose"
+			}
+		},
+		"node_modules/mpath": {
+			"version": "0.9.0",
+			"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
+			"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=4.0.0"
+			}
+		},
+		"node_modules/mquery": {
+			"version": "6.0.0",
+			"resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz",
+			"integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=20.19.0"
+			}
+		},
 		"node_modules/mrmime": {
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -940,6 +1206,12 @@
 				"node": ">=10"
 			}
 		},
+		"node_modules/ms": {
+			"version": "2.1.3",
+			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+			"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+			"license": "MIT"
+		},
 		"node_modules/nanoid": {
 			"version": "3.3.18",
 			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -959,6 +1231,26 @@
 				"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
 			}
 		},
+		"node_modules/node-addon-api": {
+			"version": "8.9.2",
+			"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
+			"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
+			"license": "MIT",
+			"engines": {
+				"node": "^18 || ^20 || >= 21"
+			}
+		},
+		"node_modules/node-gyp-build": {
+			"version": "4.8.4",
+			"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+			"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+			"license": "MIT",
+			"bin": {
+				"node-gyp-build": "bin.js",
+				"node-gyp-build-optional": "optional.js",
+				"node-gyp-build-test": "build-test.js"
+			}
+		},
 		"node_modules/obug": {
 			"version": "2.1.4",
 			"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
@@ -1022,6 +1314,15 @@
 				"node": "^10 || ^12 || >=14"
 			}
 		},
+		"node_modules/punycode": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+			"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
+			}
+		},
 		"node_modules/rolldown": {
 			"version": "1.2.6",
 			"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
@@ -1056,6 +1357,38 @@
 				"@rolldown/binding-win32-x64-msvc": "1.2.6"
 			}
 		},
+		"node_modules/safe-buffer": {
+			"version": "5.2.1",
+			"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+			"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/feross"
+				},
+				{
+					"type": "patreon",
+					"url": "https://www.patreon.com/feross"
+				},
+				{
+					"type": "consulting",
+					"url": "https://feross.org/support"
+				}
+			],
+			"license": "MIT"
+		},
+		"node_modules/semver": {
+			"version": "7.8.5",
+			"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+			"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+			"license": "ISC",
+			"bin": {
+				"semver": "bin/semver.js"
+			},
+			"engines": {
+				"node": ">=10"
+			}
+		},
 		"node_modules/set-cookie-parser": {
 			"version": "3.1.2",
 			"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
@@ -1063,6 +1396,12 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/sift": {
+			"version": "17.1.3",
+			"resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
+			"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
+			"license": "MIT"
+		},
 		"node_modules/sirv": {
 			"version": "3.0.2",
 			"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -1088,6 +1427,15 @@
 				"node": ">=0.10.0"
 			}
 		},
+		"node_modules/sparse-bitfield": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+			"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+			"license": "MIT",
+			"dependencies": {
+				"memory-pager": "^1.0.2"
+			}
+		},
 		"node_modules/svelte": {
 			"version": "5.56.10",
 			"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz",
@@ -1143,6 +1491,27 @@
 				"node": ">=6"
 			}
 		},
+		"node_modules/tr46": {
+			"version": "5.1.1",
+			"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+			"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+			"license": "MIT",
+			"dependencies": {
+				"punycode": "^2.3.1"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/validator": {
+			"version": "13.15.35",
+			"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
+			"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
+			"license": "MIT",
+			"engines": {
+				"node": ">= 0.10"
+			}
+		},
 		"node_modules/vite": {
 			"version": "8.2.2",
 			"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
@@ -1241,6 +1610,28 @@
 				}
 			}
 		},
+		"node_modules/webidl-conversions": {
+			"version": "7.0.0",
+			"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+			"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+			"license": "BSD-2-Clause",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/whatwg-url": {
+			"version": "14.2.0",
+			"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+			"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+			"license": "MIT",
+			"dependencies": {
+				"tr46": "^5.1.0",
+				"webidl-conversions": "^7.0.0"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
 		"node_modules/zimmerframe": {
 			"version": "1.1.4",
 			"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

+ 6 - 0
package.json

@@ -15,5 +15,11 @@
 		"@sveltejs/vite-plugin-svelte": "^7.1.2",
 		"svelte": "^5.56.1",
 		"vite": "^8.0.16"
+	},
+	"dependencies": {
+		"bcrypt": "^6.0.0",
+		"jsonwebtoken": "^9.0.3",
+		"mongoose": "^9.9.4",
+		"validator": "^13.15.35"
 	}
 }

BIN
src/lib/logo.jpg


+ 17 - 0
src/lib/notifier.svelte.js

@@ -0,0 +1,17 @@
+let notifications = $state([]);
+
+export function notify(type, message){
+    notifications.push({type, message});
+
+    while(notifications.length > 3){
+        notifications.shift();
+    }
+
+    setTimeout(()=>{
+        notifications.shift();
+    }, 7500);
+}
+
+export function getNotifications(){
+    return notifications;
+}

+ 29 - 0
src/lib/server/auth.js

@@ -0,0 +1,29 @@
+import {redirect, error} from "@sveltejs/kit";
+import jwt from "jsonwebtoken";
+import {JWT_SECRET} from "$env/static/private";
+import User from "$lib/server/models/User.js";
+
+export default async function userAuth(cookies, rdrct = true){
+    const userCookie = cookies.get("user");
+    if(!userCookie){
+        if(rdrct){
+            redirect(303, "/");
+        }else{
+            error(401, "Unauthorized");
+        }
+    }
+
+    let user;
+    try{
+        const userData = jwt.verify(userCookie, JWT_SECRET);
+        user = await User.findOne({_id: userData.id, token: userData.token});
+    }catch{
+        if(rdrct){
+            redirect(303, "/");
+        }else{
+            error(403, "Forbidden");
+        }
+    }
+
+    return user;
+}

+ 6 - 0
src/lib/server/db.js

@@ -0,0 +1,6 @@
+import mongoose from "mongoose";
+import {MONGODB_URI} from "$env/static/private";
+
+if(mongoose.connection.readyState === 0) mongoose.connect(MONGODB_URI);
+
+export default mongoose;

+ 41 - 0
src/lib/server/models/Recipe.js

@@ -0,0 +1,41 @@
+import "$lib/server/db.js";
+import mongoose from "mongoose";
+
+const recipeSchema = new mongoose.Schema({
+    user: {
+        type: mongoose.Schema.Types.ObjectId,
+        ref: "users",
+        required: true
+    },
+    name: {
+        type: String,
+        required: true
+    },
+    ingredients: [{
+        quantity: {
+            type: Number,
+            required: true
+        },
+        unit: {
+            type: String,
+            required: true,
+            default: ""
+        },
+        name: {
+            type: String,
+            required: true
+        }
+    }],
+    steps: [String],
+    notes: String,
+    createdAt: {
+        type: Date,
+        required: true
+    },
+    updateAt: {
+        type: Date,
+        required: true
+    }
+});
+
+export default mongoose.models.recipes || mongoose.model("recipes", recipeSchema);

+ 23 - 0
src/lib/server/models/User.js

@@ -0,0 +1,23 @@
+import "$lib/server/db.js";
+import mongoose from "mongoose";
+
+const userSchema = new mongoose.Schema({
+    name: {
+        type: String,
+        required: true
+    },
+    email: {
+        type: String,
+        required: true
+    },
+    password: {
+        type: String,
+        required: true
+    },
+    token: {
+        type: String,
+        required: true
+    }
+});
+
+export default mongoose.models.users || mongoose.model("users", userSchema);

+ 57 - 0
src/routes/+error.svelte

@@ -0,0 +1,57 @@
+<script>
+    import {page} from "$app/state";
+</script>
+
+<div class="container">
+    <div class="content">
+        <p class="code">{page.status}</p>
+        <h1>{page.status === 404 ? "Page not found" : "Something went wrong"}</h1>
+        <p class="message">
+            {page.status === 404
+                ? "We couldn't find the recipe or page you were looking for."
+                : (page.error?.message ?? "An unexpected error occurred.")}
+        </p>
+        <a href="/" class="button">Back to Home</a>
+    </div>
+</div>
+
+<style>
+    .container{
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        width: 100%;
+        height: calc(100% - 75px);
+        text-align: center;
+    }
+
+    .content{
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 0.75rem;
+        padding: 2rem;
+    }
+
+    .code{
+        margin: 0;
+        font-size: 5rem;
+        font-weight: 700;
+        line-height: 1;
+        color: var(--color-primary);
+    }
+
+    h1{
+        margin: 0;
+        color: var(--color-text);
+        font-size: 1.5rem;
+        font-weight: 700;
+    }
+
+    .message{
+        margin: 0 0 0.5rem;
+        max-width: 320px;
+        color: var(--color-text-muted);
+        font-size: 1rem;
+    }
+</style>

+ 1 - 1
src/routes/+layout.server.js

@@ -1,5 +1,5 @@
 export function load({cookies}){
     return {
-        loggedIn: cookies.get("user") === undefined
+        loggedIn: cookies.get("user") !== undefined
     };
 }

+ 58 - 4
src/routes/+layout.svelte

@@ -1,10 +1,11 @@
 <script>
     import "$lib/global.css";
 	import logo from '$lib/assets/favicon.svg';
-
     import {page} from "$app/state";
+    import {getNotifications} from "$lib/notifier.svelte.js";
 
 	let {data, children} = $props();
+    let notifications = getNotifications();
 </script>
 
 <svelte:head>
@@ -17,16 +18,32 @@
         <img src={logo} alt="Site logo">
     </a>
 
+    {#if data.loggedIn && page.url.pathname !== "/dashboard"}
+        <a href="/dashboard" class="button">Dashboard</a>
+    {/if}
+
     {#if page.url.pathname === "/"}
-        {#if data.loggedIn === false}
-            <a href="/login" class="button">Log In</a>
+        {#if !data.loggedIn}
+            <a href="/login" class="button">Sign In</a>
             <a href="/register" class="button">Sign Up</a>
+        {:else}
+            <a href="logout" class="button">Log Out</a>
         {/if}
     {/if}
 
     {#if page.url.pathname === "/register"}
-        <a href="/login" class="button">Log In</a>
+        <a href="/login" class="button">Sign In</a>
+    {/if}
+
+    {#if page.url.pathname === "/login"}
+        <a href="/register" class="button">Sign Up</a>
     {/if}
+
+    <div class="notifications">
+        {#each notifications as n}
+            <p class="{n.type}">{n.message}</p>
+        {/each}
+    </div>
 </header>
 
 {@render children()}
@@ -48,4 +65,41 @@
         height: 100%;
         width: auto;
     }
+
+    .notifications{
+        position: fixed;
+        top: 1rem;
+        right: 1rem;
+        z-index: 9999;
+        display: flex;
+        flex-direction: column;
+        gap: 0.5rem;
+        width: min(320px, calc(100vw - 2rem));
+        pointer-events: none;
+    }
+
+    .notifications p{
+        margin: 0;
+        padding: 0.75rem 1rem;
+        border-radius: 6px;
+        font-size: 0.9rem;
+        line-height: 1.3;
+        color: var(--color-text);
+        background: var(--color-surface-alt);
+        border: 1px solid var(--color-border);
+        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
+        pointer-events: auto;
+    }
+
+    .notifications p.success{
+        border-color: var(--color-success);
+    }
+
+    .notifications p.warning{
+        border-color: var(--color-warning);
+    }
+
+    .notifications p.error{
+        border-color: var(--color-error);
+    }
 </style>

+ 6 - 2
src/routes/+page.svelte

@@ -1,9 +1,13 @@
 <script>
-    
+    let searchInput;
+
+    $effect(()=>{
+        searchInput?.focus();
+    });
 </script>
 
 <form>
-    <input type="text" placeholder="Search Recipes">
+    <input type="text" placeholder="Search Recipes" bind:this={searchInput} required>
 
     <button>
         <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000">

+ 16 - 0
src/routes/dashboard/+page.server.js

@@ -0,0 +1,16 @@
+import Recipe from "$lib/server/models/Recipe.js";
+import userAuth from "$lib/server/auth.js";
+
+export async function load({cookies}){
+    const userCookie = await userAuth(cookies);
+    const recipes = await Recipe.aggregate([
+        {$match: {user: userCookie._id}},
+        {$project: {
+            _id: 0,
+            id: {$toString: "$_id"},
+            name: 1,
+            updatedAt: 1
+        }}
+    ]);
+    return {recipes};
+}

+ 129 - 0
src/routes/dashboard/+page.svelte

@@ -0,0 +1,129 @@
+<script>
+    let {data} = $props();
+</script>
+
+<div class="container">
+    <div class="topbar">
+        <h1>Recipes</h1>
+        <a href="/recipe/new" class="button">New Recipe</a>
+    </div>
+
+    <div class="recipes">
+        {#each data.recipes as r}
+            <article class="recipe">
+                <p class="name">{r.name}</p>
+                <p class="date">{r.updatedAt ? new Date(r.updatedAt).toLocaleDateString() : ""}</p>
+            </article>
+        {:else}
+            <p class="empty">No recipes yet. Create your first one.</p>
+        {/each}
+    </div>
+</div>
+
+<style>
+    .container{
+        width: min(720px, 100%);
+        min-height: calc(100% - 75px);
+        min-height: calc(100dvh - 75px);
+        margin: 0 auto;
+        padding: 1rem max(0.75rem, env(safe-area-inset-right, 0px)) max(2rem, env(safe-area-inset-bottom, 0px)) max(0.75rem, env(safe-area-inset-left, 0px));
+        color: var(--color-text);
+        overflow-x: clip;
+    }
+
+    .topbar{
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: 0.75rem 1rem;
+        margin-bottom: 1.25rem;
+    }
+
+    h1{
+        font-size: clamp(1.35rem, 1rem + 1.6vw, 1.75rem);
+        font-weight: 700;
+        line-height: 1.2;
+        min-width: 0;
+    }
+
+    .button{
+        margin: 0;
+        flex-shrink: 0;
+        text-align: center;
+    }
+
+    .recipes{
+        display: flex;
+        flex-direction: column;
+        gap: 0.5rem;
+    }
+
+    .recipe{
+        display: flex;
+        flex-direction: row;
+        align-items: baseline;
+        justify-content: space-between;
+        gap: 0.75rem 1.5rem;
+        min-width: 0;
+        padding: 0.85rem 1rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-left: 3px solid var(--color-primary);
+        border-radius: 10px;
+    }
+
+    .name{
+        flex: 1;
+        min-width: 0;
+        font-size: 1.05rem;
+        font-weight: 600;
+        line-height: 1.35;
+        overflow-wrap: anywhere;
+    }
+
+    .date{
+        flex-shrink: 0;
+        color: var(--color-text-muted);
+        font-size: 0.85rem;
+        line-height: 1.3;
+        white-space: nowrap;
+        text-align: right;
+    }
+
+    .date:empty{
+        display: none;
+    }
+
+    .empty{
+        margin: 0;
+        padding: 2.5rem 1.25rem;
+        text-align: center;
+        color: var(--color-text-muted);
+        background: var(--color-surface);
+        border: 1px dashed var(--color-border);
+        border-radius: 10px;
+        font-size: 0.95rem;
+        line-height: 1.45;
+    }
+
+    @media (min-width: 560px){
+        .container{
+            padding: 1.5rem 1rem 3rem;
+        }
+    }
+
+    @media (min-width: 900px){
+        .container{
+            padding-top: 2rem;
+        }
+    }
+
+    @media (pointer: coarse){
+        .button{
+            min-height: 44px;
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+        }
+    }
+</style>

+ 35 - 0
src/routes/login/+page.server.js

@@ -0,0 +1,35 @@
+import {fail, redirect} from "@sveltejs/kit";
+import User from "$lib/server/models/User.js";
+import bcrypt from "bcrypt";
+import jwt from "jsonwebtoken";
+import {JWT_SECRET} from "$env/static/private";
+
+export const actions = {
+    default: async ({request, cookies})=>{
+        const data = await request.formData();
+        const email = data.get("email").toLowerCase();
+        const password = data.get("password");
+
+        const user = await User.findOne({email: email});
+
+        if(!user) return fail(401, "Wrong email or password");
+        
+        if(!bcrypt.compare(password, user.password)) return fail(401, "Wrong email or password");
+
+        const tokenData = {
+            id: user._id,
+            token: user.token
+        };
+        const token = jwt.sign(tokenData, JWT_SECRET, {expiresIn: "30d"});
+
+        cookies.set("user", token, {
+            path: "/",
+            httpOnly: true,
+            secure: true,
+            sameSite: "strict",
+            maxAge: 60 * 60 *24 * 7 * 30
+        });
+
+        redirect(303, "/dashboard")
+    }
+}

+ 32 - 6
src/routes/login/+page.svelte

@@ -1,9 +1,35 @@
 <script>
-    const submit = ()=>{
-        console.log("submitting");
-    }
+    import {enhance} from "$app/forms";
+
+    let emailInput;
+
+    $effect(()=>{
+        emailInput?.focus();
+    });
 </script>
 
-<form class="standardForm" onsubmit={submit}>
-    
-</form>
+<div class="container">
+    <form class="standardForm" method="POST" use:enhance>
+        <h1>Sign In</h1>
+
+        <label>Email
+            <input name="email" type="email" bind:this={emailInput} required>
+        </label>
+
+        <label>Password
+            <input name="password" type="password" required>
+        </label>
+
+        <button>Submit</button>
+    </form>
+</div>
+
+<style>
+    .container{
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        width: 100%;
+        height: calc(100% - 75px);
+    }
+</style>

+ 6 - 0
src/routes/logout/+server.js

@@ -0,0 +1,6 @@
+import {redirect} from "@sveltejs/kit";
+
+export async function GET({cookies}){
+    cookies.delete("user", {path: "/"});
+    redirect(303, "/");
+}

+ 339 - 0
src/routes/recipe/new/+page.svelte

@@ -0,0 +1,339 @@
+<script>
+    import {notify} from "$lib/notifier.svelte.js";
+    import {goto} from "$app/navigation";
+
+    let nameInput, quantityInput, stepsInput;
+    let name = $state("");
+    let quantity = $state();
+    let unit = $state("");
+    let ingredientName = $state("");
+    let ingredients = $state([]);
+    let stepString = $state("");
+    let steps = $state([]);
+    let notes = $state("");
+
+    const submit = async (event)=>{
+        event.preventDefault();
+
+        if(!name){
+            notify("error", "Please enter recipe name");
+            nameInput.focus();
+            return;
+        }
+
+        const data = {name, ingredients, steps, notes};
+
+        const response = await fetch("/recipe/new", {
+            method: "POST",
+            headers: {"Content-Type": "application/json"},
+            credentials: "include",
+            body: JSON.stringify(data)
+        });
+
+        const recipe = await response.json();
+        if(!response.ok) return notify("error", recipe.message);
+
+        goto("/dashboard");
+    }
+
+    const addIngredient = ()=>{
+        if(quantity <= 0) return notify("error", "Quantity must be greater than 0");
+        if(!unit) return notify("error", "Unit is required");
+        if(!ingredientName) return notify("error", "Ingredient name is required");
+
+        ingredients.push({
+            quantity: quantity,
+            unit: unit,
+            name: ingredientName
+        });
+
+        quantity = null;
+        unit = "";
+        ingredientName = "";
+
+        quantityInput.focus();
+    }
+
+    const addStep = ()=>{
+        if(!stepString) return notify("error", "Please enter some text");
+        steps.push(stepString);
+        stepString = "";
+        stepsInput.focus();
+    }
+
+    $effect(()=>{
+        nameInput?.focus();
+    });
+</script>
+
+<div class="container">
+    <form class="standardForm" onsubmit={submit}>
+        <h1>New Recipe</h1>
+
+        <label>Name
+            <input type="text" bind:this={nameInput} bind:value={name}>
+        </label>
+
+        <div class="ingredients">
+            <h2>Ingredients</h2>
+
+            <ul>
+                {#each ingredients as i}
+                    <li>{i.name}, {i.quantity} {i.unit}</li>
+                {/each}
+            </ul>
+
+            <label>Quantity
+                <input type="number" min="0" bind:value={quantity} bind:this={quantityInput}>
+            </label>
+
+            <label>Unit
+                <input type="text" bind:value={unit}>
+            </label>
+
+            <label>Name
+                <input type="text" bind:value={ingredientName}>
+            </label>
+
+            <button class="button" type="button" onclick={addIngredient}>Add</button>
+        </div>
+
+        <div class="steps">
+            <h2>Steps</h2>
+
+            <ol>
+                {#each steps as s}
+                    <li>{s}</li>
+                {/each}
+            </ol>
+
+            <textarea bind:value={stepString} rows="2" bind:this={stepsInput}></textarea>
+
+            <button class="button" type="button" onclick={addStep}>Add</button>
+        </div>
+
+        <textarea bind:value={notes} rows="4" placeholder="Notes"></textarea>
+
+        <button>Create Recipe</button>
+    </form>
+</div>
+
+<style>
+    .container{
+        display: flex;
+        justify-content: center;
+        align-items: flex-start;
+        width: 100%;
+        max-width: 100%;
+        min-height: calc(100% - 75px);
+        min-height: calc(100dvh - 75px);
+        padding: 1rem max(0.75rem, env(safe-area-inset-right, 0px)) max(1.5rem, env(safe-area-inset-bottom, 0px)) max(0.75rem, env(safe-area-inset-left, 0px));
+        color: var(--color-text);
+        overflow-x: clip;
+        box-sizing: border-box;
+    }
+
+    .standardForm{
+        width: min(640px, 100%);
+        max-width: 100%;
+        min-width: 0;
+        padding: 1.15rem 0.9rem;
+        container-type: inline-size;
+        container-name: recipe-form;
+    }
+
+    h1{
+        font-size: clamp(1.25rem, 1rem + 2vw, 1.5rem);
+    }
+
+    h2{
+        color: var(--color-text);
+        font-size: clamp(1rem, 0.9rem + 0.5vw, 1.1rem);
+        font-weight: 700;
+        letter-spacing: 0.01em;
+    }
+
+    input,
+    textarea{
+        width: 100%;
+        min-width: 0;
+        max-width: 100%;
+        font-size: 1rem;
+    }
+
+    .ingredients,
+    .steps{
+        display: flex;
+        flex-direction: column;
+        gap: 0.75rem;
+        padding: 0.75rem;
+        min-width: 0;
+        max-width: 100%;
+        background: var(--color-surface-alt);
+        border: 1px solid var(--color-border);
+        border-radius: 8px;
+    }
+
+    ul,
+    ol{
+        margin: 0;
+        padding: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 0.45rem;
+        list-style: none;
+        min-width: 0;
+        max-width: 100%;
+    }
+
+    ul:not(:has(li)),
+    ol:not(:has(li)){
+        display: none;
+    }
+
+    li{
+        padding: 0.55rem 0.7rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-left: 3px solid var(--color-primary);
+        border-radius: 6px;
+        font-size: 0.95rem;
+        line-height: 1.4;
+        color: var(--color-text);
+        overflow-wrap: anywhere;
+        word-break: break-word;
+        min-width: 0;
+        max-width: 100%;
+    }
+
+    ol{
+        counter-reset: step;
+    }
+
+    ol li{
+        display: grid;
+        grid-template-columns: 1.5rem minmax(0, 1fr);
+        align-items: start;
+        gap: 0.7rem;
+        counter-increment: step;
+    }
+
+    ol li::before{
+        content: counter(step);
+        flex-shrink: 0;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 1.5rem;
+        height: 1.5rem;
+        border-radius: 50%;
+        background: var(--color-primary);
+        color: var(--color-text-on-primary);
+        font-size: 0.75rem;
+        font-weight: 700;
+        line-height: 1;
+    }
+
+    .ingredients{
+        display: grid;
+        grid-template-columns: minmax(0, 1fr);
+        align-items: stretch;
+        gap: 0.75rem;
+    }
+
+    .ingredients > *{
+        min-width: 0;
+        max-width: 100%;
+    }
+
+    .button{
+        margin: 0;
+        width: 100%;
+        height: auto;
+        min-height: 2.5rem;
+        white-space: nowrap;
+    }
+
+    textarea{
+        resize: vertical;
+        min-height: 3.5rem;
+    }
+
+    .standardForm > textarea{
+        min-height: 5rem;
+    }
+
+    @container recipe-form (min-width: 260px){
+        .ingredients{
+            grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+            align-items: end;
+        }
+
+        .ingredients h2,
+        .ingredients ul,
+        .ingredients label:last-of-type,
+        .ingredients .button{
+            grid-column: 1 / -1;
+        }
+    }
+
+    @container recipe-form (min-width: 480px){
+        .ingredients,
+        .steps{
+            padding: 1rem;
+        }
+
+        .ingredients{
+            grid-template-columns: minmax(0, 5.5rem) minmax(0, 6.5rem) minmax(0, 1fr) auto;
+            align-items: end;
+        }
+
+        .ingredients h2,
+        .ingredients ul{
+            grid-column: 1 / -1;
+        }
+
+        .ingredients label:last-of-type,
+        .ingredients .button{
+            grid-column: auto;
+        }
+
+        .ingredients .button{
+            width: auto;
+            min-height: 0;
+            height: fit-content;
+        }
+    }
+
+    @media (min-width: 640px){
+        .container{
+            padding: 2rem 1rem 3rem;
+        }
+
+        .standardForm{
+            padding: 2rem;
+        }
+
+        .standardForm > textarea{
+            min-height: 6rem;
+        }
+    }
+
+    @media (max-height: 500px){
+        .container{
+            padding-top: 0.75rem;
+            padding-bottom: 1rem;
+        }
+
+        .standardForm > textarea{
+            min-height: 3.5rem;
+        }
+    }
+
+    @media (pointer: coarse){
+        .button,
+        .standardForm button{
+            min-height: 44px;
+        }
+    }
+</style>

+ 31 - 0
src/routes/recipe/new/+server.js

@@ -0,0 +1,31 @@
+import auth from "$lib/server/auth.js";
+import Recipe from "$lib/server/models/Recipe.js";
+import {error, json} from "@sveltejs/kit";
+
+export async function POST({request, cookies}){
+    const user = await auth(cookies, false);
+    const body = await request.json();
+
+    if(!body.name) error(400, "Recipe name required");
+    if(body.ingredients.length <= 0) error(400, "Ingredients required");
+    if(body.steps.length <= 0) error(400, "Preparation steps required");
+
+    for(let i = 0; i < body.ingredients.length; i++){
+        if(body.ingredients[i].quantity <= 0) error(400, "Quantity must be greater than 0");
+        if(!body.ingredients[i].name) error(400, "Ingredient name required");
+    }
+
+    const recipe = new Recipe({
+        user: user._id,
+        name: body.name,
+        ingredients: body.ingredients,
+        steps: body.steps,
+        notes: body.notes,
+        createdAt: new Date(),
+        updatedAt: new Date()
+    });
+
+    await recipe.save();
+
+    return json({id: recipe._id.toString()}, {status: 201});
+}

+ 37 - 0
src/routes/register/+page.server.js

@@ -0,0 +1,37 @@
+import {fail, redirect} from "@sveltejs/kit";
+import validator from "validator";
+import bcrypt from "bcrypt";
+import User from "$lib/server/models/User.js";
+import {randomUUID} from "crypto";
+
+export const actions = {
+    default: async ({request})=>{
+        const data = await request.formData();
+        const name = data.get("name");
+        const email = data.get("email").toLowerCase();
+        const password = data.get("password");
+        const confirmPassword = data.get("confirmPassword");
+
+        if(!name) return fail(400, {message: "Name is required"});
+        if(!email) return fail(400, {message: "Email is required"});
+        if(!password || !confirmPassword) return fail(400, "Password is required");
+
+        if(!validator.isEmail(email)) return fail(400, "Invalid email");
+        if(password !== confirmPassword) return fail(400, "Passwords do not match");
+        if(password.length < 12) return fail(400, "Password must contain at least 12 characters");
+
+        const checkUser = await User.findOne({email: email});
+        if(checkUser) return fail(400, "User with this email already exists");
+
+        const user = new User({
+            name: name,
+            email: email,
+            password: await bcrypt.hash(password, 12),
+            token: randomUUID()
+        });
+
+        await user.save();
+
+        redirect(303, "/login");
+    }
+}

+ 15 - 2
src/routes/register/+page.svelte

@@ -1,5 +1,18 @@
 <script>
     import {enhance} from "$app/forms";
+    import {untrack} from "svelte";
+    import {notify} from "$lib/notifier.svelte.js";
+
+    let {form} = $props();
+    let nameInput;
+
+    $effect(()=>{
+        nameInput?.focus();
+    });
+
+    $effect(()=>{
+        if(form) untrack(()=>{notify("error", form)});
+    });
 </script>
 
 <div class="container">
@@ -7,7 +20,7 @@
         <h1>Sign Up</h1>
 
         <label>Name
-            <input name="name" type="text" required>
+            <input name="name" type="text" bind:this={nameInput} required>
         </label>
 
         <label>Email
@@ -22,7 +35,7 @@
             <input name="confirmPassword" type="password" required>
         </label>
 
-        <button>Register</button>
+        <button>Submit</button>
     </form>
 </div>