31 Commits 6f3fbb9472 ... d323f28c88

Author SHA1 Message Date
  Lee Morgan d323f28c88 Fix button style 1 week ago
  Lee Morgan fb5a221d03 Fix recipe page to display whether logged in or not 1 week ago
  Lee Morgan a2f081bd20 Create upload button for recipes 1 week ago
  Lee Morgan 503bcfbb57 Add download button for recipes 1 week ago
  Lee Morgan ba372e7ef1 Create the print recipe page 1 week ago
  Lee Morgan f3ee976a3b Add functionality to copy link 1 week ago
  Lee Morgan 3854fdc8ce Update the style of the buttons on the recipe page 1 week ago
  Lee Morgan be3bb522c4 Add notifications for turning on/off recipe mode 2 weeks ago
  Lee Morgan 214e051968 Add recipe mode on the page 2 weeks ago
  Lee Morgan 2b7e42842a Add funcitoning delete button 2 weeks ago
  Lee Morgan 3a67d42dc6 Update ingredients and steps so that they can be removed 2 weeks ago
  Lee Morgan 42676be23c Make recipes editable 2 weeks ago
  Lee Morgan 07df9be0dd Move much of the new recipe stuff to a component 2 weeks ago
  Lee Morgan 2c37e47dd1 Create the page to display a recipe 2 weeks ago
  Lee Morgan 26b40ce5c1 Add public/private options for recipes 2 weeks ago
  Lee Morgan 01241a90d2 Create the layout and style for the user dashboard page 2 weeks ago
  Lee Morgan 67c08147fc Add styling for new recipe page 2 weeks ago
  Lee Morgan 3816e025ce Create the html and some code for the new recipe page 2 weeks ago
  Lee Morgan 71b2fe3894 Fetch recipes from the database 2 weeks ago
  Lee Morgan 9f58124051 Create recipe schema 2 weeks ago
  Lee Morgan 0889d007a8 Autofocus search and login inputs on page load 2 weeks ago
  Lee Morgan 3be220daa5 Add logout route/button 2 weeks ago
  Lee Morgan 37c8091262 Add dashboard button to home page if already logged in 2 weeks ago
  Lee Morgan b39023d051 Create login, create 404 page 2 weeks ago
  Lee Morgan 7f33191361 Create notifier 2 weeks ago
  Lee Morgan b1c33b1e87 Create notifications system without style 2 weeks ago
  Lee Morgan d2a387e8d5 New user creation now functional 2 weeks ago
  Lee Morgan 8f504452e6 Update form styling 2 weeks ago
  Lee Morgan 80e104843d Create the form for register 2 weeks ago
  Lee Morgan dbaa8d3fbb Add unworking search bar on home page 2 weeks ago
  Lee Morgan b914ba4f6d Create base of header 2 weeks ago

+ 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/.wakeLock.js.swn


+ 19 - 1
src/lib/assets/favicon.svg

@@ -1 +1,19 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+  <!-- Background -->
+  <rect width="32" height="32" rx="6" fill="#121212"/>
+
+  <!-- Fork (left) -->
+  <g fill="#ff7a1a">
+    <rect x="9" y="6" width="1.6" height="10" />
+    <rect x="12" y="6" width="1.6" height="10" />
+    <rect x="15" y="6" width="1.6" height="10" />
+    <path d="M8.5 16 h9 v2 c0 2.5 -2 4 -4.5 4 s-4.5 -1.5 -4.5 -4 z" />
+    <rect x="12.2" y="21.5" width="1.6" height="5" rx="0.8" />
+  </g>
+
+  <!-- Spoon (right) -->
+  <g fill="#ff7a1a">
+    <ellipse cx="21.5" cy="10" rx="3" ry="4.2" />
+    <rect x="20.7" y="13.5" width="1.6" height="13" rx="0.8" />
+  </g>
+</svg>

+ 142 - 0
src/lib/global.css

@@ -0,0 +1,142 @@
+:root {
+  /* Core palette */
+  --color-bg: #121212;
+  --color-surface: #1e1e1e;
+  --color-surface-alt: #262626;
+  --color-border: #3a3a3a;
+
+  /* Orange accents */
+  --color-primary: #ff7a1a;
+  --color-primary-hover: #ff9142;
+  --color-primary-dark: #cc5f0f;
+  --color-primary-muted: #4d2c14;
+
+  /* Text */
+  --color-text: #f5f5f0;
+  --color-text-muted: #b3b0a8;
+  --color-text-on-primary: #121212;
+
+  /* Feedback colors (keep readable against dark bg) */
+  --color-success: #7cb342;
+  --color-warning: #ffb300;
+  --color-error: #e6543a;
+}
+
+*{
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+body, html{
+    height: 100%;
+    width: 100%;
+}
+
+body{
+    background: var(--color-bg);
+    text: var(--color-text);
+}
+
+.button{
+    border: none;
+    outline: none;
+    text-decoration: none;
+    cursor: pointer;
+    background: var(--color-primary);
+    color: var(--color-text-on-primary);
+    border-radius: 6px;
+    padding: 0.6rem 1.2rem;
+    font-weight: 600;
+    font-size: 1rem;
+    line-height: 1.2;
+    white-space: nowrap;
+    transition: background 0.15s eas, transform 0.05s ease;
+    margin: 0 15px;
+}
+
+.button:hover{
+    background: var(--color-primary-hover);
+}
+
+.button:active{
+    transform: translateY(1px);
+}
+
+.standardForm{
+    display: flex;
+    flex-direction: column;
+    gap: 1rem;
+    width: 100%;
+    max-width: 360px;
+    margin: 0 auto;
+    padding: 2rem;
+    background: var(--color-surface);
+    border: 1px solid var(--color-border);
+    border-radius: 10px;
+}
+
+.standardForm label{
+    display: flex;
+    flex-direction: column;
+    gap: 0.4rem;
+    color: var(--color-text);
+    font-size: 0.9rem;
+    font-weight: 600;
+}
+
+.standardForm input,
+.standardForm textarea,
+.standardForm select{
+    border: 1px solid var(--color-border);
+    outline: none;
+    background: var(--color-surface-alt);
+    color: var(--color-text);
+    border-radius: 6px;
+    padding: 0.6rem 0.8rem;
+    font-size: 1rem;
+    font-family: inherit;
+    transition: border-color 0.15s ease;
+}
+
+.standardForm input:focus,
+.standardForm textarea:focus,
+.standardForm select:focus{
+    border-color: var(--color-primary);
+}
+
+.standardForm h1{
+    color: var(--color-text);
+    font-size: 1.5rem;
+    font-weight: 700;
+    text-align: center;
+    margin-bottom: 0.5rem;
+}
+
+.standardForm .button{
+    margin: 0;
+}
+
+.standardForm .error{
+    color: var(--color-error);
+    font-size: 0.85rem;
+}
+
+.standardForm .formFooter{
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-top: 0.5rem;
+    font-size: 0.85rem;
+    color: var(--color-text-muted);
+}
+
+.standardForm .formFooter a{
+    color: var(--color-primary);
+    text-decoration: none;
+}
+
+.standardForm .formFooter a:hover{
+    color: var(--color-primary-hover);
+    text-decoration: underline;
+}

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;

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

@@ -0,0 +1,45 @@
+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
+    },
+    updatedAt: {
+        type: Date,
+        required: true
+    },
+    private: {
+        type: Boolean,
+        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);

+ 31 - 0
src/lib/wakeLock.js

@@ -0,0 +1,31 @@
+let sentinel = null;
+
+export async function enableRecipeMode(){
+    if(typeof navigator === "undefined" || !("wakeLock" in navigator)) return false;
+
+    try{
+        sentinel = await navigator.wakeLock.request("screen");
+        sentinel.addEventListener("release", ()=>{sentinel = null});
+        return true;
+    }catch{
+        return false;
+    }
+}
+
+export async function disableRecipeMode(){
+    await sentinel?.release();
+    sentinel = null;
+}
+
+export function attachVisibilityReacquire(enabled){
+    if(typeof document === "undefined") return () => {};
+
+    async function onVis(){
+        const isEnabled = typeof enabled === "function" ? enabled() : enabled;
+        if(document.visibilityState === "visible" && isEnabled){
+            await enableRecipeMode();
+        }
+    }
+    document.addEventListener("visibilitychange", onVis);
+    return () => document.removeEventListener("visibilitychange", onVis);
+}

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

+ 5 - 0
src/routes/+layout.server.js

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

+ 99 - 3
src/routes/+layout.svelte

@@ -1,11 +1,107 @@
 <script>
-	import favicon from '$lib/assets/favicon.svg';
+    import "$lib/global.css";
+	import logo from '$lib/assets/favicon.svg';
+    import {page} from "$app/state";
+    import {getNotifications} from "$lib/notifier.svelte.js";
 
-	let { children } = $props();
+	let {data, children} = $props();
+    let notifications = getNotifications();
 </script>
 
 <svelte:head>
-	<link rel="icon" href={favicon} />
+	<link rel="icon" href={logo} />
+    <title>Recipes</title>
 </svelte:head>
 
+{#if !page.url.pathname.endsWith("/print")}
+<header>
+    <a class="logo" href="/">
+        <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}
+            <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">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>
+{/if}
+
 {@render children()}
+
+<style>
+    header{
+        display: flex;
+        align-items: center;
+        height: 75px;
+        width: 100%;
+    }
+
+    .logo{
+        height: 90%;
+        cursor: pointer;
+    }
+
+    .logo img{
+        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>

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

@@ -1,2 +1,65 @@
-<h1>Welcome to SvelteKit</h1>
-<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
+<script>
+    let searchInput;
+
+    $effect(()=>{
+        searchInput?.focus();
+    });
+</script>
+
+<form>
+    <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">
+            <path d="M17 17L21 21" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
+            <path d="M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
+        </svg>
+    </button>
+</form>
+
+<style>
+    form{
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        gap: 0.5rem;
+        width: min(480px, 90vw);
+        margin: 40vh auto 0;
+        padding: 0.5rem 0.5rem 0.5rem 1.25rem;
+        background: #ffffff;
+        border-radius: 999px;
+        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
+    }
+
+    input[type="text"]{
+        flex: 1;
+        border: none;
+        outline: none;
+        background: none;
+        font-size: 1rem;
+        color: #000000;
+    }
+
+    button{
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 2.5rem;
+        height: 2.5rem;
+        background: #000000;
+        border: none;
+        border-radius: 50%;
+        cursor: pointer;
+        flex-shrink: 0;
+        transition: background 0.15s ease, transform 0.1s ease;
+    }
+
+    button:hover{
+        background: #333333;
+    }
+
+    button:active{
+        background: #555555;
+        transform: scale(0.92);
+    }
+</style>

+ 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 user = await userAuth(cookies);
+    const recipes = await Recipe.aggregate([
+        {$match: {user: user._id}},
+        {$project: {
+            _id: 0,
+            id: {$toString: "$_id"},
+            name: 1,
+            updatedAt: 1
+        }}
+    ]);
+    return {recipes};
+}

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

@@ -0,0 +1,248 @@
+<script>
+    import {notify} from "$lib/notifier.svelte.js";
+    import {goto} from "$app/navigation";
+
+    let {data} = $props();
+    let uploadInput = $state();
+
+    const uploadRecipe = async ()=>{
+        const file = uploadInput.files[0];
+        uploadInput.value = "";
+        if(!file) return;
+
+        try{
+            const text = await file.text();
+            const response = await fetch("/recipe/new", {
+                method: "POST",
+                headers: {"Content-Type": "application/json"},
+                credentials: "include",
+                body: text
+            });
+
+            let responseData = await response.json();
+            if(!response.ok) return notify("error", responseData.msg);
+
+            notify("success", "Recipe added");
+            goto(`/recipe/${responseData.id}`);
+        }catch(e){
+            notify("error", "Failed to parse the recipe");
+        }
+    }
+</script>
+
+<div class="container">
+    <div class="topbar">
+        <h1>Recipes</h1>
+        <div class="share">
+            <button type="button" class="shareToggle" aria-haspopup="true">New Recipe</button>
+            <div class="shareMenu" role="menu">
+                <a href="/recipe/new" role="menuitem">Create</a>
+                <button role="menuitem" onclick={()=>{uploadInput.click()}}>Upload</button>
+                <input
+                    bind:this={uploadInput}
+                    onchange={uploadRecipe}
+                    type="file"
+                    accept=".json,application/json"
+                    hidden
+                >
+            </div>
+        </div>
+    </div>
+
+    <div class="recipes">
+        {#each data.recipes as r}
+            <a href="/recipe/{r.id}" class="recipe">
+                <p class="name">{r.name}</p>
+                <p class="date">{r.updatedAt ? new Date(r.updatedAt).toLocaleDateString() : ""}</p>
+            </a>
+        {: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;
+    }
+
+    .shareToggle{
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        box-sizing: border-box;
+        border: none;
+        outline: none;
+        cursor: pointer;
+        margin: 0;
+        background: var(--color-primary);
+        color: var(--color-text-on-primary);
+        border-radius: 6px;
+        padding: 0.6rem 1.2rem;
+        font-weight: 600;
+        font-size: 1rem;
+        line-height: 1.2;
+        white-space: nowrap;
+        text-decoration: none;
+        transition: background 0.15s ease, transform 0.05s ease;
+        font-family: inherit;
+    }
+
+    .shareToggle:hover{
+        background: var(--color-primary-hover);
+    }
+
+    .shareToggle:active{
+        transform: translateY(1px);
+    }
+
+    .share{
+        position: relative;
+        flex-shrink: 0;
+    }
+
+    .shareMenu{
+        position: absolute;
+        right: 0;
+        top: calc(100% + 0.4rem);
+        z-index: 10;
+        display: none;
+        flex-direction: column;
+        gap: 0.2rem;
+        min-width: 100%;
+        padding: 0.35rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 8px;
+        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
+    }
+
+    .share:focus-within .shareMenu{
+        display: flex;
+    }
+
+    .shareMenu button,
+    .shareMenu a{
+        display: flex;
+        align-items: center;
+        box-sizing: border-box;
+        border: none;
+        outline: none;
+        cursor: pointer;
+        margin: 0;
+        width: 100%;
+        padding: 0.55rem 0.8rem;
+        background: transparent;
+        color: var(--color-text);
+        border-radius: 6px;
+        font-weight: 600;
+        font-size: 0.9rem;
+        line-height: 1.2;
+        text-align: left;
+        white-space: nowrap;
+        text-decoration: none;
+        font-family: inherit;
+    }
+
+    .shareMenu button:hover,
+    .shareMenu a:hover{
+        background: var(--color-surface-alt);
+    }
+
+    .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;
+        text-decoration: none;
+        color: var(--color-text);
+    }
+
+    .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){
+        .shareToggle,
+        .shareMenu button,
+        .shareMenu a{
+            min-height: 44px;
+        }
+    }
+</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")
+    }
+}

+ 35 - 0
src/routes/login/+page.svelte

@@ -0,0 +1,35 @@
+<script>
+    import {enhance} from "$app/forms";
+
+    let emailInput;
+
+    $effect(()=>{
+        emailInput?.focus();
+    });
+</script>
+
+<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 class="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, "/");
+}

+ 500 - 0
src/routes/recipe/EditableRecipe.svelte

@@ -0,0 +1,500 @@
+<script>
+    import {notify} from "$lib/notifier.svelte.js";
+    import {goto} from "$app/navigation";
+
+    let nameInput, quantityInput, stepsInput;
+    let {
+        id,
+        name,
+        ingredients: ingredientsProp,
+        steps: stepsProp,
+        notes,
+        visibility,
+        route
+    } = $props();
+    let ingredients = $state(ingredientsProp);
+    let steps = $state(stepsProp);
+    let quantity = $state();
+    let unit = $state("");
+    let ingredientName = $state("");
+    let stepString = $state("");
+
+    const submit = async (event)=>{
+        event.preventDefault();
+
+        if(!name){
+            notify("error", "Please enter recipe name");
+            nameInput.focus();
+            return;
+        }
+
+        const data = {
+            name, ingredients, steps, notes,
+            private: visibility === "private" ? true : false
+        };
+
+        const response = await fetch(route, {
+            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);
+
+        let redirect = route === "/recipe/new" ? "/dashboard" : `/recipe/${id}`;
+        goto(redirect);
+    }
+
+    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>
+
+<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, idx}
+                <li><button
+                    onclick={()=>ingredients.splice(idx, 1)}
+                    type="button"
+                >{i.name}, {i.quantity} {i.unit}</button></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, idx}
+                <li><button
+                    onclick={()=>{steps.splice(idx, 1)}}
+                    type="button"
+                >{s}</button></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>
+
+    <fieldset class="visibility">
+        <legend>Visibility</legend>
+        <div class="visibilityOptions">
+            <label>
+                <input
+                    type="radio"
+                    name="visibility"
+                    value="private"
+                    bind:group={visibility}
+                >
+                <span>Private</span>
+            </label>
+            <label>
+                <input
+                    type="radio"
+                    name="visibility"
+                    value="public"
+                    bind:group={visibility}
+                >
+                <span>Public</span>
+            </label>
+        </div>
+    </fieldset>
+
+    <button class="button">
+        {#if route === "/recipe/new"}
+            Create Recipe
+        {:else}
+            Update Recipe
+        {/if}
+    </button>
+</form>
+
+<style>
+    .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%;
+    }
+
+    ul li{
+        padding: 0;
+    }
+
+    li button{
+        display: block;
+        width: 100%;
+        margin: 0;
+        padding: 0.55rem 0.7rem;
+        border: none;
+        outline: none;
+        background: none;
+        color: inherit;
+        font: inherit;
+        font-size: inherit;
+        line-height: inherit;
+        text-align: left;
+        cursor: pointer;
+        overflow-wrap: anywhere;
+        word-break: break-word;
+    }
+
+    ol li button{
+        padding: 0;
+    }
+
+    ul li:hover,
+    ol li:hover{
+        border-color: var(--color-primary);
+    }
+
+    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;
+    }
+
+    .visibility{
+        border: 0;
+        margin: 0;
+        padding: 0;
+        min-width: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 0.4rem;
+    }
+
+    .visibility legend{
+        float: left;
+        width: 100%;
+        padding: 0;
+        margin: 0;
+        color: var(--color-text);
+        font-size: 0.9rem;
+        font-weight: 600;
+    }
+
+    .visibilityOptions{
+        position: relative;
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+        gap: 0;
+        width: 100%;
+        clear: both;
+        padding: 0.2rem;
+        background: var(--color-surface-alt);
+        border: 1px solid var(--color-border);
+        border-radius: 999px;
+        isolation: isolate;
+    }
+
+    .visibilityOptions::before{
+        content: "";
+        position: absolute;
+        top: 0.2rem;
+        left: 0.2rem;
+        width: calc(50% - 0.2rem);
+        height: calc(100% - 0.4rem);
+        background: var(--color-primary);
+        border-radius: 999px;
+        transition: transform 0.2s ease;
+        pointer-events: none;
+        z-index: 0;
+    }
+
+    .visibilityOptions:has(input[value="public"]:checked)::before{
+        transform: translateX(100%);
+    }
+
+    .visibilityOptions label{
+        position: relative;
+        z-index: 1;
+        flex-direction: row;
+        align-items: stretch;
+        justify-content: stretch;
+        gap: 0;
+        margin: 0;
+        cursor: pointer;
+    }
+
+    .visibilityOptions input{
+        position: absolute;
+        inset: 0;
+        width: 100%;
+        height: 100%;
+        margin: 0;
+        padding: 0;
+        opacity: 0;
+        border: 0;
+        background: none;
+        appearance: none;
+        cursor: pointer;
+    }
+
+    .visibilityOptions span{
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 100%;
+        min-height: 2.5rem;
+        padding: 0.45rem 0.8rem;
+        background: transparent;
+        border: 0;
+        border-radius: 999px;
+        color: var(--color-text-muted);
+        font-size: 0.95rem;
+        font-weight: 600;
+        line-height: 1.2;
+        text-align: center;
+        transition: color 0.15s ease;
+    }
+
+    .visibilityOptions input:hover + span{
+        color: var(--color-text);
+    }
+
+    .visibilityOptions input:checked + span{
+        color: var(--color-text-on-primary);
+    }
+
+    .visibilityOptions input:focus-visible + span{
+        outline: 2px solid var(--color-primary);
+        outline-offset: 2px;
+    }
+
+    @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){
+        .standardForm{
+            padding: 2rem;
+        }
+
+        .standardForm > textarea{
+            min-height: 6rem;
+        }
+    }
+
+    @media (max-height: 500px){
+        .standardForm > textarea{
+            min-height: 3.5rem;
+        }
+    }
+
+    @media (pointer: coarse){
+        .button,
+        .visibilityOptions span{
+            min-height: 44px;
+        }
+    }
+</style>

+ 42 - 0
src/routes/recipe/[recipeId]/+page.server.js

@@ -0,0 +1,42 @@
+import auth from "$lib/server/auth.js";
+import Recipe from "$lib/server/models/Recipe.js";
+import User from "$lib/server/models/User.js";
+import {JWT_SECRET} from "$env/static/private";
+import jwt from "jsonwebtoken";
+
+export async function load({cookies, params}){
+    let recipe;
+    let user = null;
+    let isOwner = false;
+    try{
+        const userData = jwt.verify(cookies.get("user"), JWT_SECRET);
+        user = await User.findOne({_id: userData.id, token: userData.token});
+    }catch{}
+    
+
+    try{
+        recipe = await Recipe.findOne({_id: params.recipeId}, {"ingredients._id": 0}).lean();
+        isOwner = user && user._id.toString() === recipe.user.toString();
+
+        if(recipe.private && !isOwner){
+            recipe = null;
+        }else{
+            recipe = {
+                id: recipe._id.toString(),
+                name: recipe.name,
+                ingredients: recipe.ingredients,
+                steps: recipe.steps,
+                notes: recipe.notes,
+                createdAt: recipe.createdAt,
+                updatedAt: recipe.updatedAt
+            };
+        }
+    }catch(e){
+        recipe = null;
+    }
+
+    return {
+        isOwner,
+        recipe
+    };
+}

+ 405 - 0
src/routes/recipe/[recipeId]/+page.svelte

@@ -0,0 +1,405 @@
+<script>
+    import {onMount} from "svelte";
+    import {enableRecipeMode, disableRecipeMode, attachVisibilityReacquire} from "$lib/wakeLock.js";
+    import {notify} from "$lib/notifier.svelte.js";
+
+    let {data} = $props();
+    let on = $state(false);
+    let shareOpen = $state(false);
+
+    async function toggle(){
+        if(on){
+            await disableRecipeMode();
+            on = false;
+            notify("success", "Recipe mode on. Screen will not turn off");
+            return;
+        }
+        on = await enableRecipeMode();
+        notify("warning", "Recipe mode off.")
+    }
+
+    const copyLink = ()=>{
+        navigator.clipboard.writeText(`https://${location.hostname}/recipe/${data.recipe.id}`);
+        notify("success", "Link copied to clipboard");
+    }
+
+    const download = ()=>{
+        const json = JSON.stringify({
+            name: data.recipe.name,
+            ingredients: data.recipe.ingredients,
+            steps: data.recipe.steps,
+            notes: data.recipe.notes,
+            private: data.recipe.private
+        });
+        const blob = new Blob([json], {type: "application/json"});
+        const url = URL.createObjectURL(blob);
+
+        const a = document.createElement("a");
+        a.href = url;
+        a.download = `${data.recipe.name.replaceAll(" ", "_")}.json`;
+        document.body.appendChild(a);
+        a.click();
+        a.remove();
+
+        URL.revokeObjectURL(url);
+    }
+
+    onMount(()=>{
+        const stop = attachVisibilityReacquire(()=>on);
+
+        function onPointerDown(event){
+            if(!shareOpen) return;
+            const target = event.target;
+            if(!(target instanceof Node) || !target.closest(".share")){
+                shareOpen = false;
+            }
+        }
+        document.addEventListener("pointerdown", onPointerDown, true);
+
+        return ()=>{
+            document.removeEventListener("pointerdown", onPointerDown, true);
+            stop();
+            disableRecipeMode();
+        };
+    });
+</script>
+
+<div class="container">
+    {#if data.recipe}
+        <article>
+            <h1>{data.recipe.name}</h1>
+
+            <ul>
+                {#each data.recipe.ingredients as i}
+                    <li>{i.quantity} {i.unit} {i.name}</li>
+                {/each}
+            </ul>
+
+            <ol>
+                {#each data.recipe.steps as s}
+                    <li>{s}</li>
+                {/each}
+            </ol>
+
+            <p>{data.recipe.notes}</p>
+        </article>
+
+        <div class="buttonBox">
+            {#if data.isOwner}
+                <a class="button" href="/recipe/{data.recipe.id}/edit">Edit</a>
+            {/if}
+            <button onclick={toggle}>Recipe Mode</button>
+            <div class="share">
+                <button
+                    type="button"
+                    class="shareToggle"
+                    aria-expanded={shareOpen}
+                    aria-haspopup="true"
+                    onclick={()=>shareOpen = !shareOpen}
+                >Share</button>
+                {#if shareOpen}
+                    <div class="shareMenu" role="menu">
+                        <button role="menuitem" onclick={copyLink}>Copy link</button>
+                        <a href="/recipe/{data.recipe.id}/print" role="menuitem">Print</a>
+                        <button role="menuitem" onclick={download}>Download</button>
+                    </div>
+                {/if}
+            </div>
+        </div>
+    {:else}
+        <div class="missing">
+            <h1>Recipe doesn't exist or is private</h1>
+            <p>It may have been removed, or you don't have permission to view it.</p>
+            <a href="/dashboard" class="button">Back to Dashboard</a>
+        </div>
+    {/if}
+</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;
+    }
+
+    .container:has(article){
+        display: flex;
+        flex-direction: column;
+        gap: 1rem;
+    }
+
+    .container:not(:has(article)){
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        width: 100%;
+    }
+
+    .buttonBox{
+        display: flex;
+        flex-wrap: wrap;
+        align-items: center;
+        gap: 0.5rem;
+    }
+
+    .buttonBox > a,
+    .buttonBox > button,
+    .shareToggle{
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        box-sizing: border-box;
+        border: none;
+        outline: none;
+        cursor: pointer;
+        margin: 0;
+        background: var(--color-primary);
+        color: var(--color-text-on-primary);
+        border-radius: 6px;
+        padding: 0.6rem 1.2rem;
+        font-family: inherit;
+        font-weight: 600;
+        font-size: 1rem;
+        line-height: 1.2;
+        white-space: nowrap;
+        text-decoration: none;
+        transition: background 0.15s ease, transform 0.05s ease;
+    }
+
+    .buttonBox > a:hover,
+    .buttonBox > button:hover,
+    .shareToggle:hover{
+        background: var(--color-primary-hover);
+    }
+
+    .buttonBox > a:active,
+    .buttonBox > button:active,
+    .shareToggle:active{
+        transform: translateY(1px);
+    }
+
+    .share{
+        position: relative;
+        margin-left: auto;
+    }
+
+    .shareMenu{
+        position: absolute;
+        right: 0;
+        bottom: calc(100% + 0.4rem);
+        z-index: 10;
+        display: flex;
+        flex-direction: column;
+        gap: 0.2rem;
+        min-width: 100%;
+        padding: 0.35rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 8px;
+        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
+    }
+
+    .shareMenu button,
+    .shareMenu a{
+        display: flex;
+        align-items: center;
+        box-sizing: border-box;
+        border: none;
+        outline: none;
+        cursor: pointer;
+        margin: 0;
+        width: 100%;
+        padding: 0.55rem 0.8rem;
+        background: transparent;
+        color: var(--color-text);
+        border-radius: 6px;
+        font-weight: 600;
+        font-size: 0.9rem;
+        line-height: 1.2;
+        text-align: left;
+        white-space: nowrap;
+        text-decoration: none;
+        font-family: inherit;
+    }
+
+    .shareMenu button:hover,
+    .shareMenu a:hover{
+        background: var(--color-surface-alt);
+    }
+
+    article{
+        display: flex;
+        flex-direction: column;
+        gap: 1.15rem;
+        min-width: 0;
+        padding: 1.15rem 0.9rem 1.35rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 10px;
+    }
+
+    article h1{
+        margin: 0;
+        color: var(--color-text);
+        font-size: clamp(1.4rem, 1.1rem + 1.6vw, 1.85rem);
+        font-weight: 700;
+        line-height: 1.25;
+        text-align: center;
+        overflow-wrap: anywhere;
+    }
+
+    article ul,
+    article ol,
+    article > p{
+        display: flex;
+        flex-direction: column;
+        gap: 0.45rem;
+        min-width: 0;
+        max-width: 100%;
+        margin: 0;
+        padding: 0.75rem;
+        list-style: none;
+        background: var(--color-surface-alt);
+        border: 1px solid var(--color-border);
+        border-radius: 8px;
+    }
+
+    article ul::before,
+    article ol::before,
+    article > p::before{
+        color: var(--color-text);
+        font-size: clamp(1rem, 0.9rem + 0.5vw, 1.1rem);
+        font-weight: 700;
+        letter-spacing: 0.01em;
+        margin-bottom: 0.2rem;
+    }
+
+    article ul::before{
+        content: "Ingredients";
+    }
+
+    article ol::before{
+        content: "Steps";
+    }
+
+    article > p::before{
+        content: "Notes";
+    }
+
+    article 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%;
+    }
+
+    article ol{
+        counter-reset: step;
+    }
+
+    article ol li{
+        display: grid;
+        grid-template-columns: 1.5rem minmax(0, 1fr);
+        align-items: start;
+        gap: 0.7rem;
+        counter-increment: step;
+    }
+
+    article ol li::before{
+        content: counter(step);
+        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;
+    }
+
+    article > p{
+        color: var(--color-text);
+        font-size: 0.95rem;
+        line-height: 1.5;
+        white-space: pre-wrap;
+        overflow-wrap: anywhere;
+    }
+
+    article > p:empty{
+        display: none;
+    }
+
+    .missing{
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 0.75rem;
+        width: min(420px, 100%);
+        padding: 2rem 1.5rem;
+        text-align: center;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 10px;
+    }
+
+    .missing h1{
+        margin: 0;
+        color: var(--color-text);
+        font-size: clamp(1.25rem, 1rem + 1.4vw, 1.5rem);
+        font-weight: 700;
+        line-height: 1.3;
+    }
+
+    .missing p{
+        margin: 0 0 0.35rem;
+        color: var(--color-text-muted);
+        font-size: 0.95rem;
+        line-height: 1.45;
+    }
+
+    .missing .button{
+        margin: 0;
+    }
+
+    @media (min-width: 640px){
+        .container{
+            padding: 2rem 1rem 3rem;
+        }
+
+        article{
+            padding: 2rem;
+        }
+
+        article ul,
+        article ol,
+        article > p{
+            padding: 1rem;
+        }
+    }
+
+    @media (pointer: coarse){
+        .buttonBox > a,
+        .buttonBox > button,
+        .shareToggle,
+        .shareMenu button,
+        .shareMenu a,
+        .missing .button{
+            min-height: 44px;
+        }
+    }
+</style>

+ 22 - 0
src/routes/recipe/[recipeId]/edit/+page.server.js

@@ -0,0 +1,22 @@
+import auth from "$lib/server/auth.js";
+import Recipe from "$lib/server/models/Recipe.js";
+
+export async function load({cookies, params}){
+    const user = await auth(cookies);
+
+    let recipe;
+    try{
+        recipe = await Recipe.findOne({
+            _id: params.recipeId,
+            user: user._id
+        }, {"ingredients._id": 0}).lean();
+        
+        recipe.id = recipe._id.toString();
+        recipe._id = undefined;
+        recipe.user = undefined;
+
+        return recipe;
+    }catch{
+        return {recipe: null};
+    }
+}

+ 84 - 0
src/routes/recipe/[recipeId]/edit/+page.svelte

@@ -0,0 +1,84 @@
+<script>
+    import EditableRecipe from "../../EditableRecipe.svelte";
+    import {notify} from "$lib/notifier.svelte.js";
+    import {goto} from "$app/navigation";
+
+    let {data} = $props();
+
+    const deleteRecipe = async ()=>{
+        const response = await fetch(`/recipe/${data.id}/edit`, {
+            method: "DELETE",
+            headers: {"Content-Type": "application/json"},
+            credentials: "include"
+        });
+
+        const json = await response.json();
+        if(!response.ok) return notify("error", json.msg);
+        goto("/dashboard");
+    }
+</script>
+
+<div class="container">
+    <EditableRecipe
+        id={data.id}
+        name={data.name}
+        ingredients={data.ingredients}
+        steps={data.steps}
+        notes={data.notes}
+        visibility={data.private ? "private" : "public"}
+        route="/recipe/{data.id}/edit"
+    />
+    <button
+        type="button"
+        class="button delete"
+        onclick={deleteRecipe}
+    >Delete</button>
+</div>
+
+<style>
+    .container{
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 0.75rem;
+        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;
+    }
+
+    .delete{
+        margin: 0;
+        width: min(640px, 100%);
+        background: var(--color-error);
+        color: var(--color-text);
+    }
+
+    .delete:hover{
+        background: color-mix(in srgb, var(--color-error) 82%, white);
+    }
+
+    @media (min-width: 640px){
+        .container{
+            padding: 2rem 1rem 3rem;
+        }
+    }
+
+    @media (max-height: 500px){
+        .container{
+            padding-top: 0.75rem;
+            padding-bottom: 1rem;
+        }
+    }
+
+    @media (pointer: coarse){
+        .delete{
+            min-height: 44px;
+        }
+    }
+</style>

+ 46 - 0
src/routes/recipe/[recipeId]/edit/+server.js

@@ -0,0 +1,46 @@
+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, params}){
+    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 = await Recipe.findOne({_id: params.recipeId});
+
+    recipe.name = body.name;
+    recipe.ingredients = body.ingredients;
+    recipe.steps = body.steps;
+    recipe.notes = body.notes;
+    recipe.private = body.private;
+    recipe.updatedAt = new Date();
+
+    await recipe.save();
+
+    return json({msg: "success"}, {status: 200});
+}
+
+export async function DELETE({cookies, params}){
+    const user = await auth(cookies, false);
+
+    let recipe;
+    try{
+        recipe = await Recipe.findOne({_id: params.recipeId});
+        if(!recipe || user._id.toString() !== recipe.user.toString()) error(403, "Forbidden");
+        await Recipe.deleteOne({_id: params.recipeId});
+    }catch(e){
+        if(e.status === 403) error(403, "Forbidden");
+        error(500, "Internal Server Error");
+    }
+
+    return json({msg: "success"}, {status: 200});
+}

+ 23 - 0
src/routes/recipe/[recipeId]/print/+page.server.js

@@ -0,0 +1,23 @@
+import auth from "$lib/server/auth.js";
+import Recipe from "$lib/server/models/Recipe.js";
+
+export async function load({cookies, params}){
+    const user = await auth(cookies);
+
+    let recipe;
+    try{
+        recipe = await Recipe.findOne({_id: params.recipeId}, {"ingredients._id": 0}).lean();
+
+        if(recipe.private && user._id.toString() !== recipe.user.toString()){
+            recipe = null;
+        }else{
+            recipe.id = recipe._id.toString();
+            recipe._id = undefined;
+            recipe.user = undefined;
+        }
+    }catch{
+        recipe = null;
+    }
+
+    return {recipe};
+}

+ 376 - 0
src/routes/recipe/[recipeId]/print/+page.svelte

@@ -0,0 +1,376 @@
+<script>
+    let {data} = $props();
+    let recipe = $derived(data.recipe);
+
+    function printPage(){
+        window.print();
+    }
+</script>
+
+<svelte:head>
+    <title>{recipe ? `${recipe.name}` : "Recipe"}</title>
+</svelte:head>
+
+{#if recipe}
+    <div class="screenBar">
+        <a href="/recipe/{recipe.id}">Back to recipe</a>
+        <button type="button" onclick={printPage}>Print</button>
+    </div>
+
+    <article class="sheet">
+        <div class="masthead">
+            <h1>{recipe.name}</h1>
+        </div>
+
+        <div class="layout">
+            <section class="ingredients">
+                <h2>Ingredients</h2>
+                <ul>
+                    {#each recipe.ingredients as i}
+                        <li>
+                            <span class="qty">{i.quantity}</span>
+                            <span class="unit">{i.unit}</span>
+                            <span class="iname">{i.name}</span>
+                        </li>
+                    {/each}
+                </ul>
+            </section>
+
+            <section class="method">
+                <h2>Method</h2>
+                <ol>
+                    {#each recipe.steps as s, idx}
+                        <li>
+                            <span class="num">{idx + 1}</span>
+                            <p>{s}</p>
+                        </li>
+                    {/each}
+                </ol>
+            </section>
+
+            {#if recipe.notes}
+                <section class="notes">
+                    <h2>Notes</h2>
+                    <p>{recipe.notes}</p>
+                </section>
+            {/if}
+        </div>
+    </article>
+{:else}
+    <div class="missing">
+        <h1>Recipe doesn't exist or is private</h1>
+        <p>It may have been removed, or you don't have permission to view it.</p>
+        <a href="/dashboard" class="button">Back to Dashboard</a>
+    </div>
+{/if}
+
+<style>
+    :global(html),
+    :global(body){
+        background: var(--color-bg);
+    }
+
+    .screenBar{
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: 0.75rem;
+        width: min(8.5in, calc(100% - 2rem));
+        margin: 1rem auto 0;
+    }
+
+    .screenBar a,
+    .screenBar button{
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        margin: 0;
+        border: none;
+        border-radius: 6px;
+        padding: 0.55rem 1.1rem;
+        background: var(--color-primary);
+        color: var(--color-text-on-primary);
+        font: inherit;
+        font-weight: 600;
+        font-size: 0.95rem;
+        text-decoration: none;
+        cursor: pointer;
+    }
+
+    .screenBar a:hover,
+    .screenBar button:hover{
+        background: var(--color-primary-hover);
+    }
+
+    .sheet{
+        width: min(8.5in, calc(100% - 2rem));
+        margin: 1rem auto 2.5rem;
+        padding: 0.7in 0.75in 0.65in;
+        background: #fffdf8;
+        color: #1c1917;
+        border-radius: 2px;
+        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
+    }
+
+    .masthead{
+        margin-bottom: 1.35rem;
+        padding-bottom: 0.85rem;
+        border-bottom: 2px solid #ff7a1a;
+    }
+
+    h1{
+        margin: 0;
+        font-family: Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, serif;
+        font-size: clamp(1.8rem, 1.4rem + 1.6vw, 2.45rem);
+        font-weight: 700;
+        line-height: 1.15;
+        letter-spacing: -0.02em;
+        overflow-wrap: anywhere;
+    }
+
+    .layout,
+    .ingredients,
+    .method,
+    .notes{
+        display: block;
+        width: 100%;
+        max-width: 100%;
+        float: none;
+    }
+
+    .layout{
+        display: flex;
+        flex-direction: column;
+        gap: 1.6rem;
+    }
+
+    h2{
+        margin: 0 0 0.7rem;
+        color: #cc5f0f;
+        font-size: 0.72rem;
+        font-weight: 700;
+        letter-spacing: 0.18em;
+        text-transform: uppercase;
+    }
+
+    .ingredients ul{
+        margin: 0;
+        padding: 0;
+        list-style: none;
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+        gap: 0.45rem 1.5rem;
+        width: 100%;
+    }
+
+    .ingredients li{
+        display: grid;
+        grid-template-columns: 2.1rem 2.2rem minmax(0, 1fr);
+        gap: 0.35rem 0.45rem;
+        align-items: baseline;
+        min-width: 0;
+        padding: 0.2rem 0 0.2rem 0.6rem;
+        border-left: 2px solid #ff7a1a;
+        font-size: 0.95rem;
+        line-height: 1.35;
+        break-inside: avoid;
+    }
+
+    .qty{
+        font-variant-numeric: tabular-nums;
+        font-weight: 700;
+        text-align: right;
+    }
+
+    .unit{
+        color: #6b645c;
+        font-size: 0.85rem;
+    }
+
+    .iname{
+        overflow-wrap: anywhere;
+    }
+
+    .method{
+        width: 100%;
+        padding-top: 0.15rem;
+        border-top: 1px solid #e6dfd4;
+    }
+
+    .method ol{
+        margin: 0;
+        padding: 0;
+        list-style: none;
+        display: flex;
+        flex-direction: column;
+        gap: 0.95rem;
+        counter-reset: none;
+    }
+
+    .method li{
+        display: grid;
+        grid-template-columns: 1.55rem minmax(0, 1fr);
+        gap: 0.85rem;
+        align-items: start;
+        width: 100%;
+        break-inside: avoid;
+    }
+
+    .num{
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 1.55rem;
+        height: 1.55rem;
+        border: 1.5px solid #ff7a1a;
+        border-radius: 50%;
+        color: #cc5f0f;
+        font-size: 0.75rem;
+        font-weight: 700;
+        line-height: 1;
+    }
+
+    .method p{
+        margin: 0;
+        font-size: 0.98rem;
+        line-height: 1.5;
+        overflow-wrap: anywhere;
+    }
+
+    .notes{
+        padding-top: 0.35rem;
+        border-top: 1px solid #e6dfd4;
+        break-inside: avoid;
+    }
+
+    .notes p{
+        margin: 0;
+        font-size: 0.95rem;
+        line-height: 1.55;
+        white-space: pre-wrap;
+        overflow-wrap: anywhere;
+        color: #3f3a36;
+    }
+
+    @media (max-width: 560px){
+        .ingredients ul{
+            grid-template-columns: 1fr;
+        }
+    }
+
+    .missing{
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 0.75rem;
+        width: min(420px, calc(100% - 2rem));
+        margin: 15vh auto;
+        padding: 2rem 1.5rem;
+        text-align: center;
+        color: var(--color-text);
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 10px;
+    }
+
+    .missing h1{
+        font-family: inherit;
+        font-size: 1.35rem;
+        letter-spacing: 0;
+    }
+
+    .missing p{
+        margin: 0;
+        color: var(--color-text-muted);
+    }
+
+    .missing a{
+        margin: 0;
+    }
+
+    @page{
+        size: letter;
+        margin: 0.55in 0.6in 0.55in 0.6in;
+    }
+
+    @media print{
+        :global(html),
+        :global(body){
+            background: #ffffff !important;
+            color: #1c1917 !important;
+            height: auto !important;
+            overflow: visible !important;
+        }
+
+        .screenBar{
+            display: none !important;
+        }
+
+        .sheet{
+            width: auto;
+            max-width: none;
+            margin: 0;
+            padding: 0;
+            background: #ffffff;
+            box-shadow: none;
+            border-radius: 0;
+        }
+
+        .layout,
+        .ingredients,
+        .method,
+        .notes{
+            display: block !important;
+            width: 100% !important;
+            max-width: 100% !important;
+            float: none !important;
+            clear: both !important;
+            grid-column: auto !important;
+            grid-row: auto !important;
+            grid-template-columns: none !important;
+        }
+
+        .layout{
+            gap: 0 !important;
+        }
+
+        .ingredients,
+        .method,
+        .notes{
+            margin-top: 1.15rem !important;
+            padding-right: 0 !important;
+            border-right: 0 !important;
+        }
+
+        .ingredients{
+            margin-top: 0 !important;
+        }
+
+        .ingredients ul{
+            display: grid !important;
+            grid-template-columns: 1fr 1fr !important;
+            gap: 0.35rem 0.5in !important;
+            width: 100% !important;
+            float: none !important;
+        }
+
+        .ingredients li{
+            grid-template-columns: 2.1rem 2.2rem minmax(0, 1fr) !important;
+        }
+
+        h1, h2, .masthead, .method li, .ingredients li, .notes{
+            break-inside: avoid;
+            page-break-inside: avoid;
+        }
+
+        .masthead{
+            break-after: avoid;
+            page-break-after: avoid;
+        }
+
+        *{
+            print-color-adjust: exact;
+            -webkit-print-color-adjust: exact;
+        }
+    }
+</style>

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

@@ -0,0 +1,43 @@
+<script>
+    import EditableRecipe from "../EditableRecipe.svelte";
+</script>
+
+<div class="container">
+    <EditableRecipe
+        name=""
+        ingredients={[]}
+        steps={[]}
+        notes=""
+        visibility="private"
+        route="/recipe/new"
+    />
+</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;
+    }
+
+    @media (min-width: 640px){
+        .container{
+            padding: 2rem 1rem 3rem;
+        }
+    }
+
+    @media (max-height: 500px){
+        .container{
+            padding-top: 0.75rem;
+            padding-bottom: 1rem;
+        }
+    }
+</style>

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

@@ -0,0 +1,32 @@
+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(),
+        private: body.private
+    });
+
+    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");
+    }
+}

+ 50 - 0
src/routes/register/+page.svelte

@@ -0,0 +1,50 @@
+<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">
+    <form class="standardForm" method="POST" use:enhance>
+        <h1>Sign Up</h1>
+
+        <label>Name
+            <input name="name" type="text" bind:this={nameInput} required>
+        </label>
+
+        <label>Email
+            <input name="email" type="email" required>
+        </label>
+
+        <label>Password
+            <input name="password" type="password" required>
+        </label>
+
+        <label>Confirm Password
+            <input name="confirmPassword" type="password" required>
+        </label>
+
+        <button class="button">Submit</button>
+    </form>
+</div>
+
+<style>
+    .container{
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        width: 100%;
+        height: calc(100% - 75px);
+    }
+</style>