Compare commits

..

92 Commits

Author SHA1 Message Date
9d06e979b8 Merge pull request '(FEAT): Implemented creation engagement.' (#76) from feature/create-engagement into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 44s
Reviewed-on: #76
2026-01-10 12:44:30 -07:00
Hayden Hargreaves
8ae705db89 (FEAT): Implemented creation engagement. 2026-01-10 12:44:04 -07:00
e0d423f806 Merge pull request '(FEAT): Added the view all button for Connor!' (#74) from feature/view-all into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 58s
Reviewed-on: #74
2026-01-10 12:15:37 -07:00
Hayden Hargreaves
2bd894ad9a (FEAT): Added the view all button for Connor! 2026-01-10 12:15:10 -07:00
439f7d219e Merge pull request '(FIX): Fixed the description display issue.' (#72) from fix/description into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 58s
Reviewed-on: #72
2026-01-09 19:35:43 -07:00
Hayden Hargreaves
5e850d8127 (FIX): Fixed the description display issue. 2026-01-09 19:34:55 -07:00
06a7f22182 Merge pull request '(FEAT): New favicon' (#71) from refactor/react into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 57s
Reviewed-on: #71
2026-01-09 18:30:52 -07:00
43a383ef90 Merge branch 'master' into refactor/react 2026-01-09 18:30:46 -07:00
Hayden Hargreaves
ae690d6690 (FEAT): New favicon 2026-01-09 18:30:17 -07:00
4a530627ed Merge pull request '(FIX): Fixed tailwind? failwind haha' (#70) from refactor/react into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 1m3s
Reviewed-on: #70
2026-01-09 10:53:46 -07:00
Hayden Hargreaves
5c77d925cf Merge branch 'refactor/react' 2026-01-09 10:53:04 -07:00
f0498f3eb1 Merge branch 'master' into refactor/react 2026-01-09 10:52:37 -07:00
Hayden Hargreaves
dbcb9bbc2d Merge branch 'refactor/react' of gitea:azpect/Potion into refactor/react 2026-01-09 10:51:50 -07:00
Hayden Hargreaves
48f72abcae (FIX): Fixed tailwind? failwind haha 2026-01-09 10:51:11 -07:00
17cf37d4ec Merge pull request '(FIX): Dev environemnt fixes in the backend' (#69) from refactor/react into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 45s
Reviewed-on: #69
2026-01-09 10:08:40 -07:00
168ac23d39 Merge branch 'master' into refactor/react 2026-01-09 10:08:31 -07:00
Hayden Hargreaves
bb52e1bee3 (FIX): Dev environemnt fixes in the backend 2026-01-09 10:07:55 -07:00
32256b3c0e Merge pull request '(FIX): Actually fixed it' (#67) from refactor/react into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 58s
Reviewed-on: #67
2026-01-08 22:32:52 -07:00
Hayden Hargreaves
f7ebc56856 (FIX): Actually fixed it 2026-01-08 22:32:36 -07:00
a0acd0fd46 Merge pull request '(FIX): Fixing the issue with the localhost share button' (#66) from refactor/react into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 58s
Reviewed-on: #66
2026-01-08 22:22:03 -07:00
7e67413305 Merge branch 'master' into refactor/react 2026-01-08 22:21:58 -07:00
Hayden Hargreaves
339a3ca5af Merge branch 'refactor/react' of gitea:azpect/Potion into refactor/react 2026-01-08 22:21:41 -07:00
Hayden Hargreaves
ecea23355c (FIX): Fixing the issue with the localhost share button 2026-01-08 22:21:29 -07:00
38098ea671 Merge pull request '(FIX): This is funny, look at the diff' (#65) from refactor/react into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 1m2s
Reviewed-on: #65
2026-01-08 22:13:09 -07:00
bb85873d3d Merge branch 'master' into refactor/react 2026-01-08 22:13:04 -07:00
Hayden Hargreaves
adb0c0d807 Merge branch 'refactor/react' of gitea:azpect/Potion into refactor/react 2026-01-08 22:12:46 -07:00
Hayden Hargreaves
d0ef6c0b11 (FIX): This is funny, look at the diff 2026-01-08 22:12:33 -07:00
0bdf4c3029 Merge pull request 'Another CI/CD fix. Commited go.sum' (#64) from refactor/react into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 51s
Reviewed-on: #64
2026-01-08 22:08:51 -07:00
4ed9771357 Merge branch 'master' into refactor/react 2026-01-08 22:08:44 -07:00
Hayden Hargreaves
46f613aeb3 Merge branch 'refactor/react' of gitea:azpect/Potion into refactor/react 2026-01-08 22:08:20 -07:00
Hayden Hargreaves
3077dcf86a (CI/CD): We needed go.sum, so its there 2026-01-08 22:08:01 -07:00
75ac9fdb5f Merge pull request '(CI/CD) Another fix, hopefully this is the end.' (#63) from refactor/react into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 18s
Reviewed-on: #63
2026-01-08 22:00:14 -07:00
89cf123745 Merge branch 'master' into refactor/react 2026-01-08 21:59:58 -07:00
Hayden Hargreaves
c35cda5ce1 Merge branch 'refactor/react' of gitea:azpect/Potion into refactor/react 2026-01-08 21:59:25 -07:00
Hayden Hargreaves
c418aab509 (FIX): Why did we copy the go.sum...
Stupid AI
2026-01-08 21:59:07 -07:00
3841f338d6 Merge pull request 'CI/CD attempts to fix the docker containers.' (#62) from refactor/react into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 13s
Reviewed-on: #62
2026-01-08 21:55:48 -07:00
2541843b10 Merge branch 'master' into refactor/react 2026-01-08 21:55:42 -07:00
Hayden Hargreaves
fd113091a0 (CI/CD): This might actually fix the problems 2026-01-08 21:54:55 -07:00
Hayden Hargreaves
cbaf34d39c (FIX): Simple fixes that got pointed out last week.
Back to docker fixes.
2026-01-08 21:30:01 -07:00
43ef9d9490 Merge pull request 'Merging in the React Refactor' (#56) from refactor/react into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 3m51s
Reviewed-on: #56
2025-12-28 22:27:51 -07:00
Hayden Hargreaves
f93ff6501a Merge branch 'master' into refactor/react 2025-12-28 22:27:33 -07:00
Hayden Hargreaves
e6a387744e (FEAT): CI/CD attempt one. This needs a merge. 2025-12-28 22:25:52 -07:00
Hayden Hargreaves
f027a16b8c (FEAT): Deployed! Need to work on the CI/CD! 2025-12-28 22:20:39 -07:00
Hayden Hargreaves
6bbd58b471 (FIX): Migrated services to returning the IDs. 2025-12-28 18:06:21 -07:00
Hayden Hargreaves
04b6ac918a (FEAT): Implemented serving size toggle 2025-12-28 17:56:54 -07:00
Hayden Hargreaves
54c557bec5 (FEAT): Finished fixing up the search page! 2025-12-28 17:39:28 -07:00
Hayden Hargreaves
14b41d204f forgot this 2025-12-27 23:46:01 -07:00
Hayden Hargreaves
90b3b7b1b0 (FIX): So so so much has been migrated.
But this includes templ builds also... Needed for compilation. Search is
the last broken piece, I believe.
2025-12-27 23:45:09 -07:00
Hayden Hargreaves
ce6d748731 (FIX): Validation and dirty checks have been implemented.
Review this, but it looks right.
2025-12-25 21:44:52 -07:00
Hayden Hargreaves
c729e883e0 (FEAT): Worked on dockerization 2025-12-17 23:47:50 -07:00
Hayden Hargreaves
8873727585 (FIX): Extracted a little bit so the Create page is cleaner. 2025-12-17 23:19:53 -07:00
Hayden Hargreaves
deecc01c7e (REFACTOR): Yet another rewrite...
The validation has been kept in the parent Create.tsx file, and the
inputs have been moved out to their owns components. The instructions
and ingredients need validation.
2025-12-17 23:03:59 -07:00
Hayden Hargreaves
357e8772e7 forgot these 2025-12-11 20:49:34 -07:00
Hayden Hargreaves
c9aa4e62fa (FEAT): Implemented the ingredients, but not validation!
Need to update the validation to be in the parent. Review the AI output.
2025-12-11 20:48:42 -07:00
Hayden Hargreaves
d170429752 (WIP): Moving to desktop 2025-12-10 15:01:53 -07:00
Hayden Hargreaves
d18710e1fc (FEAT): Worked on tags! They seem good, but need a final check.
This was much easier than the previous implementation, gotta love JS/TS (I
hate this shit).
2025-12-02 22:55:32 -07:00
Hayden Hargreaves
58691fd7a1 (FIX): Added error display list.
This looks nice and is a bit more functional! I really need to break the
form up into components...
2025-12-02 22:37:51 -07:00
Hayden Hargreaves
728b7eb28c (FEAT): Worked on the instruction list!
This is great! Looking so much better than it did before! And it works
the way I wanted it to! Yayy!!! The reorder stuff is awesome too!
2025-12-02 22:23:05 -07:00
Hayden Hargreaves
1acc3792c5 (FEAT): Finally working on the create page.
This is a big build, but the last one! Lots of validation is done and
most of the inputs are completed. What remains are the complex elements:
tags, ingredients and instructions. Once those are done, we can start
working on the backend and making sure everything is wired together
properly.
2025-12-01 21:31:53 -07:00
Hayden Hargreaves
031df19b44 (FEAT): Added loading spinners to search and implemented context.
Filter context seems to be working! Using local storage so it can
persist.
2025-12-01 13:45:22 -07:00
Hayden Hargreaves
4093f9fd9c (FIX): Added click handler to profile recipe list items. 2025-12-01 12:30:38 -07:00
Hayden Hargreaves
90ebdd3a9a (FIX): Better styles and some small fixes. 2025-11-30 22:01:13 -07:00
Hayden Hargreaves
25ac0fd527 (FEAT): Searching is working!
So much progress! Yay!! Whats missing is the global storage of the
filters. That is the final touch for searching.
2025-11-30 21:53:07 -07:00
Hayden Hargreaves
00acb981b0 (FEAT): Created GetUserV2 API.
This is used to finish the recipe display page!
2025-11-20 12:40:49 -07:00
Hayden Hargreaves
c68cb72ffb (FEAT): Engagement for recipes is translated. 2025-11-19 13:44:41 -07:00
Hayden Hargreaves
2f5dd0dbc4 (FEAT): UI is complete, just need the actions to be implemented. 2025-11-19 12:30:56 -07:00
Hayden Hargreaves
cfaace1bfd (FEAT): Working on the recipe page! This one is making lots of progress.
But there is lots to be done!
2025-11-18 22:28:22 -07:00
Hayden Hargreaves
3905557511 (FIX): Fixed google image error.
The API is rate limited, so by defaulting to the letter images works
fine for now.
2025-11-18 21:36:22 -07:00
Hayden Hargreaves
3e138089dd (DOC): Added some optmization notes 2025-11-15 23:59:22 -07:00
Hayden Hargreaves
6cd46cffc3 (FIX): Updated the recipe of the week with optional help. 2025-11-15 23:56:10 -07:00
Hayden Hargreaves
38f3c87885 (FEAT): Migrated the home page APIS.
Just need a fix for the optinal authenticated user for the ROTW.
2025-11-15 23:43:20 -07:00
Hayden Hargreaves
c0b76506c4 (FEAT): Profile page APIs are complete!!!!
This also includes a shell.nix file for use just in case the flake
isn't.
2025-11-15 23:26:16 -07:00
Hayden Hargreaves
f66a990040 (FEAT): Profile APIs are almost totally migrated! 2025-11-14 23:46:46 -07:00
Hayden Hargreaves
983d326a47 (FIX): Auth seems to be working much better!
Finally, thank god. I still want a better way to manage the cookies. But
not right now.
2025-11-14 23:31:23 -07:00
Hayden Hargreaves
3177a4d089 (FEAT): Working on auth still 2025-11-14 22:33:54 -07:00
Hayden Hargreaves
1749a91bf9 (FEAT): Context is somewhat working
It works okay, feels a bit slugish, but that might just be the
environments fault.
2025-11-14 13:08:05 -07:00
Hayden Hargreaves
25ea3fcfd7 (FEAT): JWT auth is coming along so well!
We have it in the UI, just need a way to send it back and handle it in
the backend.
2025-11-13 22:57:05 -07:00
Hayden Hargreaves
7df879b04a (CHORE): Removed the handlers directory, they were old 2025-11-13 21:39:09 -07:00
Hayden Hargreaves
34b0cc4199 (FEAT): Translated the first API.
Auth is next...
2025-11-13 21:38:47 -07:00
Hayden Hargreaves
281fd673d3 (FEAT): Shopping list 2025-11-13 20:18:55 -07:00
Hayden Hargreaves
5db803d033 (FEAT): Create page translate, but still not functionality.
This one will be very difficult to translate.
2025-11-13 20:13:46 -07:00
Hayden Hargreaves
c9be9876e3 (FEAT): Profile page is complete, minus functionality 2025-11-13 19:57:10 -07:00
Hayden Hargreaves
45a0d0e54c (FEAT): Favorites list completed, roughly.
No functionality still.
2025-11-13 14:22:10 -07:00
Hayden Hargreaves
2916eeef61 (FEAT): Home page is awfully close to complete.
Of course it needs to be wired up, but for now it works.
2025-10-30 21:19:55 -07:00
Hayden Hargreaves
7c67225f10 (FEAT): Continued implementation of the home page.
Making lots of progress
2025-10-30 13:10:50 -07:00
119e87ba49 Merge pull request 'dev' (#53) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 42s
Reviewed-on: #53
2025-10-30 12:01:36 -07:00
Hayden Hargreaves
b8ed93fd9e (FEAT): Started work on the home page!
This is going by very quick, at least the static pieces.
2025-10-29 21:44:10 -07:00
Hayden Hargreaves
0a1b380105 (FIX): Implemented an interesting layout update. 2025-10-29 21:37:12 -07:00
Hayden Hargreaves
15c2b31e9f (FEAT): Implemented 404 page 2025-10-29 21:31:28 -07:00
Hayden Hargreaves
426c8534f0 (FEAT): Created the other pages, empty for now. 2025-10-29 21:27:47 -07:00
Hayden Hargreaves
8655df8f6b (FEAT): Navbar implemented! 2025-10-29 21:19:53 -07:00
Hayden Hargreaves
a0d4f29527 (FEAT): Init the React + TSX project!
Jeez, how far I have come...
2025-10-29 19:59:10 -07:00
152 changed files with 9867 additions and 3124 deletions

View File

@ -1,6 +1,6 @@
name: Deploy application with Docker
on:
on:
push:
branches:
- master
@ -19,10 +19,20 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push Docker image
- name: Build and push backend Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
push: true
tags: azpect3120/potion.gophernest:latest
tags: azpect3120/potion.backend:latest
- name: Build and push frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./web
file: ./web/Dockerfile
push: true
tags: azpect3120/potion.frontend:latest
build-args: |
VITE_ENVIRONMENT=prod

1
.gitignore vendored
View File

@ -1,4 +1,3 @@
/flake.lock
/go.sum
/.env
/*.dump

View File

@ -1,57 +1,14 @@
# Fetch stage
FROM golang:latest AS fetch-stage
COPY . /app
FROM golang:1.25-alpine
WORKDIR /app
RUN go mod tidy
COPY go.mod go.sum ./
RUN go mod download
# Generate stage
FROM ghcr.io/a-h/templ:latest AS generate-stage
COPY . .
COPY --chown=65532:65532 . /app
RUN go build -o server ./cmd/web/main.go
WORKDIR /app
EXPOSE 8080
RUN ["templ", "generate"]
# Generate stage two
FROM node:lts-alpine AS tailwind-build-stage
COPY --from=generate-stage /app /app
WORKDIR /app
RUN npm install tailwindcss @tailwindcss/cli && npx @tailwindcss/cli -i ./web/static/css/main.css -o ./web/static/css/tailwind.css --minify -c ./tailwind.config.js
# Build stage
FROM golang:latest AS build-stage
COPY --from=tailwind-build-stage /app /app
WORKDIR /app
RUN go mod tidy
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -o /entrypoint /app/cmd/web/main.go
# Deploy.
FROM gcr.io/distroless/static-debian11 AS release-stage
WORKDIR /
COPY --from=build-stage /entrypoint /entrypoint
COPY --from=build-stage /app/web/static /web/static
EXPOSE 3000
USER nonroot:nonroot
ENTRYPOINT ["/entrypoint"]
CMD ["./server"]

57
Dockerfile.old Normal file
View File

@ -0,0 +1,57 @@
# Fetch stage
FROM golang:latest AS fetch-stage
COPY . /app
WORKDIR /app
RUN go mod tidy
RUN go mod download
# Generate stage
FROM ghcr.io/a-h/templ:latest AS generate-stage
COPY --chown=65532:65532 . /app
WORKDIR /app
RUN ["templ", "generate"]
# Generate stage two
FROM node:lts-alpine AS tailwind-build-stage
COPY --from=generate-stage /app /app
WORKDIR /app
RUN npm install tailwindcss @tailwindcss/cli && npx @tailwindcss/cli -i ./web/static/css/main.css -o ./web/static/css/tailwind.css --minify -c ./tailwind.config.js
# Build stage
FROM golang:latest AS build-stage
COPY --from=tailwind-build-stage /app /app
WORKDIR /app
RUN go mod tidy
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -o /entrypoint /app/cmd/web/main.go
# Deploy.
FROM gcr.io/distroless/static-debian11 AS release-stage
WORKDIR /
COPY --from=build-stage /entrypoint /entrypoint
COPY --from=build-stage /app/web/static /web/static
EXPOSE 3000
USER nonroot:nonroot
ENTRYPOINT ["/entrypoint"]

6
docker-compose.yml Normal file
View File

@ -0,0 +1,6 @@
services:
app:
build: ./web/.
container_name: potion.frontend
ports:
- "3002:3002" # host:container

View File

@ -29,6 +29,7 @@
dockerfile-language-server-nodejs
gcc_multi
glibc_multi
nodejs
];
# Define the shell that will be executed.

142
go.sum Normal file
View File

@ -0,0 +1,142 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/a-h/templ v0.3.898 h1:g9oxL/dmM6tvwRe2egJS8hBDQTncokbMoOFk1oJMX7s=
github.com/a-h/templ v0.3.898/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ=
github.com/a-h/templ v0.3.920 h1:IQjjTu4KGrYreHo/ewzSeS8uefecisPayIIc9VflLSE=
github.com/a-h/templ v0.3.920/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
github.com/a-h/templ/examples/integration-gin v0.0.0-20250610141150-9b34663a6ef0 h1:WEatUfTkcJJnOtljkxt8+zmn48Cw6tNXwoPn6uXh6Zw=
github.com/a-h/templ/examples/integration-gin v0.0.0-20250610141150-9b34663a6ef0/go.mod h1:WMIQoAHS6bEBr6QPgD8N8F/cj10Wxm74+YQ8BJI1Xng=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -1,47 +0,0 @@
package handlers
// GoogleLogin directs the user to Googles select user login page. Once the user has selected an
// account, they will be directed to the GoogleCallback handler where the main logic resides.
//
// DEPRECATED: As of September 4th, 2025.
// func GoogleLogin(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// url := deps.AuthService.GetGoogleAuthUrl()
//
// ctx.Redirect(http.StatusSeeOther, url)
// }
// GoogleCallback is the callback handler when the user successfully logs in with their Google
// account. They will be directed here and a JWT is generated. This JWT is stored in the users
// cookies and will be used by protected routes to validate their login status.
//
// We do not need to return all of this data, it is just for testing.
//
// DEPRECATED: As of September 4th, 2025.
// func GoogleCallback(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// var (
// state string = ctx.Query("state")
// code string = ctx.Query("code")
// )
//
// if jwt, err := deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// } else {
// domain.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7)
// ctx.Redirect(http.StatusSeeOther, "/")
// }
// }
// Logout removes the token from the user's browser. Effectively "logging them out." Routes that
// require authentication will require the user to sign back in before accessing them again.
// This route will direct the user back to the home page.
//
// DEPRECATED: As of September 4th, 2025.
// func Logout(ctx *gin.Context) {
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME)
// }

View File

@ -1,142 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// func EngagementViewRecipe(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// recipeId, _ := strconv.Atoi(ctx.Param("id"))
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// if !domain.IsLoggedIn(ctx) || user == nil {
// if _, err := deps.EngagementService.ViewRecipe(recipeId); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId))
// ctx.Status(http.StatusOK)
// }
// return
// }
//
// // We caught nil already, we can assume the user exists
// if _, err := deps.EngagementService.UserViewRecipe(user.Id, recipeId); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId))
// ctx.Status(http.StatusOK)
// }
// }
// DEPRECATED: As of September 4th, 2025.
// func EngagementShareRecipe(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// recipeId, _ := strconv.Atoi(ctx.Param("id"))
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// if !domain.IsLoggedIn(ctx) || user == nil {
// if _, err := deps.EngagementService.ShareRecipe(recipeId); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Status(http.StatusNoContent)
// }
// return
// }
//
// if _, err := deps.EngagementService.UserShareRecipe(user.Id, recipeId); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Status(http.StatusNoContent)
// }
// }
// DEPRECATED: As of September 4th, 2025.
// func EngagementFavoriteRecipe(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// if !domain.IsLoggedIn(ctx) || user == nil {
// ctx.Header("HX-Redirect", domain.WEB_LOGIN)
// ctx.Status(http.StatusOK)
// return
// }
//
// id := ctx.Param("id")
// recipeId, _ := strconv.Atoi(id)
//
// if _, err := deps.EngagementService.UserFavoriteRecipe(user.Id, recipeId); err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Something went wrong. %s.", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Status(http.StatusNoContent)
// }
// }
// DEPRECATED: As of September 4th, 2025.
// func EngagementMakeRecipe(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// if !domain.IsLoggedIn(ctx) || user == nil {
// ctx.Header("HX-Redirect", domain.WEB_LOGIN)
// ctx.Status(http.StatusOK)
// return
// }
//
// id := ctx.Param("id")
// recipeId, _ := strconv.Atoi(id)
//
// if _, err := deps.EngagementService.UserMakeRecipe(user.Id, recipeId); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Status(http.StatusNoContent)
// }
// }

View File

@ -1,296 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// func LoginPage(ctx *gin.Context) {
// title := "Potion - Login"
// page := pages.LoginPage()
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
// DEPRECATED: As of September 4th, 2025.
// func HomePage(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
//
// loggedIn := domain.IsLoggedIn(ctx)
//
// // Ensure user is logged in with a valid account
// if user := deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// loggedIn = false
// }
//
// var page templ.Component
// if loggedIn {
// userId := ctx.MustGet("userId").(int)
// madeRecipes, err := deps.RecipeService.GetUserMadeRecipes(userId, 6)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting made recipes. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
// })
// return
// }
// viewedRecipes, err := deps.RecipeService.GetUserViewedRecipes(userId, 6)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting viewed recipes. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting viewed recipes. %s\n", err.Error()),
// })
// return
// }
//
// // Get the recipe of the week
// recipeOfTheWeek, err := deps.RecipeService.GetRecipeOfTheWeek(&userId)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()),
// })
// return
// }
//
// if bytes, err := ctx.Cookie("search-filters"); err != nil {
// fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
// page = templates.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, nil)
// } else {
// var filters domainRecipe.SearchFilters
// if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
// fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
// page = templates.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, nil)
// } else {
// page = templates.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, &filters)
// }
// }
// } else {
// // Get the recipe of the week
// recipeOfTheWeek, err := deps.RecipeService.GetRecipeOfTheWeek(nil)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()),
// })
// return
// }
//
// if bytes, err := ctx.Cookie("search-filters"); err != nil {
// fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
// page = templates.HomePage(false, nil, nil, recipeOfTheWeek, nil)
// } else {
// var filters domainRecipe.SearchFilters
// if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
// fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
// page = templates.HomePage(false, nil, nil, recipeOfTheWeek, nil)
// } else {
// page = templates.HomePage(false, nil, nil, recipeOfTheWeek, &filters)
// }
// }
// }
//
// title := "Potion - Home"
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
// DEPRECATED: As of September 4th, 2025.
// func FavoritesPage(ctx *gin.Context) {
// // If not logged in, direct to the login page
// if !domainServer.IsLoggedIn(ctx) {
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// title := "Potion - Favorites"
// var page templ.Component
//
// // Get filters from cookies
// if bytes, err := ctx.Cookie("search-filters"); err != nil {
// fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
// page = pages.FavoritesPage(nil)
// } else {
// var filters domainRecipe.SearchFilters
// if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
// fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
// page = pages.FavoritesPage(nil)
// } else {
// page = pages.FavoritesPage(&filters)
// }
// }
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
//
// func CreatePage(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
//
// // If not logged in, direct to the login page
// if !domainServer.IsLoggedIn(ctx) {
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// // Ensure user is logged in with a valid account
// if user := deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
//
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// title := "Potion - Create"
// page := pages.CreatePage()
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
// DEPRECATED: As of September 4th, 2025.
// func ProfilePage(ctx *gin.Context) {
// // If not logged in, direct to the login page
// if !domainServer.IsLoggedIn(ctx) {
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// // Else, get the user data
// deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // User is failing to be found, direct to the login page
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// recipes, err := deps.RecipeService.GetUserRecipes(user.Id)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipes. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting recipes. %s\n", err.Error()),
// })
// return
// }
//
// favorites, err := deps.RecipeService.GetUserFavoriteRecipes(user.Id)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting favorite recipes. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting favorite recipes. %s\n", err.Error()),
// })
// return
// }
//
// // Get the engagement data, not sure what will happen when errors occur
// engagements, err := deps.EngagementService.GetUserEngagement(user.Id, 6)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting user engagements. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting user engagements. %s\n", err.Error()),
// })
// return
// }
//
// title := "Potion - Profile"
// page := pages.ProfilePage(*user, recipes, favorites, engagements)
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
// DEPRECATED: As of September 4th, 2025.
// func ListPage(ctx *gin.Context) {
// title := "Potion - Shopping List"
// page := pages.ListPage()
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
// DEPRECATED: As of September 4th, 2025.
// func RecipePage(ctx *gin.Context) {
// // Call recipe service to get via ID
// deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
// id := ctx.Param("id")
//
// // Parse ID
// parsed, err := strconv.Atoi(id)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
// ctx.JSON(400, err.Error())
// return
// }
//
// // Get signed in user, if they exist
// var userId *int = nil
// var loggedIn = domainServer.IsLoggedIn(ctx)
//
// // Ensure user is logged in with a valid account
// if user := deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// loggedIn = false
// }
//
// if loggedIn {
// storeId := ctx.MustGet("userId").(int)
// userId = &storeId
// }
//
// // Get recipe
// recipe, err := deps.RecipeService.GetRecipe(parsed, userId)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
// ctx.JSON(400, err.Error())
// return
// }
//
// // Get user (owner)
// user, err := deps.UserService.GetUser(recipe.UserId)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
// ctx.JSON(400, err.Error())
// return
// }
//
// title := "Potion - View Recipe"
// page := pages.RecipePage(*recipe, *user, loggedIn, deps.EnvironmentConfig.Domain)
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
//
// func SearchPage(ctx *gin.Context) {
// var page templ.Component
// // Get filters from cookies
// if bytes, err := ctx.Cookie("search-filters"); err != nil {
// fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
// page = pages.SearchPage(nil, false)
// } else {
// var filters domainRecipe.SearchFilters
// if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
// fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
// page = pages.SearchPage(nil, false)
// } else {
// page = pages.SearchPage(&filters, true)
// }
// }
//
// title := "Potion - Recipe Search"
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
// DEPRECATED: As of September 4th, 2025.
// func NotFoundPage(ctx *gin.Context) {
// title := "Potion - Not Found"
// page := pages.NotFoundPage()
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }

View File

@ -1,136 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// const CREATE_ERROR_HTML = `
// <p id="response" class="text-sm text-red-500 px-4 py-1 bg-red-100 rounded-full w-fit">
// Uh oh! Something went wrong when creating your recipe. Please try again. %s
// </p>
// `
// DEPRECATED: As of September 4th, 2025.
// func CreateRecipe(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// recipe, err := deps.RecipeService.CreateRecipe(ctx)
// if err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.String(http.StatusOK, CREATE_ERROR_HTML, err.Error())
// return
// }
//
// // Send HTMX redirection
// url := fmt.Sprintf(domain.WEB_RECIPE, recipe.Id)
// ctx.Header("HX-Redirect", url)
// ctx.Status(http.StatusCreated)
// }
// toBits converts an array of stringified numbers into a single summed value
//
// DEPRECATED: As of September 4th, 2025.
// func toBits(arr []string) (bits int) {
// for _, x := range arr {
// num, _ := strconv.Atoi(x)
// bits += num
// }
// return
// }
// DEPRECATED: As of September 4th, 2025.
// func SearchRecipes(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// // create filters
// filters := domainRecipe.SearchFilters{
// Search: ctx.PostForm("search"), // string, search query for titles
// MealType: toBits(ctx.PostFormArray("meal")),
// Time: toBits(ctx.PostFormArray("time")),
// Difficulty: toBits(ctx.PostFormArray("difficulty")),
// ServingSize: toBits(ctx.PostFormArray("serving")),
// }
//
// // Set the filters into the cookies, so they can be reloaded
// if bytes, err := json.Marshal(filters); err == nil {
// domain.SetCookie(ctx, "search-filters", string(bytes), time.Hour*24)
// // ctx.SetCookie(
// // "search-filters",
// // string(bytes),
// // int(time.Now().Add(24*time.Hour).Sub(time.Now()).Seconds()),
// // "/",
// // true,
// // )
// }
//
// redirect := ctx.PostForm("redirect")
// if redirect == "true" {
// ctx.Header("HX-Redirect", domain.WEB_SEARCH)
// ctx.Status(http.StatusOK)
// return
// }
//
// // Get user if logged in, so we can get favorite status
// var userId *int = nil
// if domain.IsLoggedIn(ctx) {
// id := ctx.MustGet("userId").(int)
// userId = &id
// }
//
// // TODO: Not sure if we need to ensure the user is valid here
//
// // We don't care about favorite status, so use false
// recipes, err := deps.RecipeService.SearchRecipes(filters, userId, false)
// if err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusOK, gin.H{"error": err.Error()})
// }
//
// // Render content as the response
// ctx.Status(200)
// templates.ResultList(recipes).Render(ctx.Request.Context(), ctx.Writer)
// }
// DEPRECATED: As of September 4th, 2025.
// func SearchRecipesFavorites(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// // create filters
// filters := domainRecipe.SearchFilters{
// Search: ctx.PostForm("search"), // string, search query for titles
// MealType: toBits(ctx.PostFormArray("meal")),
// Time: toBits(ctx.PostFormArray("time")),
// Difficulty: toBits(ctx.PostFormArray("difficulty")),
// ServingSize: toBits(ctx.PostFormArray("serving")),
// }
//
// // Set the filters into the cookies, so they can be reloaded
// if bytes, err := json.Marshal(filters); err == nil {
// domain.SetCookie(ctx, "search-filters", string(bytes), time.Hour*24)
// // ctx.SetCookie(
// // "search-filters",
// // string(bytes),
// // int(time.Now().Add(24*time.Hour).Sub(time.Now()).Seconds()),
// // "/",
// // "", // TODO: Need an actual domain
// // false, // TODO: True in prod
// // true,
// // )
// }
//
// // TODO: Error here if they're not logged in?
// // Get user data (they should be logged in)
// if !domain.IsLoggedIn(ctx) {
// components.RenderErrorBanner(ctx, "User is not logged in. User will be nil.")
// ctx.JSON(http.StatusOK, gin.H{"error": "User is not logged in. User will be nil."})
// }
//
// userId := ctx.MustGet("userId").(int)
//
// recipes, err := deps.RecipeService.SearchRecipes(filters, &userId, true)
// if err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusOK, gin.H{"error": err.Error()})
// }
//
// // Render content as the response
// ctx.Status(200)
// templates.FavoriteList(recipes).Render(ctx.Request.Context(), ctx.Writer)
// }

View File

@ -1,71 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// const TAG_HTML = `
// <li
// hx-post="%s"
// hx-trigger="click"
// hx-target="#tag-list"
// hx-swap="innerHTML"
// hx-include="#tag-list"
// hx-vals='{"target": "%s"}'
// class="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300">
// &times; %s
// </li>
// `
// DEPRECATED: As of September 4th, 2025.
// const TAG_LIST_HTML = `
// <input
// hx-swap-oob="outerHTML"
// type="hidden"
// name="tags"
// id="tags"
// value="%s"
// />
// `
// DEPRECATED: As of September 4th, 2025.
// func NewTag(ctx *gin.Context) {
// tag := strings.ToLower(ctx.PostForm("tag"))
// tags := strings.Split(ctx.PostForm("tags"), ",")
//
// tags = append([]string{tag}, tags...)
//
// var html string
// var cleaned_tags []string
// for _, tag := range tags {
// if tag != "" {
// html += fmt.Sprintf(TAG_HTML, domain.STATE_TAGS_DELETE, tag, tag)
//
// // Ensure that the list provided does not contain blank spaces.
// // This is another measure to ensure this state is bulletproof.
// cleaned_tags = append(cleaned_tags, tag)
// }
// }
//
// // Execute OOB swap for the tags
// html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(cleaned_tags, ","))
//
// ctx.String(http.StatusOK, html)
// }
// DEPRECATED: As of September 4th, 2025.
// func DeleteTag(ctx *gin.Context) {
// tags := strings.Split(ctx.PostForm("tags"), ",")
// target := ctx.PostForm("target")
//
// var html string
// var new_tags []string
// for _, tag := range tags {
// if tag != target && tag != "" {
// html += fmt.Sprintf(TAG_HTML, domain.STATE_TAGS_DELETE ,tag, tag)
// new_tags = append(new_tags, tag)
// }
// }
//
// // Execute OOB swap for the tags
// html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(new_tags, ","))
//
// ctx.String(http.StatusOK, html)
// }

View File

@ -1,83 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// func GetUserRecipes(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// // Ensure logged in
// if !domain.IsLoggedIn(ctx) || user == nil {
// components.RenderErrorBanner(ctx, "User is not authorized to access this endpoint. Please login to continue.")
// ctx.JSON(http.StatusUnauthorized, gin.H{
// "status": http.StatusUnauthorized,
// "message": "User is not authorized to access this endpoint. Please login to continue.",
// "recipes": nil,
// })
// return
// }
//
// recipes, err := deps.RecipeService.GetUserRecipes(user.Id)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Could not get user recipes. %s", err.Error()))
// ctx.JSON(http.StatusBadRequest, gin.H{
// "status": http.StatusBadRequest,
// "message": fmt.Sprintf("Could not get user recipes. %s", err.Error()),
// "recipes": nil,
// })
// return
// }
//
// ctx.JSON(http.StatusOK, gin.H{
// "status": http.StatusOK,
// "message": "User recipes successfully retrieved.",
// "recipes": recipes,
// })
// }
// DEPRECATED: As of September 4th, 2025.
// func GetUserFavoriteRecipes(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// // Ensure logged in
// if !domain.IsLoggedIn(ctx) || user == nil {
// components.RenderErrorBanner(ctx, "User is not authorized to access this endpoint. Please login to continue.")
// ctx.JSON(http.StatusUnauthorized, gin.H{
// "status": http.StatusUnauthorized,
// "message": "User is not authorized to access this endpoint. Please login to continue.",
// "recipes": nil,
// })
// return
// }
//
// recipes, err := deps.RecipeService.GetUserFavoriteRecipes(user.Id)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Could not get favorite recipes. %s", err.Error()))
// ctx.JSON(http.StatusBadRequest, gin.H{
// "status": http.StatusBadRequest,
// "message": fmt.Sprintf("Could not get favorite recipes. %s", err.Error()),
// "recipes": nil,
// })
// return
// }
//
// ctx.JSON(http.StatusOK, gin.H{
// "status": http.StatusOK,
// "message": "User recipes successfully retrieved.",
// "recipes": recipes,
// })
// }

View File

@ -0,0 +1,48 @@
package server
import (
"fmt"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
)
// GetGoogleAuthUrlHandlerV2 fetches a Google authentication URl and returns it.
// This function is atomic and cannot fail.
func (s *Server) GetGoogleAuthUrlHandlerV2(ctx *gin.Context) {
url := s.deps.AuthService.GetGoogleAuthUrl()
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved Google auth URL.",
"url": url,
})
}
// GoogleCallbackHandlerV2 reads the data from the Google redirection and uses it
// to generate a JWT which is sent back to the UI via a URL query parameter. If an
// error occurs the user will be directed to the login page with an error query param.
func (s *Server) GoogleCallbackHandlerV2(ctx *gin.Context) {
var (
state string = ctx.Query("state")
code string = ctx.Query("code")
)
domain := s.deps.EnvironmentConfig.FrontendDomain
if jwt, err := s.deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
url := fmt.Sprintf("%s/v2/web/login?error=%s", domain, url.QueryEscape(err.Error()))
ctx.Redirect(http.StatusSeeOther, url)
} else {
url := fmt.Sprintf("%s/v2/web/home", domain)
s.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7)
ctx.Redirect(http.StatusSeeOther, url)
}
}
func (s *Server) LogoutHandlerV2(ctx *gin.Context) {
s.SetCookie(ctx, "jwt_token", "", -1)
// s.SetCookie(ctx, "search-filters", "", -1) // TODO: This was copied, might function differently now
ctx.Status(http.StatusNoContent)
}

View File

@ -0,0 +1,49 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
// AuthenticatedFunc is a function that handles authenticated requests
type AuthenticatedFunc func(ctx *gin.Context, user *domain.User)
// withAuthenticatedUser is a helper to run a handler only if user is authenticated. Otherwise
// the function will return an error with a 401 status.
//
// BUG: This is probably not very effecient, since we hit the DB on every single protected request.
//
// If this ends up being a bottle neck we could simply hit the context for the userId, since
// that is usually all we need...Or maybe have two methods, for those that need the whole user
// and those that just need the ID.
func (s *Server) withAuthenticatedUser(ctx *gin.Context, handler AuthenticatedFunc) {
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// User is stale, ensure they are logged out so they can be prompted to log back in
s.SetCookie(ctx, "jwt_token", "", -1)
// s.SetCookie(ctx, "search-filters", "", -1) // TODO: Might need this again
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Could not fetch authenticated user.",
})
return
}
handler(ctx, user)
}
// getUserId retrieves the userId from the context and returns a pointer to it. A nil
// pointer can be returned and will if the userId does not exist.
func getUserId(ctx *gin.Context) *int {
userIdAny, exists := ctx.Get("userId")
if !exists {
return nil
}
userIdInt, ok := userIdAny.(int)
if !ok {
return nil
}
return &userIdInt
}

View File

@ -1,6 +1,7 @@
package server
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
@ -18,10 +19,8 @@ import (
func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.Duration) {
var (
path string = "/"
httpOnly bool = true
httpOnly bool = false // NOTE: Should use false so React can see it!
maxAge int
secure bool
domain string
)
if duration < 0 {
@ -32,22 +31,49 @@ func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.D
maxAge = 0
} else {
// Normal calculation
maxAge = int(time.Now().Add(duration).Sub(time.Now()).Seconds())
maxAge = int(time.Until(time.Now().Add(duration)).Seconds())
}
if s.deps.EnvironmentConfig.Environment == "prod" {
secure = true
domain = s.deps.EnvironmentConfig.Domain
} else if s.deps.EnvironmentConfig.Environment == "dev" {
secure = false
domain = s.deps.EnvironmentConfig.Domain
} else {
// Defaults
secure = false
domain = ""
switch s.deps.EnvironmentConfig.Environment {
case "prod":
// Cross-site between subdomains, HTTPS only
ctx.SetSameSite(http.SameSiteNoneMode)
ctx.SetCookie(
name,
value,
maxAge,
path,
".gophernest.net", // or your backend domain / parent
true, // secure
httpOnly,
)
case "dev":
// Local dev on http://localhost:PORT
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
name,
value,
maxAge,
path,
"", // no Domain → default to current host
false, // not secure on plain HTTP
httpOnly,
)
}
ctx.SetCookie(name, value, maxAge, path, domain, secure, httpOnly)
// TODO: This whole system is stupid now
// if s.deps.EnvironmentConfig.Environment == "prod" {
// secure = true
// // domain = "potion.gophernest"
// // domain = s.deps.EnvironmentConfig.Domain
// domain = ".gophernest.net"
//
// } else if s.deps.EnvironmentConfig.Environment == "dev" {
// secure = false
// // domain = s.deps.EnvironmentConfig.Domain
// domain = "localhost"
// }
//
// ctx.SetSameSite(http.SameSiteNoneMode)
// ctx.SetCookie(name, value, maxAge, path, domain, secure, httpOnly)
}

View File

@ -0,0 +1,143 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
func (s *Server) EngagementViewRecipeHandlerV2(ctx *gin.Context) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
userId := getUserId(ctx)
if userId == nil {
if engagement, err := s.deps.EngagementService.ViewRecipe(recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
return
}
if engagement, err := s.deps.EngagementService.UserViewRecipe(*userId, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
}
func (s *Server) EngagementShareRecipeHandlerV2(ctx *gin.Context) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
userId := getUserId(ctx)
if userId == nil {
if engagement, err := s.deps.EngagementService.ShareRecipe(recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
return
}
if engagement, err := s.deps.EngagementService.UserShareRecipe(*userId, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
}
func (s *Server) EngagementFavoriteRecipeHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
if engagement, err := s.deps.EngagementService.UserFavoriteRecipe(user.Id, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
})
}
func (s *Server) EngagementMakeRecipeHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
if engagement, err := s.deps.EngagementService.UserMakeRecipe(user.Id, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
})
}

View File

@ -0,0 +1,98 @@
package server
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
)
// JwtAuthMiddlewareV2 is responsible for protecting routes. Anything that may go wrong
// will be returned via JSON with a 'message' field and a 401 error code. When this
// middleware is successful, it will set the 'userId' and 'userEmail' fields and pass
// to the next function in the chain.
//
// Functions that are called after this can assume that those values defined are always
// set.
func JwtAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
return func(ctx *gin.Context) {
tokenString, err := ctx.Cookie("jwt_token")
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": fmt.Sprintf("[UNAUTHORIZED] Failed to get token from cookie. %s", err.Error()),
})
ctx.Abort()
return
}
claims := &domain.JwtClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSecretKey, nil
})
// Error occurred when parsing
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": fmt.Sprintf("[UNAUTHORIZED] Error parsing cooking. %s", err.Error()),
})
ctx.Abort()
return
}
// Token is invalid
if !token.Valid {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Token is invalid.",
})
ctx.Abort()
return
}
// Found: Set the values
ctx.Set("userId", claims.UserId)
ctx.Set("userEmail", claims.Email)
ctx.Next()
}
}
// JwtOptionalAuthMiddlewareV2 is responsible for collecting user data for routes where
// authentication is optional. Meaning: if the use is not logged in, this function does
// not fail or return, it simply does nothing. But if the user is logged in, then the
// 'userId' and 'userEmail' context values are set.
//
// e.g., `userIdAny, exists := ctx.Get("userId")`
func JwtOptionalAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
return func(ctx *gin.Context) {
tokenString, err := ctx.Cookie("jwt_token")
if err != nil || tokenString == "" {
// No cookie found: not authenticated, but allow access
ctx.Next()
return
}
claims := &domain.JwtClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSecretKey, nil
})
if err == nil && token.Valid {
// Set user info in context if token is valid
ctx.Set("userId", claims.UserId)
ctx.Set("userEmail", claims.Email)
}
// Otherwise, just continue (user is unauthenticated)
ctx.Next()
}
}

View File

@ -0,0 +1,131 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
)
// GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it.
// If an error occurs, it will be returned and a recipe will not be returned.
//
// BUG: Until auth is reimplemented, there is no way to determine what user is making the
// call.
// NOTE: I believe this issue has been resolved
func (s *Server) GetRecipeOfTheWeekHandlerV2(ctx *gin.Context) {
userId := getUserId(ctx)
recipe, err := s.deps.RecipeService.GetRecipeOfTheWeek(userId)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get recipe of the week. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved recipe of the week.",
"recipe": recipe,
})
}
func (s *Server) GetRecipeHandlerV2(ctx *gin.Context) {
id := ctx.Param("id")
parsedId, err := strconv.Atoi(id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
userId := getUserId(ctx)
recipe, err := s.deps.RecipeService.GetRecipe(parsedId, userId)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get recipe. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved recipe.",
"recipe": recipe,
})
}
func (s *Server) SearchRecipeHandlerV2(ctx *gin.Context) {
var filters domain.SearchFilters
// Parse filters
if err := ctx.ShouldBindJSON(&filters); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse filters. %s", err.Error()),
})
return
}
// This is optional, so we can do this
userId := getUserId(ctx)
// Did I really have two APIs...?
// TODO: Fix service at some point, no need to accept the favorites (bool) param
recipes, err := s.deps.RecipeService.SearchRecipes(filters, userId, filters.Favorites)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get searched recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved recipes based on provided filters.",
"recipes": recipes,
})
}
func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
userId := getUserId(ctx)
if userId == nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "[ERROR] User must be logged in to create a recipe.",
})
return
}
recipe, err := s.deps.RecipeService.CreateRecipe(ctx)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create recipe. %s", err.Error()),
})
return
}
_, err = s.deps.EngagementService.UserCreateRecipe(*userId, recipe.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created new recipe.",
"recipe": recipe,
})
}

View File

@ -42,7 +42,11 @@ func Init(port int) *Server {
server.Router.SetTrustedProxies(nil)
// Setup the CORS settings and active them
server.config.AllowAllOrigins = true
// server.config.AllowAllOrigins = true
server.config.AllowOrigins = []string{"http://localhost:5173", "https://potion.gophernest.net"}
server.config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE"}
server.config.AllowHeaders = []string{"Origin", "Content-Type", "Authorization"}
server.config.AllowCredentials = true
server.Router.Use(cors.New(server.config))
return server
@ -74,7 +78,8 @@ func (s *Server) Setup() *Server {
// SETUP GOOGLE AUTH
var (
redirectUrl string = fmt.Sprintf("%s%s", cfg.Domain, domain.API_AUTH_CALLBACK)
// NOTE: USING V2 NOW
redirectUrl string = fmt.Sprintf("%s%s", cfg.Domain, domain.API_AUTH_CALLBACK_V2)
clientId string = cfg.GoogleClientId
clientSecret string = cfg.GoogleClientSecret
scope []string = []string{
@ -120,13 +125,15 @@ func (s *Server) Setup() *Server {
// Apply middleware
s.Router.Use(RecoveryMiddleware())
s.Router.Use(JwtAuthMiddleWare(jwtSecret))
// NOTE: No longer running on every connection!
// s.Router.Use(JwtAuthMiddleWare(jwtSecret))
// Redirect index to home page: Update this as needed
s.Router.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) })
// Wrap all routes with a version
router_v1 := s.Router.Group(domain.VERSION)
router_v1 := s.Router.Group(domain.VERSION_1)
router_v2 := s.Router.Group(domain.VERSION_2)
// Domain specific routers
router_web := router_v1.Group(domain.WEB)
@ -179,7 +186,7 @@ func (s *Server) Setup() *Server {
path := ctx.Request.URL.Path
// TODO: Use constants for errors?
if strings.HasPrefix(path, domain.VERSION+domain.API) {
if strings.HasPrefix(path, domain.VERSION_1+domain.API) {
ctx.JSON(http.StatusNotFound, gin.H{
"status": http.StatusNotFound,
"error": "API_NOT_FOUND",
@ -192,5 +199,34 @@ func (s *Server) Setup() *Server {
ctx.Redirect(http.StatusSeeOther, domain.WEB_NOT_FOUND)
})
// ---- VERSION 2 ROUTES ---- //
router_api_v2 := router_v2.Group(domain.API)
router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeHandlerV2)
router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2)
router_api_v2.POST("/recipe/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
router_api_v2.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2)
router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2)
router_api_v2.GET("/auth/logout", s.LogoutHandlerV2)
router_api_v2.GET("/user", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenticatedUserHandlerV2)
router_api_v2.GET("/user/:id", s.GetUserV2)
router_api_v2.GET("/user/recipes", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserRecipesV2)
router_api_v2.GET("/user/favorites", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserFavoritesV2)
router_api_v2.GET("/user/engagement", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserEngagementV2)
router_api_v2.GET("/user/recipes/made", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserMadeRecipesV2)
router_api_v2.GET("/user/recipes/viewed", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserViewedRecipesV2)
router_api_v2.GET("/protected", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"msg": "YAY"})
})
router_api_v2.POST("/engagement/view/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementViewRecipeHandlerV2)
router_api_v2.POST("/engagement/share/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementShareRecipeHandlerV2)
router_api_v2.POST("/engagement/favorite/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementFavoriteRecipeHandlerV2)
router_api_v2.POST("/engagement/make/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementMakeRecipeHandlerV2)
return s
}

View File

@ -0,0 +1,142 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
func (s *Server) GetUserV2(ctx *gin.Context) {
id := ctx.Param("id")
parsedId, err := strconv.Atoi(id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
user, err := s.deps.UserService.GetUser(parsedId)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get the target user. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved target user.",
"user": user,
})
}
func (s *Server) GetAuthenticatedUserHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user.",
"user": user,
})
})
}
func (s *Server) GetAuthenicatedUserRecipesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipes, err := s.deps.RecipeService.GetUserRecipes(user.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Could not fetch authenticated users's recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's recipes.",
"recipes": recipes,
})
})
}
func (s *Server) GetAuthenicatedUserFavoritesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
favorites, err := s.deps.RecipeService.GetUserFavoriteRecipes(user.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Could not fetch authenticated users's favorites. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's favorites.",
"favorites": favorites,
})
})
}
func (s *Server) GetAuthenicatedUserEngagementV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
engagement, err := s.deps.EngagementService.GetUserEngagement(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get authenticated user engagement. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user engagement.",
"engagement": engagement,
})
})
}
func (s *Server) GetAuthenicatedUserMadeRecipesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipes, err := s.deps.RecipeService.GetUserMadeRecipes(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get authenticated user's made recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's made recipes.",
"recipes": recipes,
})
})
}
func (s *Server) GetAuthenicatedUserViewedRecipesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipes, err := s.deps.RecipeService.GetUserViewedRecipes(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get authenticated user's viewed recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's viewed recipes.",
"recipes": recipes,
})
})
}

View File

@ -122,6 +122,34 @@ func (s *EngagementService) UserShareRecipe(userId, recipeId int) (domain.Engage
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementShared)
}
// UserCreateRecipe requires a user ID and a recipe ID to create an engagement record in the database.
// A message will be generated using the recipe data and then used to add a make engagement to the
// database.
func (s *EngagementService) UserCreateRecipe(userId, recipeId int) (domain.Engagement, error) {
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
if err != nil {
return domain.Engagement{}, err
}
message := fmt.Sprintf("Created \"%s\"", recipe.Title)
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementCreated)
}
// UserDeleteRecipe requires a user ID and a recipe ID to create an engagement record in the database.
// A message will be generated using the recipe data and then used to add a make engagement to the
// database.
func (s *EngagementService) UserDeleteRecipe(userId, recipeId int) (domain.Engagement, error) {
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
if err != nil {
return domain.Engagement{}, err
}
message := fmt.Sprintf("Deleted \"%s\"", recipe.Title)
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementDeleted)
}
// GetUserEngagement returns a list of the users most recent engagement entries. The number of records
// is determined by the limit passed into this function. The results are sorted, newest-to-oldest.
func (s *EngagementService) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {

View File

@ -1,11 +1,7 @@
package service
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@ -45,66 +41,25 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
return nil, fmt.Errorf("User is not logged in.")
}
title := ctx.PostForm("title")
description := ctx.PostForm("description")
preparation := ctx.PostForm("preparation-time")
cook := ctx.PostForm("cook-time")
serving := ctx.PostForm("serving-size")
category := ctx.PostForm("category")
difficulty := ctx.PostForm("difficulty")
ingredients := ctx.PostFormArray("ingredients")
quantity := ctx.PostFormArray("quantity")
instructions := ctx.PostFormArray("instructions")
tags := strings.Split(ctx.PostForm("tags"), ",")
userId := ctx.MustGet("userId").(int)
var req domain.CreateRecipeRequest
// Have to get the image differently
image, err := ctx.FormFile("image")
if err != nil && !errors.Is(err, http.ErrMissingFile) {
// Error getting image
if err := ctx.ShouldBindJSON(&req); err != nil {
return nil, err
}
// Convert to proper values
servingInt, _ := strconv.Atoi(serving)
difficultyInt, _ := strconv.Atoi(difficulty)
prepInt, _ := strconv.Atoi(preparation)
cookInt, _ := strconv.Atoi(cook)
var ingredientSlice []domain.RecipeIngredient
for i := range len(ingredients) {
if strings.TrimSpace(ingredients[i]) != "" {
ins := domain.RecipeIngredient{
Name: ingredients[i],
Quantity: quantity[i],
}
ingredientSlice = append(ingredientSlice, ins)
}
}
var instructionSlice []string
for _, ins := range instructions {
if ins != "" {
instructionSlice = append(instructionSlice, ins)
}
}
// Create the recipe
recipe := domain.Recipe{
Title: title,
Description: description,
Instructions: instructionSlice,
Serves: servingInt,
Difficulty: difficultyInt,
Duration: domain.RecipeDuration{
Total: prepInt + cookInt,
Prep: prepInt,
Cook: cookInt,
},
Category: domain.RecipeMeal(category),
Ingredients: ingredientSlice,
UserId: userId,
Created: time.Now(),
Title: req.Title,
Description: req.Description,
Instructions: req.Instructions,
Serves: req.Serves,
Difficulty: req.Difficulty,
Duration: req.Duration,
Category: req.Category,
Ingredients: req.Ingredients,
Sections: req.Sections,
UserId: userId,
Created: time.Now(),
}
if err := s.recipeRepository.CreateRecipe(&recipe); err != nil {
@ -112,17 +67,96 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
}
// TODO: Upload the image
if image != nil {
}
// if req.image != nil {
// }
// Create the tags
if len(tags) > 0 {
if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil {
if len(req.Tags) > 0 {
if err := s.recipeRepository.CreateRecipeTags(recipe, req.Tags); err != nil {
return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
}
}
return &recipe, nil
// title := ctx.PostForm("title")
// description := ctx.PostForm("description")
// preparation := ctx.PostForm("preparation-time")
// cook := ctx.PostForm("cook-time")
// serving := ctx.PostForm("serving-size")
// category := ctx.PostForm("category")
// difficulty := ctx.PostForm("difficulty")
// ingredients := ctx.PostFormArray("ingredients")
// quantity := ctx.PostFormArray("quantity")
// instructions := ctx.PostFormArray("instructions")
// tags := strings.Split(ctx.PostForm("tags"), ",")
// userId := ctx.MustGet("userId").(int)
//
// // Have to get the image differently
// image, err := ctx.FormFile("image")
// if err != nil && !errors.Is(err, http.ErrMissingFile) {
// // Error getting image
// }
//
// // Convert to proper values
// servingInt, _ := strconv.Atoi(serving)
// difficultyInt, _ := strconv.Atoi(difficulty)
// prepInt, _ := strconv.Atoi(preparation)
// cookInt, _ := strconv.Atoi(cook)
//
// var ingredientSlice []domain.RecipeIngredient
// for i := range len(ingredients) {
// if strings.TrimSpace(ingredients[i]) != "" {
// ins := domain.RecipeIngredient{
// Name: ingredients[i],
// Quantity: quantity[i],
// }
//
// ingredientSlice = append(ingredientSlice, ins)
// }
// }
//
// var instructionSlice []string
// for _, ins := range instructions {
// if ins != "" {
// instructionSlice = append(instructionSlice, ins)
// }
// }
//
// // Create the recipe
// recipe := domain.Recipe{
// Title: title,
// Description: description,
// Instructions: instructionSlice,
// Serves: servingInt,
// Difficulty: difficultyInt,
// Duration: domain.RecipeDuration{
// Total: prepInt + cookInt,
// Prep: prepInt,
// Cook: cookInt,
// },
// Category: domain.RecipeMeal(category),
// Ingredients: ingredientSlice,
// UserId: userId,
// Created: time.Now(),
// }
//
// if err := s.recipeRepository.CreateRecipe(&recipe); err != nil {
// return &recipe, err
// }
//
// // TODO: Upload the image
// if image != nil {
// }
//
// // Create the tags
// if len(tags) > 0 {
// if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil {
// return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
// }
// }
//
// return &recipe, nil
}
// GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore,
@ -135,7 +169,7 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
func (s *RecipeService) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
recipe, err := s.recipeRepository.GetRecipe(id, userId)
if recipe == nil {
if recipe == nil && err == nil {
return nil, fmt.Errorf("Recipe does not exist or has been relocated. Please try again.")
}
@ -159,19 +193,34 @@ func (s *RecipeService) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
//
// The favorites parameter is used to only return filters favorited by the userId provided.
func (s *RecipeService) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]domain.Recipe, error) {
return s.recipeRepository.SearchRecipes(filters, userId, favorites)
ids, err := s.recipeRepository.SearchRecipes(filters, userId, favorites)
if err != nil {
return []domain.Recipe{}, err
}
return s.recipeRepository.GetRecipes(ids, userId)
}
// GetUserRecipes returns a list of the recipes that the user has created. The user's
// ID should be provided. Any errors will be bubbled to the caller.
func (s *RecipeService) GetUserRecipes(id int) ([]domain.Recipe, error) {
return s.recipeRepository.GetUserRecipes(id)
func (s *RecipeService) GetUserRecipes(userId int) ([]domain.Recipe, error) {
ids, err := s.recipeRepository.GetUserRecipesIds(userId)
if err != nil {
return []domain.Recipe{}, err
}
return s.recipeRepository.GetRecipes(ids, &userId)
}
// GetUserFavoriteRecipes returns a list of the recipes that the user has marked as a
// favorite. The user's ID should be provided. Any errors will be bubbled to the caller.
func (s *RecipeService) GetUserFavoriteRecipes(id int) ([]domain.Recipe, error) {
return s.recipeRepository.GetUserFavoriteRecipes(id)
func (s *RecipeService) GetUserFavoriteRecipes(userId int) ([]domain.Recipe, error) {
ids, err := s.recipeRepository.GetUserFavoriteRecipesIds(userId)
if err != nil {
return []domain.Recipe{}, err
}
return s.recipeRepository.GetRecipes(ids, &userId)
}
// GetUserViewedRecipes returns a list of the most recent x (limit) recipes viewed by a user, from
@ -211,5 +260,14 @@ func (s *RecipeService) GetUserMadeRecipes(userId, limit int) ([]domain.Recipe,
// GetRecipeOfTheWeek searches for the most recent recipe of the week. If there is not a value,
// the recipe will be nil. Any errors will be bubbled to the caller.
func (s *RecipeService) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) {
return s.recipeRepository.GetRecipeOfTheWeek(userId)
id, err := s.recipeRepository.GetRecipeOfTheWeekId(userId)
if err != nil {
return nil, err
}
if id == nil {
return nil, fmt.Errorf("[ERROR] Recipe of the week ID could not be found. It may not exist.")
}
return s.recipeRepository.GetRecipe(*id, userId)
}

View File

@ -14,6 +14,8 @@ const (
EngagementShared EngagementType = "shared"
EngagementReviewed EngagementType = "reviewed"
EngagementRated EngagementType = "rated"
EngagementCreated EngagementType = "created"
EngagementDeleted EngagementType = "deleted"
)
// Engagement is the database model of a user engagement. There is no need to map to a different

View File

@ -7,5 +7,7 @@ type EngagementService interface {
UserFavoriteRecipe(userId, recipeId int) (Engagement, error)
UserMakeRecipe(userId, recipeId int) (Engagement, error)
UserShareRecipe(userId, recipeId int) (Engagement, error)
UserCreateRecipe(userId, recipeId int) (Engagement, error)
UserDeleteRecipe(userId, recipeId int) (Engagement, error)
GetUserEngagement(userId, limit int) ([]Engagement, error)
}

View File

@ -5,9 +5,9 @@ import "time"
// RecipeDuration is the duration to prepare recipe. It has JSON tags which allows it to be
// marshaled into a JSON object and stored in the database (JSONB).
type RecipeDuration struct {
Total int `json:"total"`
Prep int `json:"prep"`
Cook int `json:"cook"`
Total int `json:"Total"`
Prep int `json:"Prep"`
Cook int `json:"Cook"`
}
// RecipeMeal is the database enum E_MEAL which defines the meal type of a recipe. Postgres enums
@ -46,11 +46,62 @@ func ParseMeal(meal int) RecipeMeal {
}
}
// TODO: Comment
type RecipeIngredientUnit string
const (
Blank RecipeIngredientUnit = ""
Tsp RecipeIngredientUnit = "tsp"
Tbsp RecipeIngredientUnit = "tbsp"
FlOz RecipeIngredientUnit = "fl oz"
Cup RecipeIngredientUnit = "cup"
Ml RecipeIngredientUnit = "ml"
Litre RecipeIngredientUnit = "l"
Pint RecipeIngredientUnit = "pt"
Quart RecipeIngredientUnit = "qt"
Gallon RecipeIngredientUnit = "gal"
Gram RecipeIngredientUnit = "g"
Kilogram RecipeIngredientUnit = "kg"
Ounce RecipeIngredientUnit = "oz"
Pound RecipeIngredientUnit = "lb"
Piece RecipeIngredientUnit = "piece"
Clove RecipeIngredientUnit = "clove"
Slice RecipeIngredientUnit = "slice"
Stick RecipeIngredientUnit = "stick"
Bunch RecipeIngredientUnit = "bunch"
Pinch RecipeIngredientUnit = "pinch"
Dash RecipeIngredientUnit = "dash"
Splash RecipeIngredientUnit = "splash"
ToTaste RecipeIngredientUnit = "to taste"
)
// RecipeIngredient is a single ingredients in a recipe. These have JSON tags which allow them
// to be marshaled into a JSON array and stored in the database (JSONB).
type RecipeIngredient struct {
Name string `json:"name"`
Quantity string `json:"quantity"`
Id string `json:"Id"`
SectionId string `json:"SectionId"`
Name string `json:"Name"`
Amount float64 `json:"Amount"`
Unit RecipeIngredientUnit `json:"Unit"`
}
// TODO: Comment
type RecipeInstruction struct {
Id string `json:"Id"`
Content string `json:"Content"`
}
// TODO: Comment
type RecipeIngredientSection struct {
Id string `json:"Id"`
Name string `json:"Name"`
}
// RecipeIngredientStore is the struct stored in the database Ingredients column. It is simply a
// combindation of the sections and the ingredients so they can be stored together.
type RecipeIngredientStore struct {
Sections []RecipeIngredientSection `json:"Sections"`
Ingredients []RecipeIngredient `json:"Ingredients"`
}
// Recipe is the database model of a recipe. There is no need to map to a different model so
@ -61,12 +112,13 @@ type Recipe struct {
Id int
Title string
Description string
Instructions []string
Instructions []RecipeInstruction
Serves int
Difficulty int
Duration RecipeDuration
Category RecipeMeal
Ingredients []RecipeIngredient // Just a list of ingredients
Ingredients []RecipeIngredient
Sections []RecipeIngredientSection
UserId int
Modified *time.Time // Pointer to allow null
Created time.Time
@ -78,11 +130,12 @@ type Recipe struct {
// The integer values should be provided as bits and used to parse out individual flags. More
// details can be found in the SearchRecipes service function.
type SearchFilters struct {
Search string
MealType int
Time int
Difficulty int
ServingSize int
Search string `json:"Search"`
MealType int `json:"MealType"`
Time int `json:"Time"`
Difficulty int `json:"Difficulty"`
ServingSize int `json:"ServingSize"`
Favorites bool `json:"Favorites"`
}
// Tag is a model which represents a single tag in the Tags table. A tag is mapped to a recipe
@ -101,3 +154,17 @@ type RecipeTag struct {
TagId int
Created time.Time
}
// TODO: Comment
type CreateRecipeRequest struct {
Title string
Description string
Instructions []RecipeInstruction
Serves int
Difficulty int
Duration RecipeDuration
Category RecipeMeal
Ingredients []RecipeIngredient
Sections []RecipeIngredientSection
Tags []string
}

View File

@ -4,11 +4,11 @@ type RecipeRepository interface {
CreateRecipe(recipe *Recipe) error
GetRecipe(id int, userId *int) (*Recipe, error)
GetRecipes(ids []int, userId *int) ([]Recipe, error)
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)
CreateRecipeTags(recipe Recipe, tags []string) error
GetUserRecipes(id int) ([]Recipe, error)
GetUserFavoriteRecipes(id int) ([]Recipe, error)
GetUserRecipesIds(userId int) ([]int, error)
GetUserFavoriteRecipesIds(userId int) ([]int, error)
GetRecipeTags(recipe *Recipe) error
GetRecipeFavorite(recipe *Recipe, userId int) error
GetRecipeOfTheWeek(userId *int) (*Recipe, error)
GetRecipeOfTheWeekId(userId *int) (*int, error)
}

View File

@ -8,8 +8,8 @@ type RecipeService interface {
CreateRecipe(ctx *gin.Context) (*Recipe, error)
GetRecipe(id int, userId *int) (*Recipe, error)
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
GetUserRecipes(id int) ([]Recipe, error)
GetUserFavoriteRecipes(id int) ([]Recipe, error)
GetUserRecipes(userId int) ([]Recipe, error)
GetUserFavoriteRecipes(userId int) ([]Recipe, error)
GetUserViewedRecipes(userId, limit int) ([]Recipe, error)
GetUserMadeRecipes(userId, limit int) ([]Recipe, error)
GetRecipeOfTheWeek(userId *int) (*Recipe, error)

View File

@ -1,36 +1,38 @@
package domain
// Sub-routes
const VERSION = "/v1"
const VERSION_1 = "/v1"
const VERSION_2 = "/v2"
const WEB = "/web"
const API = "/api"
const STATE = "/state"
// Web prefixed routes
const WEB_LOGIN = VERSION + WEB + "/login"
const WEB_INDEX = VERSION + WEB
const WEB_HOME = VERSION + WEB + "/home"
const WEB_FAVORITES = VERSION + WEB + "/favorites"
const WEB_CREATE = VERSION + WEB + "/create"
const WEB_PROFIlE = VERSION + WEB + "/profile"
const WEB_LIST = VERSION + WEB + "/list"
const WEB_RECIPE = VERSION + WEB + "/recipe/%d"
const WEB_SEARCH = VERSION + WEB + "/search"
const WEB_NOT_FOUND = VERSION + WEB + "/404"
const WEB_LOGIN = VERSION_1 + WEB + "/login"
const WEB_INDEX = VERSION_1 + WEB
const WEB_HOME = VERSION_1 + WEB + "/home"
const WEB_FAVORITES = VERSION_1 + WEB + "/favorites"
const WEB_CREATE = VERSION_1 + WEB + "/create"
const WEB_PROFIlE = VERSION_1 + WEB + "/profile"
const WEB_LIST = VERSION_1 + WEB + "/list"
const WEB_RECIPE = VERSION_1 + WEB + "/recipe/%d"
const WEB_SEARCH = VERSION_1 + WEB + "/search"
const WEB_NOT_FOUND = VERSION_1 + WEB + "/404"
// API prefixed routes
const API_AUTH_LOGIN = VERSION + API + "/auth/login"
const API_AUTH_CALLBACK = VERSION + API + "/auth/callback"
const API_AUTH_LOGOUT = VERSION + API + "/auth/logout"
const API_CREATE_RECIPE = VERSION + API + "/recipe"
const API_SEARCH_RECIPES = VERSION + API + "/recipe/search"
const API_SEARCH_FAVORITES = VERSION + API + "/recipe/search/favorites"
const API_AUTH_LOGIN = VERSION_1 + API + "/auth/login"
const API_AUTH_CALLBACK = VERSION_1 + API + "/auth/callback"
const API_AUTH_CALLBACK_V2 = VERSION_2 + API + "/auth/callback"
const API_AUTH_LOGOUT = VERSION_1 + API + "/auth/logout"
const API_CREATE_RECIPE = VERSION_1 + API + "/recipe"
const API_SEARCH_RECIPES = VERSION_1 + API + "/recipe/search"
const API_SEARCH_FAVORITES = VERSION_1 + API + "/recipe/search/favorites"
const API_ENGAGEMENT_VIEW = VERSION + API + "/engagement/view/%d"
const API_ENGAGEMENT_SHARE = VERSION + API + "/engagement/share/%d"
const API_ENGAGEMENT_FAVORITE = VERSION + API + "/engagement/favorite/%d"
const API_ENGAGEMENT_MAKE = VERSION + API + "/engagement/make/%d"
const API_ENGAGEMENT_VIEW = VERSION_1 + API + "/engagement/view/%d"
const API_ENGAGEMENT_SHARE = VERSION_1 + API + "/engagement/share/%d"
const API_ENGAGEMENT_FAVORITE = VERSION_1 + API + "/engagement/favorite/%d"
const API_ENGAGEMENT_MAKE = VERSION_1 + API + "/engagement/make/%d"
// State prefixed routes
const STATE_TAGS_CREATE = VERSION + WEB + STATE + "/tags"
const STATE_TAGS_DELETE = VERSION + WEB + STATE + "/tags/delete"
const STATE_TAGS_CREATE = VERSION_1 + WEB + STATE + "/tags"
const STATE_TAGS_DELETE = VERSION_1 + WEB + STATE + "/tags/delete"

View File

@ -23,6 +23,7 @@ type EnvironmentConfig struct {
DatabaseUrl string
Environment string
Domain string
FrontendDomain string
}
// InjectedDependencies is a collection of dependencies that are injected into the application. They
@ -38,8 +39,8 @@ type InjectedDependencies struct {
// JwtClaims is the data stored in the JSON web token. All that is needed is the users ID and their
// Google email provided.
type JwtClaims struct {
UserId int `json:"id"`
Email string `json:"email"`
UserId int `json:"Id"`
Email string `json:"Email"`
jwt.RegisteredClaims
}
@ -81,16 +82,25 @@ func LoadEnvironment() (*EnvironmentConfig, error) {
}
var domain string
var frontendDomain string
if env == "dev" {
domain = os.Getenv("DOMAIN_DEV")
if domain == "" {
return nil, fmt.Errorf("DOMAIN_DEV environment variable is required when ENVIRONMENT is 'dev'.")
}
frontendDomain = os.Getenv("FRONTEND_DOMAIN_DEV")
if frontendDomain == "" {
return nil, fmt.Errorf("FRONTEND_DOMAIN_DEV environment variable is required when ENVIRONMENT is 'dev'.")
}
} else if env == "prod" {
domain = os.Getenv("DOMAIN_PROD")
if domain == "" {
return nil, fmt.Errorf("DOMAIN_PROD environment variable is required when ENVIRONMENT is 'prod'.")
}
frontendDomain = os.Getenv("FRONTEND_DOMAIN_PROD")
if frontendDomain == "" {
return nil, fmt.Errorf("FRONTEND_DOMAIN_PROD environment variable is required when ENVIRONMENT is 'dev'.")
}
} else {
return nil, fmt.Errorf("ENVIRONMENT environment variable is required and must be 'dev' or 'prod'.")
}
@ -117,8 +127,11 @@ func LoadEnvironment() (*EnvironmentConfig, error) {
DatabaseUrl: dbUrl,
Environment: env,
Domain: domain,
FrontendDomain: frontendDomain,
}
fmt.Printf("Environment Config: %+v\n", cfg)
return cfg, nil
}

View File

@ -4,13 +4,13 @@ import "time"
// GoogleUserInfo is a data type which contains a mapping of the Google User Info API call.
type GoogleUserInfo struct {
Id string `json:"id"`
Email string `json:"email"`
Verified bool `json:"verified_email"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Picture string `json:"picture"`
Id string `json:"Id"`
Email string `json:"Email"`
Verified bool `json:"VerifiedEmail"`
Name string `json:"Name"`
GivenName string `json:"GivenName"`
FamilyName string `json:"FamilyName"`
Picture string `json:"Picture"`
}
// User is the database model of a user. There is no need to map to a different model so

View File

@ -0,0 +1,14 @@
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
-- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted.
-- Date: 01/10/2026
BEGIN;
ALTER TYPE E_ENGAGEMENT
ADD VALUE IF NOT EXISTS 'created'; -- created recipe
ALTER TYPE E_ENGAGEMENT
ADD VALUE IF NOT EXISTS 'deleted'; -- deleted recipe
COMMIT;

View File

@ -10,4 +10,5 @@ psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infra
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/008_create_favorites_table.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/009_create_recipe_of_the_week_table.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/011_update_engagement_enum.sql

View File

@ -46,7 +46,9 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// NOTE: Data steps
// cast duration to JSON
// cast ingredients to JSON
// convert ingredients to store type
// cast store type to JSON
// extract string instructions from type
// cast category to string
// use nil for the modified time
@ -55,17 +57,27 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
return err
}
ingredientsJSON, err := json.Marshal(recipe.Ingredients)
ingredientsStore := domain.RecipeIngredientStore{
Sections: recipe.Sections,
Ingredients: recipe.Ingredients,
}
ingredientsJSON, err := json.Marshal(ingredientsStore)
if err != nil {
return err
}
instructions := make([]string, len(recipe.Instructions))
for i, instruction := range recipe.Instructions {
instructions[i] = instruction.Content
}
var id int
if err = tx.QueryRow(
query,
recipe.Title,
recipe.Description,
pq.Array(recipe.Instructions),
pq.Array(instructions),
recipe.Serves,
recipe.Difficulty,
durationJSON,
@ -94,8 +106,7 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// for added safety. The repository will not check for a nil result, instead the service will. Callers
// are responsible for protecting against double nil results. Any errors will be bubbled to the caller.
func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
query := `
SELECT
query := ` SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created
FROM recipes
@ -103,6 +114,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
`
var durationBytes []byte
var instructions pq.StringArray
var ingredientBytes []byte
var recipe domain.Recipe
@ -110,7 +122,8 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
// pq.Array(&instructions),
&instructions,
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
@ -137,16 +150,23 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
var store domain.RecipeIngredientStore
if err := json.Unmarshal(ingredientBytes, &store); err != nil {
// Check for unmarshal to support backwards compatability
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
recipe.Ingredients = store.Ingredients
recipe.Sections = store.Sections
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add instructions
for _, instruction := range instructions {
recipe.Instructions = append(recipe.Instructions, domain.RecipeInstruction{Content: instruction})
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
@ -169,83 +189,18 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
// will. Callers are responsible for protecting against double nil results. Any errors will be bubbled
// to the caller.
func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) {
query := `
SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients, userid, modified, created
FROM recipes
WHERE id = ANY($1)
ORDER BY array_position($1, id);
`
var recipes []domain.Recipe
rows, err := r.db.Query(query, pq.Array(ids))
if err != nil {
return nil, fmt.Errorf("Failed to get recipes. %s", err.Error())
}
defer rows.Close()
for rows.Next() {
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan recipe from database: %s", err.Error())
for _, id := range ids {
recipe, err := r.GetRecipe(id, userId)
if err != nil {
return nil, err
}
// Parse duration
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
// Skip any un-found recipes...?
if recipe != nil {
recipes = append(recipes, *recipe)
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Get favorite status, if user id is provided
if userId != nil {
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
}
} else {
recipe.Favorite = false
}
recipes = append(recipes, recipe)
}
return recipes, nil
@ -264,7 +219,12 @@ func isBitActive(bits, pos int) bool {
// The favorites parameter is used to only return filters favorited by the userId provided.
//
// TODO: Pagination is required, to provide infinite scroll.
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]domain.Recipe, error) {
//
// TODO: This does not work in the current build, the DB does not return valid values.
//
// 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching
// elsewhere.
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) {
// Compute meals type filters (there are 7 bits)
var mealConditions []string
for i := range 7 {
@ -348,17 +308,6 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
// Define columns to select. More fields can be added if the full text search is required
columns := []string{
"r.id",
"r.title",
"r.description",
"r.instructions",
"r.serves",
"r.difficulty",
"r.duration",
"r.category",
"r.ingredients",
"r.userid",
"r.modified",
"r.created",
}
// TODO: Need to add these to the query
@ -435,76 +384,21 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
// Execute the query
rows, err := r.db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query recipes: %w", err)
return []int{}, fmt.Errorf("failed to query recipes: %w", err)
}
defer rows.Close()
var recipes []domain.Recipe
var ids []int
for rows.Next() {
// Parsed values location
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("failed to scan recipe row: %w", err)
var id int
if err := rows.Scan(&id); err != nil {
return []int{}, fmt.Errorf("failed to extract ID: %s\n", err.Error())
}
// Parse duration from bytes
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("failed to parse duration for recipe ID %d: %w", recipe.Id, err)
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredients from bytes
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("failed to parse ingredients for recipe ID %d: %w", recipe.Id, err)
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Add recipe if not a favorite search
if !favorites && userId != nil {
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
}
if favorites {
recipe.Favorite = true
}
recipes = append(recipes, recipe)
ids = append(ids, id)
}
return recipes, nil
return ids, nil
}
// CreateRecipeTags accepts a list of tags (names) and a recipe (already created by the DB) and
@ -569,96 +463,43 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
// GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is
// authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) {
//
// 12/28/25: This now returns just the IDs, the service can handle fetching them.
func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
query := `
SELECT id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created
SELECT id
FROM recipes
WHERE userid = $1
ORDER BY created DESC;
`
rows, err := r.db.Query(query, id)
rows, err := r.db.Query(query, user_id)
if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
}
defer rows.Close()
// Prepare statement for tag query
// tagQuery := `
// `
var recipes []domain.Recipe
var ids []int
for rows.Next() {
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
// Scan results from recipe query onto recipe object
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan row onto recipe object. %s\n", err.Error())
var r_id int
if err := rows.Scan(&r_id); err != nil {
return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
// Parse duration
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Get favorite status
if err := r.GetRecipeFavorite(&recipe, id); err != nil {
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
}
recipes = append(recipes, recipe)
ids = append(ids, r_id)
}
return recipes, nil
return ids, nil
}
// GetUserRecipes gets a list of a users favorited recipes. This function does not ensure the user is
// authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, error) {
//
// 12/28/25: This now just returns the IDs, so the service can handle the fetching.
func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) {
query := `
SELECT r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category, r.ingredients, r.
userid, r.modified, r.created
SELECT r.id
FROM favorites f
JOIN recipes r ON r.id = f.recipeid
WHERE f.userid = $1
@ -670,66 +511,17 @@ func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, erro
}
defer rows.Close()
var recipes []domain.Recipe
var ids []int
for rows.Next() {
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
// Scan results from recipe query onto recipe object
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan row onto recipe object. %s\n", err.Error())
var r_id int
if err := rows.Scan(&r_id); err != nil {
return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
// Parse duration
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Set favorite status (they're always true!)
recipe.Favorite = true
recipes = append(recipes, recipe)
ids = append(ids, r_id)
}
return recipes, nil
return ids, nil
}
// GetRecipeTags requires a recipe to be filled with at least an ID. This function will use the ID
@ -793,82 +585,27 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
return nil
}
// GetRecipeOfTheWeek searches for the most recent recipe of the week. If there is not a value,
// GetRecipeOfTheWeekId searches for the most recent recipe of the week. If there is not a value,
// the recipe will be nil. This function simply collects the most recent entry in the recipeoftheweek
// table and return it. If there is no entry, nil will be returned Any errors will be bubbled to
// the caller.
func (r *RecipeRepository) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) {
// table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to
// the caller. All that is returned is the recipe ID, that way the caller can handle the fetching.
func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
query := `
SELECT
r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category,
r.ingredients, r.userid, r.modified, r.created
r.id
FROM recipes r
JOIN recipeoftheweek rw ON rw.recipeid = r.id
ORDER BY created DESC
ORDER BY rw.created DESC
LIMIT 1;
`
var durationBytes []byte
var ingredientBytes []byte
var recipe domain.Recipe
if err := r.db.QueryRow(query).Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
var id int
if err := r.db.QueryRow(query).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
}
// Parse duration
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Get favorite status, if user id is provided
if userId != nil {
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
}
} else {
recipe.Favorite = false
}
return &recipe, nil
return &id, nil
}

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -112,7 +112,7 @@ templ ingredientList(ingredients []domain.RecipeIngredient) {
<hr class="text-gray-300"/>
<ul class="text-lg my-4 text-gray-700">
for _, ingredient := range ingredients {
@ingredientListItem(ingredient.Name, ingredient.Quantity)
@ingredientListItem(ingredient.Name, "")
}
</ul>
</div>
@ -308,7 +308,7 @@ templ RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, doma
<p class="text-gray-700">{ recipe.Description }</p>
</div>
@ingredientList(recipe.Ingredients)
@instructionList(recipe.Instructions)
@instructionList([]string{})
@tagList(recipe.Tags, recipe.Created, recipe.Modified)
</div>
</div>

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -268,7 +268,7 @@ func ingredientList(ingredients []domain.RecipeIngredient) templ.Component {
return templ_7745c5c3_Err
}
for _, ingredient := range ingredients {
templ_7745c5c3_Err = ingredientListItem(ingredient.Name, ingredient.Quantity).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = ingredientListItem(ingredient.Name, "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -875,7 +875,7 @@ func RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, domai
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = instructionList(recipe.Instructions).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = instructionList([]string{}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

34
shell.nix Normal file
View File

@ -0,0 +1,34 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
go
gopls
go-tools
htmx-lsp2
templ
tailwindcss_4
tailwindcss-language-server
watchman
docker-language-server
dockerfile-language-server-nodejs
gcc_multi
glibc_multi
nodejs
];
shellHook = ''
alias vim="nvim"
alias vi="nvim"
alias v="nvim"
# Modify this
export PS1="\[\e[35m\]\w \$ \[\e[0m\]"
echo ""
echo "The default environment is ready!"
echo ""
exec zsh
'';
}

27
web/.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.vite

27
web/Dockerfile Normal file
View File

@ -0,0 +1,27 @@
# Build stage
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Build-time config: just env selector
ARG VITE_ENVIRONMENT=prod
ENV VITE_ENVIRONMENT=$VITE_ENVIRONMENT
RUN npm run build
# Runtime stage
FROM node:18-alpine
WORKDIR /app
# Install static file server
RUN npm install -g serve
# Copy build output only
COPY --from=build /app/dist ./dist
EXPOSE 3002
CMD ["serve", "-s", "dist", "-l", "3002"]

4
web/README.md Normal file
View File

@ -0,0 +1,4 @@
# IF BACKEND CANNOT GET COOKIE
Do not forget to send the axios request with the `{ withCredentials: true }` flags.

43
web/eslint.config.js Normal file
View File

@ -0,0 +1,43 @@
import js from "@eslint/js"
import globals from "globals"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import tseslint from "typescript-eslint"
import { defineConfig, globalIgnores } from "eslint/config"
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
// tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
web/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/potion.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gophernest - Potion</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4255
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
web/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tailwindcss/vite": "^4.1.18",
"axios": "^1.13.2",
"eslint-plugin-react-dom": "^2.2.4",
"eslint-plugin-react-x": "^2.2.4",
"motion": "^12.23.25",
"react": "^19.1.1",
"react-cookie": "^8.0.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.5",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react-swc": "^4.1.0",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

10
web/public/potion.svg Normal file
View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="#51a2ff" d="M576 144C576 99.8 540.2 64 496 64C478 64 461.4 70 448 80L367 80C390.6 32.6 439.5
0 496 0C575.5 0 640 64.5 640 144C640 223.5 575.5 288 496 288C489.5 288 483 287.6 476.7 286.7L540.7
212C541.8 210.7 542.9 209.3 544 207.9C563.4 193.3 576 170.1 576 143.9zM66.9 146.6C72.2 135.3 83.5
128 96 128L480 128C492.5 128 503.8 135.3 509.1 146.6C514.4 157.9 512.5 171.3 504.3 180.8L320 395.8L320
512L384 512C401.7 512 416 526.3 416 544C416 561.7 401.7 576 384 576L192 576C174.3 576 160 561.7 160
544C160 526.3 174.3 512 192 512L256 512L256 395.8L71.7 180.8C63.6 171.3 61.7 158 66.9 146.6zM165.6
192L288 334.8L410.4 192L165.6 192z"
/>
</svg>

After

Width:  |  Height:  |  Size: 869 B

1
web/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

62
web/src/App.tsx Normal file
View File

@ -0,0 +1,62 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import Home from './pages/Home';
import WebLayout from "./layouts/WebLayout";
import NotFound from './pages/NotFound';
import ROUTE_CONSTANTS from './types/routes';
import Create from './pages/Create';
import Favorites from './pages/Favorites';
import Profile from './pages/Profile';
import ShoppingList from './pages/ShoppingList';
import LoginPage from './pages/Login';
import { use, type ReactNode } from 'react';
import { AuthContext } from './context/AuthContext';
import RecipePage from './pages/Recipe';
import SearchPage from './pages/Search';
function ProtectedRoute({ children }: { children: ReactNode }) {
const { isLoggedIn } = use(AuthContext)
// Wait until the value is set
if (isLoggedIn === undefined) {
// Still checking auth state: don't render anything yet, or show a spinner if desired
return null; // or <Loading />
}
if (isLoggedIn) return children;
// Redirect to login page if not authenicated
return <Navigate to="/v2/web/login" replace />
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to={ROUTE_CONSTANTS.Home} replace />} />
{/* Login page does not inherit WebLayout */}
<Route path="/v2/web/login" element={<LoginPage />} />
<Route path="/v2/web" element={<WebLayout />}>
<Route index element={<Navigate to={ROUTE_CONSTANTS.Home} replace />} />
<Route path="home" element={<Home />} />
<Route path="search" element={<SearchPage />} />
<Route path="favorites" element={<ProtectedRoute><Favorites /></ProtectedRoute>} />
<Route path="create" element={<ProtectedRoute><Create /></ProtectedRoute>} />
<Route path="profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="list" element={<ProtectedRoute><ShoppingList /></ProtectedRoute>} />
<Route path="recipe/:id" element={<RecipePage />} />
</Route>
{/* 404: Not Found */}
<Route path="*" element={<WebLayout />}>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

1
web/src/assets/react.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,12 @@
interface BannerProps {
content: string;
};
export default function Banner({ content }: BannerProps) {
return (
<h2 className="text-xl md:text-2xl bg-gradient-to-r from-blue-400 to-blue-600 w-full h-fit py-6 text-center text-white">
{content}
</h2>
);
}

View File

@ -0,0 +1,116 @@
import { useState } from "react";
import ROUTE_CONSTANTS from "../types/routes.ts";
import { useLocation } from "react-router-dom";
import ShoppingListIcon from "./icons/ShoppingListIcon.tsx";
export default function Navigation() {
const [displayHamburgerMenu, setDisplayHamburgerMenu] = useState<boolean>(false);
const location = useLocation();
return (
<>
<nav className="block md:fixed w-full z-20">
<div
className="relative w-full px-8 md:px-44 p-4 border-b border-gray-300 shadow-sm shadow-gray-300 bg-white flex justify-between items-center"
>
<div>
<a href={ROUTE_CONSTANTS.Home}>
<p className="select-none">Potion</p>
</a>
</div>
<div className="hidden md:flex lg:flex items-center gap-8 select-none">
<NavigationLink name="Home" url={ROUTE_CONSTANTS.Home} current={location.pathname === ROUTE_CONSTANTS.Home} />
<NavigationLink name="Favorites" url={ROUTE_CONSTANTS.Favorites} current={location.pathname === ROUTE_CONSTANTS.Favorites} />
<NavigationLink name="Create" url={ROUTE_CONSTANTS.Create} current={location.pathname === ROUTE_CONSTANTS.Create} />
<NavigationLink name="Profile" url={ROUTE_CONSTANTS.Profile} current={location.pathname === ROUTE_CONSTANTS.Profile} />
<IconNavigationLink icon={<ShoppingListIcon current={location.pathname === ROUTE_CONSTANTS.ShoppingList} />} url={ROUTE_CONSTANTS.ShoppingList} />
</div>
<div className="md:hidden grid place-content-center">
<button onClick={() => setDisplayHamburgerMenu(!displayHamburgerMenu)} className="p-2">
<svg
className={`${displayHamburgerMenu ? "flex" : "hidden"} size-5`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 320 512"
>
<path
d="M182.6 137.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l256 0c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-128-128z"
></path>
</svg>
<svg
className={`${displayHamburgerMenu ? "hidden" : "flex"} size-5`}
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path
fill="currentColor"
d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"
></path>
</svg>
</button>
</div>
<HamburgerMenu show={displayHamburgerMenu} />
</div>
</nav>
</>
);
}
interface HamburgerMenuProps {
show: boolean;
};
function HamburgerMenu({ show }: HamburgerMenuProps) {
return (
<div className={`${show ? "flex" : "hidden"} w-full flex-col items-center absolute top-[100%] left-0 py-2 bg-white border-b border-gray-300 shadow-sm shadow-gray-300 z-20`}>
<DropdownLink name="Home" url={ROUTE_CONSTANTS.Home} />
<DropdownLink name="Favorites" url={ROUTE_CONSTANTS.Favorites} />
<DropdownLink name="Create" url={ROUTE_CONSTANTS.Create} />
<DropdownLink name="Profile" url={ROUTE_CONSTANTS.Profile} />
<DropdownLink name="Shopping List" url={ROUTE_CONSTANTS.ShoppingList} />
</div>
);
}
interface DropdownLinkProps {
name: string;
url: string;
}
function DropdownLink({ name, url }: DropdownLinkProps) {
return (
<a className="py-2" href={url}>
{name}
</a>
);
};
interface NavigationLinkProps {
name: string;
url: string;
current: boolean;
}
function NavigationLink({ name, url, current }: NavigationLinkProps) {
return (
<a href={url} className={`${current ? "border-blue-500" : "hover:border-blue-400 border-white"} duration-150 text-gray-700 border-b-2 px-1 cursor-pointer`}>
{name}
</a>
);
}
interface IconNavigationLinkProps {
icon: React.ReactElement;
url: string;
}
function IconNavigationLink({ icon, url }: IconNavigationLinkProps) {
return (
<a href={url} className="px-1 cursor-pointer">
{icon}
</a>
);
}

View File

@ -0,0 +1,12 @@
interface SpinnerProps {
content: string;
}
export default function Spinner({ content }: SpinnerProps) {
return (
<>
<div className="w-8 h-8 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin" />
<h2 className="text-xl text-gray-700"> {content}</h2>
</>
);
}

View File

@ -0,0 +1,19 @@
interface DropdownButtonProps {
content: string;
name: string;
value: string;
selected: boolean;
changeHandler: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function DropdownButton({ content, name, value, selected, changeHandler }: DropdownButtonProps) {
return (
<label className="inline-block cursor-pointer select-none">
<input onChange={changeHandler} type="checkbox" name={name} value={value} className="sr-only peer" checked={selected} />
<span className="peer-checked:bg-blue-600 peer-checked:text-white peer-checked:border-blue-600 px-2 py-1 border border-gray-300 rounded-lg">
{content}
</span>
</label>
);
}

View File

@ -0,0 +1,78 @@
import { use, useEffect, useState } from "react";
import { AuthContext } from "../../context/AuthContext";
import { useNavigate } from "react-router-dom";
import { EngagementFavoriteRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface FavoriteButtonProps {
favorite: boolean | undefined;
id: number | undefined;
}
export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
// CONTEXT
const { isLoggedIn } = use(AuthContext);
const navigate = useNavigate();
const [_favorite, setFavorite] = useState<boolean>();
const clickHandler = async () => {
// This button cannot be used if not logged in
if (!isLoggedIn) {
await navigate("/v2/web/login");
return;
}
if (!id) return;
// Toggle button first, to feel fast
setFavorite(!_favorite);
const result = await EngagementFavoriteRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
}
useEffect(() => {
if (favorite)
setFavorite(favorite);
}, [favorite]);
return _favorite ? (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
onClick={() => void clickHandler()}
>
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z"
fill="currentColor"
></path>
</svg>
Unfavorite
</button>
) : (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
onClick={() => void clickHandler()}
>
<svg className="h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 6.00019C10.2006 3.90317 7.19377 3.2551 4.93923 5.17534C2.68468 7.09558 2.36727 10.3061 4.13778 12.5772C5.60984 14.4654 10.0648 18.4479 11.5249 19.7369C11.6882 19.8811 11.7699 19.9532 11.8652 19.9815C11.9483 20.0062 12.0393 20.0062 12.1225 19.9815C12.2178 19.9532 12.2994 19.8811 12.4628 19.7369C13.9229 18.4479 18.3778 14.4654 19.8499 12.5772C21.6204 10.3061 21.3417 7.07538 19.0484 5.17534C16.7551 3.2753 13.7994 3.90317 12 6.00019Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
Favorite
</button>
);
}

View File

@ -0,0 +1,36 @@
interface FilterButtonProps {
click: () => void;
}
export default function FilterButton({ click }: FilterButtonProps) {
return (
<button
type="button"
onClick={click}
className="text-gray-400 border border-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<svg className="h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 11.1707L6 4C6 3.44771 5.55228 3 5 3C4.44771 3 4 3.44771 4 4L4 11.1707C2.83481 11.5825 2 12.6938 2 14C2 15.3062 2.83481 16.4175 4 16.8293L4 20C4 20.5523 4.44772 21 5 21C5.55228 21 6 20.5523 6 20L6 16.8293C7.16519 16.4175 8 15.3062 8 14C8 12.6938 7.16519 11.5825 6 11.1707ZM5 13C4.44772 13 4 13.4477 4 14C4 14.5523 4.44772 15 5 15C5.55228 15 6 14.5523 6 14C6 13.4477 5.55228 13 5 13Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19 21C18.4477 21 18 20.5523 18 20L18 18C18 17.9435 18.0047 17.8881 18.0137 17.8341C16.8414 17.4262 16 16.3113 16 15C16 13.6887 16.8414 12.5738 18.0137 12.1659C18.0047 12.1119 18 12.0565 18 12L18 4C18 3.44771 18.4477 3 19 3C19.5523 3 20 3.44771 20 4L20 12C20 12.0565 19.9953 12.1119 19.9863 12.1659C21.1586 12.5738 22 13.6887 22 15C22 16.3113 21.1586 17.4262 19.9863 17.8341C19.9953 17.8881 20 17.9435 20 18V20C20 20.5523 19.5523 21 19 21ZM18 15C18 14.4477 18.4477 14 19 14C19.5523 14 20 14.4477 20 15C20 15.5523 19.5523 16 19 16C18.4477 16 18 15.5523 18 15Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 9C9 7.69378 9.83481 6.58254 11 6.17071V4C11 3.44772 11.4477 3 12 3C12.5523 3 13 3.44772 13 4V6.17071C14.1652 6.58254 15 7.69378 15 9C15 10.3113 14.1586 11.4262 12.9863 11.8341C12.9953 11.8881 13 11.9435 13 12L13 20C13 20.5523 12.5523 21 12 21C11.4477 21 11 20.5523 11 20L11 12C11 11.9435 11.0047 11.8881 11.0137 11.8341C9.84135 11.4262 9 10.3113 9 9ZM11 9C11 8.44772 11.4477 8 12 8C12.5523 8 13 8.44772 13 9C13 9.55229 12.5523 10 12 10C11.4477 10 11 9.55229 11 9Z"
fill="currentColor"
/>
</svg>
</button>
);
}

View File

@ -0,0 +1,10 @@
export default function LikeButton() {
return (
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z"
fill="currentColor"
></path>
</svg>
);
}

View File

@ -0,0 +1,61 @@
import { use, useState } from "react";
import { AuthContext } from "../../context/AuthContext";
import { useNavigate } from "react-router-dom";
import { EngagementMakeRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface MadeButtonProps {
id: number;
}
export default function MadeButton({ id }: MadeButtonProps) {
// CONTEXT
const { isLoggedIn } = use(AuthContext);
const navigate = useNavigate();
const [clicked, setClicked] = useState<boolean>(false);
const clickHandler = async () => {
// This button cannot be used if not logged in
if (!isLoggedIn) {
await navigate("/v2/web/login");
return;
}
if (!id || clicked) return;
// Toggle button first, to feel fast
setClicked(true);
const result = await EngagementMakeRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
}
return (
<button
className={`flex items-center justify-center gap-x-1 rounded-lg border ${clicked ? "border-blue-500 text-blue-500" : "border-gray-300 text-gray-800"} px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer`}
onClick={() => void clickHandler()}
>
<svg
className="h-6"
fill="currentColor"
viewBox="0 -3.84 122.88 122.88"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve"
>
<g>
<path
d="M29.03,100.46l20.79-25.21l9.51,12.13L41,110.69C33.98,119.61,20.99,110.21,29.03,100.46L29.03,100.46z M53.31,43.05 c1.98-6.46,1.07-11.98-6.37-20.18L28.76,1c-2.58-3.03-8.66,1.42-6.12,5.09L37.18,24c2.75,3.34-2.36,7.76-5.2,4.32L16.94,9.8 c-2.8-3.21-8.59,1.03-5.66,4.7c4.24,5.1,10.8,13.43,15.04,18.53c2.94,2.99-1.53,7.42-4.43,3.69L6.96,18.32 c-2.19-2.38-5.77-0.9-6.72,1.88c-1.02,2.97,1.49,5.14,3.2,7.34L20.1,49.06c5.17,5.99,10.95,9.54,17.67,7.53 c1.03-0.31,2.29-0.94,3.64-1.77l44.76,57.78c2.41,3.11,7.06,3.44,10.08,0.93l0.69-0.57c3.4-2.83,3.95-8,1.04-11.34L50.58,47.16 C51.96,45.62,52.97,44.16,53.31,43.05L53.31,43.05z M65.98,55.65l7.37-8.94C63.87,23.21,99-8.11,116.03,6.29 C136.72,23.8,105.97,66,84.36,55.57l-8.73,11.09L65.98,55.65L65.98,55.65z"
></path>
</g>
</svg>
Made This!
</button>
);
}

View File

@ -0,0 +1,58 @@
import { useState } from "react";
import { EngagementShareRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
import ROUTE_CONSTANTS from "../../types/routes";
interface ShareButtonProps {
id: number;
}
export default function ShareButton({ id }: ShareButtonProps) {
const [clicked, setClicked] = useState<boolean>(false);
const clickHandler = async () => {
if (clicked) return;
console.log(window.location);
// Copy first, so it feels fast
const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`;
await navigator.clipboard.writeText(url);
const result = await EngagementShareRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
setClicked(true);
};
return clicked ? (
<button className="flex items-center justify-center gap-x-1 rounded-lg border border-green-500 text-green-500 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300">
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.803 5.33333C13.803 3.49238 15.3022 2 17.1515 2C19.0008 2 20.5 3.49238 20.5 5.33333C20.5 7.17428 19.0008 8.66667 17.1515 8.66667C16.2177 8.66667 15.3738 8.28596 14.7671 7.67347L10.1317 10.8295C10.1745 11.0425 10.197 11.2625 10.197 11.4872C10.197 11.9322 10.109 12.3576 9.94959 12.7464L15.0323 16.0858C15.6092 15.6161 16.3473 15.3333 17.1515 15.3333C19.0008 15.3333 20.5 16.8257 20.5 18.6667C20.5 20.5076 19.0008 22 17.1515 22C15.3022 22 13.803 20.5076 13.803 18.6667C13.803 18.1845 13.9062 17.7255 14.0917 17.3111L9.05007 13.9987C8.46196 14.5098 7.6916 14.8205 6.84848 14.8205C4.99917 14.8205 3.5 13.3281 3.5 11.4872C3.5 9.64623 4.99917 8.15385 6.84848 8.15385C7.9119 8.15385 8.85853 8.64725 9.47145 9.41518L13.9639 6.35642C13.8594 6.03359 13.803 5.6896 13.803 5.33333Z"
fill="currentColor" />
</svg>
Link Copied!
</button>
) : (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
onClick={() => void clickHandler()}
>
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.803 5.33333C13.803 3.49238 15.3022 2 17.1515 2C19.0008 2 20.5 3.49238 20.5 5.33333C20.5 7.17428 19.0008 8.66667 17.1515 8.66667C16.2177 8.66667 15.3738 8.28596 14.7671 7.67347L10.1317 10.8295C10.1745 11.0425 10.197 11.2625 10.197 11.4872C10.197 11.9322 10.109 12.3576 9.94959 12.7464L15.0323 16.0858C15.6092 15.6161 16.3473 15.3333 17.1515 15.3333C19.0008 15.3333 20.5 16.8257 20.5 18.6667C20.5 20.5076 19.0008 22 17.1515 22C15.3022 22 13.803 20.5076 13.803 18.6667C13.803 18.1845 13.9062 17.7255 14.0917 17.3111L9.05007 13.9987C8.46196 14.5098 7.6916 14.8205 6.84848 14.8205C4.99917 14.8205 3.5 13.3281 3.5 11.4872C3.5 9.64623 4.99917 8.15385 6.84848 8.15385C7.9119 8.15385 8.85853 8.64725 9.47145 9.41518L13.9639 6.35642C13.8594 6.03359 13.803 5.6896 13.803 5.33333Z"
fill="currentColor" />
</svg>
Share
</button>
);
}

View File

@ -0,0 +1,17 @@
interface ContentCardSmallProps {
content: string;
target: string;
};
export default function ContentCardSmall({ content, target }: ContentCardSmallProps) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-gray-300 border shadow-md p-4 flex-shrink-0">
<div className="mt-8 w-52 md:w-48 text-center">
<a className="underline" href={target}>
<p className="text-sm">{content}</p>
</a>
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
import type { Recipe } from "../../types/recipe";
import RecipePlaceholder from "../../assets/images/recipe_placeholder.png"
import LikeButton from "../buttons/LikeButton";
import { useNavigate } from "react-router-dom";
import { EngagementViewRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface RecipeCardLargeProps {
recipe: Recipe | null;
}
export default function RecipeCardLarge({ recipe }: RecipeCardLargeProps) {
const navigate = useNavigate();
// HANDLERS
const makeButtonHandler = async () => {
if (!recipe) return;
// Navigate first, so it feels faster
await navigate(`/v2/web/recipe/${recipe.Id}`);
const result = await EngagementViewRecipe(recipe.Id);
if (isApiError(result)) {
console.error(result.message);
}
}
if (recipe == null) {
return <h2 className="text-2xl md:text-3xl text-gray-400">Coming soon!</h2>
}
return (
<div className="flex flex-col items-center justify-between rounded-lg border-gray-300 border shadow-md p-4 flex-shrink-0">
<img className="size-80 rounded-sm border border-gray-200 shadow-sm shadow-gray-100" src={RecipePlaceholder} />
<div className="w-full mt-8">
<h2 className="font-semibold overflow-hidden whitespace-nowrap text-ellipsis">
{recipe.Title}
</h2>
<p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis">
Serves {recipe.Serves}
</p>
<p className="text-sm text-wrap w-80">
{recipe.Description}
</p>
<div className="flex items-end justify-between">
<p className="text-xs mt-4 bg-gray-200 rounded-lg w-fit px-2 py-1">
{recipe.Category} - {recipe.Duration.Total} mins
</p>
{recipe.Favorite && <LikeButton />}
</div>
<button
onClick={() => void makeButtonHandler()}
className="w-full rounded-lg py-2 bg-gradient-to-r from-blue-400 to-blue-600 text-white mt-2 hover:ring-blue-700 hover:shadow shadow-blue-300 duration-200 cursor-pointer"
>
Make Now!
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
import type { Recipe } from "../../types/recipe";
import RecipePlaceholder from "../../assets/images/recipe_placeholder.png"
import LikeButton from "../buttons/LikeButton";
import { EngagementViewRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
import { useNavigate } from "react-router-dom";
interface RecipeCardSmallProps {
recipe: Recipe;
}
export default function RecipeCardSmall({ recipe }: RecipeCardSmallProps) {
const navigate = useNavigate();
// HANDLERS
const makeButtonHandler = async () => {
// Navigate first, so it feels faster
await navigate(`/v2/web/recipe/${recipe.Id}`);
const result = await EngagementViewRecipe(recipe.Id);
if (isApiError(result)) {
console.error(result.message);
}
}
return (
<div className="flex flex-col items-center justify-between rounded-lg border-gray-300 border shadow-md p-4 flex-shrink-0">
<img className="size-52 md:size-48 rounded-sm border border-gray-200 shadow-sm shadow-gray-100" src={RecipePlaceholder} />
<div className="w-52 md:w-48 mt-8">
<h2 className="font-semibold overflow-hidden whitespace-nowrap text-ellipsis">
{recipe.Title}
</h2>
<p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis">
Serves {recipe.Serves}
</p>
<div className="flex items-end justify-between">
<p className="text-xs mt-4 bg-gray-200 rounded-lg w-fit px-2 py-1">
{recipe.Category} - {recipe.Duration.Total} mins
</p>
{recipe.Favorite && <LikeButton />}
</div>
<button
onClick={() => void makeButtonHandler()}
className="w-full rounded-lg py-2 bg-gradient-to-r from-blue-400 to-blue-600 text-white mt-2 hover:ring-blue-700 hover:shadow shadow-blue-300 duration-200 cursor-pointer"
>
Make Now!
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
import type { Recipe } from "../../types/recipe"
import ServingSizeIcon from "../icons/ServingSizeIcon"
import StarIcon from "../icons/StarIcon"
import TimeIcon from "../icons/TimeIcon"
function displayDifficulty(diff: number): string {
switch (diff) {
case 1:
return "Beginner"
case 2:
return "Easy"
case 3:
return "Intermediate"
case 4:
return "Challenging"
case 5:
return "Extreme"
default:
return ""
}
}
interface RecipeMetaDataProps {
recipe: Recipe | null;
}
export default function RecipeMetaData({ recipe }: RecipeMetaDataProps) {
return (
<div
className="border border-blue-300 bg-blue-50 text-gray-700 mx-4 md:mx-8 rounded-lg flex flex-col
md:flex-row justify-center items-center py-8"
>
<div className="flex flex-col items-center justify-center text-sm my-4 md:my-0 mx-4 w-full md:w-1/4">
<TimeIcon />
<p>Prep: {recipe?.Duration.Prep ?? 0} min</p>
<p>Cook: {recipe?.Duration.Cook ?? 0} min</p>
</div>
<div
className="flex flex-col items-center justify-center text-sm my-4 md:my-0 mx-4 border-y md:border-y-0 md:border-x border-blue-300 py-8 w-9/10 md:w-fit md:py-0 px-8"
>
<div className="flex gap-x-1 my-2">
{Array.from({ length: recipe?.Difficulty ?? 0 }).map((_, i) => (
<StarIcon key={`${recipe?.Id}-filled-${i}`} size={6} filled={true} />
))}
{Array.from({ length: 5 - (recipe?.Difficulty ?? 0) }).map((_, i) => (
<StarIcon key={`${recipe?.Id}-unfilled-${i}`} size={6} filled={false} />
))}
</div>
<p>{displayDifficulty(recipe?.Difficulty ?? 0)}</p>
</div>
<div className="flex flex-col items-center justify-center text-sm my-4 md:my-0 mx-4 w-1/4">
<ServingSizeIcon />
<p>Serves {recipe?.Serves ?? 0}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,94 @@
import { Reorder, useDragControls } from "motion/react";
import { INGREDIENT_UNITS, type RecipeIngredient } from "../../types/recipe";
import DeleteIconSmall from "../icons/DeleteIconSmall";
import DragIconSmall from "../icons/DragIconSmall";
interface IngredientItemProps {
classes: string;
ingredient: RecipeIngredient;
onChange: (id: string, name: "Amount" | "Unit" | "Name", value: string) => void;
removeIngredientHandler: (id: string) => void;
allowDelete: boolean;
valid: boolean;
dirty: boolean;
markDirty: (id: string) => void;
}
export default function IngredientItem({ classes, ingredient, onChange, removeIngredientHandler, allowDelete, valid, dirty, markDirty }: IngredientItemProps) {
const controls = useDragControls();
const changeHandler = (name: "Amount" | "Unit" | "Name", value: string) => {
if (!dirty) markDirty(ingredient.Id);
onChange(ingredient.Id, name, value);
}
return (
<Reorder.Item
key={ingredient.Id}
value={ingredient}
dragListener={false}
dragControls={controls}
className="select-none p-2 flex gap-2 flex-col"
>
<div className="flex gap-2">
<div className="flex-col md:flex-row flex-grow flex gap-2 flex-wrap">
<div className="flex gap-2">
<input
type="number"
step="0.25"
min="0"
placeholder="amount"
required
value={ingredient.Amount}
onChange={(e) => changeHandler("Amount", e.target.value)}
className={`w-1/2 md:w-28 ${classes} ${dirty && ingredient.Amount <= 0 ? "border-red-500" : ""}`}
/>
<select
onChange={(e) => changeHandler("Unit", e.target.value)}
className={`w-1/2 md:w-fit ${classes} ${dirty && ingredient.Unit === "" ? "border-red-500" : ""}`}
>
{INGREDIENT_UNITS.map(unit => (
<option key={unit} value={unit}>{unit ? unit : "Select"}</option>
))}
</select>
</div>
<input
type="text"
placeholder="Ingredient name"
required
value={ingredient.Name}
onChange={(e) => changeHandler("Name", e.target.value)}
className={`flex-grow ${classes} ${dirty && ingredient.Name.trim() === "" ? "border-red-500" : ""}`}
/>
</div>
<div className="flex-col md:flex-row flex gap-x-2 items-center justify-evenly md:justify-center">
<button
tabIndex={-1}
disabled={!allowDelete}
onClick={() => removeIngredientHandler(ingredient.Id)}
className="p-1 md:p-2 md:pr-0 cursor-pointer text-gray-500 hover:text-red-500 disabled:text-gray-200 disabled:cursor-not-allowed duration-300"
>
<DeleteIconSmall />
</button>
<div
tabIndex={-1}
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
className="p-1 md:p-0 cursor-pointer touch-none"
>
<DragIconSmall />
</div>
</div>
</div>
{(dirty && !valid) && (
<p className="text-sm text-red-500"> Please fill out all fields. </p>
)}
</Reorder.Item>
);
}

View File

@ -0,0 +1,47 @@
import { Reorder } from "motion/react";
import type { RecipeIngredient, RecipeIngredientSection } from "../../types/recipe";
import IngredientItem from "./IngredientItem";
import type { RecipeValidationEntry } from "../../pages/Create";
interface IngredientListProps {
classes: string;
section: RecipeIngredientSection;
ingredients: RecipeIngredient[];
setSectionIngredients: (sectionId: string, ingredients: RecipeIngredient[]) => void;
ingredientChangeHandler: (id: string, name: "Amount" | "Unit" | "Name", value: string) => void;
removeIngredientHandler: (id: string) => void;
validList: RecipeValidationEntry[];
dirtyList: Record<string, boolean>;
markDirty: (id: string) => void;
}
export default function IngredientList({ classes, section, ingredients, setSectionIngredients, ingredientChangeHandler, removeIngredientHandler, validList, dirtyList, markDirty }: IngredientListProps) {
const sectionIngredients = ingredients.filter(x => x.SectionId === section.Id);
const reorderHandler = (ingredients: RecipeIngredient[]) => {
setSectionIngredients(section.Id, ingredients);
}
return (
<Reorder.Group
axis="y"
values={ingredients}
onReorder={reorderHandler}
className="flex flex-col"
>
{sectionIngredients.map(ingredient =>
<IngredientItem
key={ingredient.Id}
classes={classes}
ingredient={ingredient}
onChange={ingredientChangeHandler}
removeIngredientHandler={removeIngredientHandler}
allowDelete={sectionIngredients.length > 1}
valid={validList.find(x => x.id === ingredient.Id)?.valid ?? true}
dirty={dirtyList[ingredient.Id] ?? false}
markDirty={markDirty}
/>
)}
</Reorder.Group>
);
}

View File

@ -0,0 +1,59 @@
import { Reorder, useDragControls } from "motion/react";
import type { RecipeIngredientSection } from "../../types/recipe";
import DeleteIconSmall from "../icons/DeleteIconSmall";
import DragIconSmall from "../icons/DragIconSmall";
import type { ReactNode } from "react";
interface IngredientSectionProps {
section: RecipeIngredientSection;
onChange: (id: string, name: string) => void;
removeIngredientSectionHandler: (id: string) => void;
allowDelete: boolean;
children?: ReactNode;
};
export default function IngredientSection({ section, onChange, removeIngredientSectionHandler, allowDelete, children }: IngredientSectionProps) {
const controls = useDragControls();
return (
<Reorder.Item
key={section.Id}
value={section}
dragListener={false}
dragControls={controls}
className="select-none"
>
<div className="w-full bg-gray-100 px-4 py-2 flex items-center my-2">
<p className="text-xs md:text-sm font-semibold">Group:</p>
<input
type="text"
value={section.Name}
onChange={(e) => onChange(section.Id, e.target.value)}
placeholder="Group label"
className="mx-2 px-2 py-1 border border-gray-300 flex-grow rounded-sm min-w-1"
/>
<div className="flex gap-x-2 items-center">
<button
disabled={!allowDelete}
className="p-2 pr-0 cursor-pointer text-gray-500 hover:text-red-500 disabled:text-gray-200 disabled:cursor-not-allowed duration-300"
onClick={() => removeIngredientSectionHandler(section.Id)}
>
<DeleteIconSmall />
</button>
<div
className="p-0 cursor-pointer touch-none"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
>
<DragIconSmall />
</div>
</div>
</div>
{children}
</Reorder.Item>
);
}

View File

@ -0,0 +1,76 @@
import { type ChangeEvent } from "react";
import type { RecipeInstruction } from "../../types/recipe";
import { Reorder, useDragControls } from "motion/react";
import DragIconSmall from "../icons/DragIconSmall";
import DeleteIconSmall from "../icons/DeleteIconSmall";
interface InstructionElementProps {
instruction: RecipeInstruction;
index: number;
allowDelete: boolean;
onChange: (id: string, value: string) => void;
onDelete: (id: string) => void;
valid: boolean;
dirty: boolean;
markDirty: (id: string) => void;
}
export default function InstructionElement({ instruction, index, allowDelete, onChange, onDelete, valid, dirty, markDirty }: InstructionElementProps) {
const controls = useDragControls();
const changeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => {
if (!dirty) markDirty(instruction.Id)
onChange(instruction.Id, e.target.value);
}
return (
<Reorder.Item
value={instruction}
dragListener={false}
dragControls={controls}
className="flex items-center"
>
<div className="flex flex-grow items-center select-none">
<h2 className="text-lg md:text-xl mr-4 text-gray-500">{index + 1}.</h2>
<div className="flex flex-col flex-grow">
<textarea
className={`flex-grow border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all min-h-40 md:min-h-26 shadow-sm ${!valid && dirty ? "border-red-500" : ""}`}
name="instructions"
value={instruction.Content}
onChange={changeHandler}
rows={3}
required
minLength={1}
placeholder="Describe this step..."
/>
{(!valid && dirty) && (
<p className="text-xs text-red-500 my-1">
Please enter an instruction (blank entries are not allowed).
</p>
)}
</div>
</div>
<div className="flex flex-col items-center">
<div
className="p-2 pr-0 cursor-grab touch-none"
onPointerDown={e => {
e.preventDefault();
controls.start(e);
}}
>
<DragIconSmall />
</div>
<button
tabIndex={-1}
disabled={!allowDelete}
onClick={() => onDelete(instruction.Id)}
className="p-2 pr-0 cursor-pointer text-gray-500 hover:text-red-500 disabled:text-gray-200 disabled:cursor-not-allowed duration-300"
>
<DeleteIconSmall />
</button>
</div>
</Reorder.Item>
);
}

View File

@ -0,0 +1,53 @@
import { Reorder } from "motion/react";
import InstructionElement from "./InstructionElement";
import type { Dispatch, SetStateAction } from "react";
import type { RecipeInstruction } from "../../types/recipe";
import type { RecipeValidationEntry } from "../../pages/Create";
interface InstructionListProps {
instructions: RecipeInstruction[];
setInstructions: Dispatch<SetStateAction<RecipeInstruction[]>>;
validList: RecipeValidationEntry[];
dirtyList: Record<string, boolean>;
markDirty: (id: string) => void;
}
export default function InstructionList({ instructions, setInstructions, validList, dirtyList, markDirty }: InstructionListProps) {
const handleChange = (id: string, value: string) => {
setInstructions(prev =>
prev.map(instr =>
instr.Id === id ? { ...instr, Content: value } : instr
)
);
};
const handleDelete = (id: string) => {
setInstructions(prev =>
prev.filter(instr => instr.Id !== id)
);
}
return (
<Reorder.Group
axis="y"
values={instructions}
onReorder={setInstructions}
className="flex flex-col gap-2 my-2"
>
{instructions.map((instruction, i) => (
<InstructionElement
key={instruction.Id}
index={i}
instruction={instruction}
allowDelete={instructions.length > 1}
onChange={handleChange}
onDelete={handleDelete}
valid={validList.find(x => x.id === instruction.Id)?.valid ?? true}
dirty={dirtyList[instruction.Id] ?? false}
markDirty={markDirty}
/>
))}
</Reorder.Group>
);
}

View File

@ -0,0 +1,44 @@
import type { CreateRecipeFormEntries } from "../../pages/Create";
interface ValidationErrorListProps {
validation: CreateRecipeFormEntries;
}
const MESSAGES: Record<keyof CreateRecipeFormEntries, string> = {
title: "Invalid title provided.",
description: "Invalid description provided.",
prepTime: "Invalid preparation time provided.",
cookTime: "Invalid cook time provided.",
servingSize: "Invalid serving size provided.",
category: "Invalid category selected.",
difficulty: "Invalid difficulty selected.",
ingredients: "Invalid ingredients provided.",
instructions: "Invalid instructions provided.",
}
export default function ValidationErrorList({ validation }: ValidationErrorListProps) {
return (
<div className="my-2">
{Object.entries(validation)
.filter(([, isValid]) => !isValid)
.map(([name]) => {
const key = name as keyof CreateRecipeFormEntries;
return (
<p key={name} className="text-sm text-red-500">
{MESSAGES[key]}
</p>
);
})}
{validation.ingredients.filter(x => !x.valid).length > 0 && (
<p className="text-sm text-red-500">
{MESSAGES.ingredients}
</p>
)}
{validation.instructions.filter(x => !x.valid).length > 0 && (
<p className="text-sm text-red-500">
{MESSAGES.instructions}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,7 @@
export default function DeleteIconSmall() {
return (
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0004 9.5L17.0004 14.5M17.0004 9.5L12.0004 14.5M4.50823 13.9546L7.43966 17.7546C7.79218 18.2115 7.96843 18.44 8.18975 18.6047C8.38579 18.7505 8.6069 18.8592 8.84212 18.9253C9.10766 19 9.39623 19 9.97336 19H17.8004C18.9205 19 19.4806 19 19.9084 18.782C20.2847 18.5903 20.5907 18.2843 20.7824 17.908C21.0004 17.4802 21.0004 16.9201 21.0004 15.8V8.2C21.0004 7.0799 21.0004 6.51984 20.7824 6.09202C20.5907 5.71569 20.2847 5.40973 19.9084 5.21799C19.4806 5 18.9205 5 17.8004 5H9.97336C9.39623 5 9.10766 5 8.84212 5.07467C8.6069 5.14081 8.38579 5.2495 8.18975 5.39534C7.96843 5.55998 7.79218 5.78846 7.43966 6.24543L4.50823 10.0454C3.96863 10.7449 3.69883 11.0947 3.59505 11.4804C3.50347 11.8207 3.50347 12.1793 3.59505 12.5196C3.69883 12.9053 3.96863 13.2551 4.50823 13.9546Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

View File

@ -0,0 +1,7 @@
export default function DragIconSmall() {
return (
<svg className="text-gray-500 h-6 w-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6H20M4 12H20M4 18H20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

View File

@ -0,0 +1,28 @@
export default function ServingSizeIcon() {
return (
<svg
className="h-8 text-blue-600"
fill="currentColor"
version="1.1"
id="Icons"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32"
xmlSpace="preserve"
>
<g>
<circle cx="12" cy="16" r="5"></circle>
<path
d="M12,6C6.5,6,2,10.5,2,16s4.5,10,10,10s10-4.5,10-10S17.5,6,12,6z M12,23c-3.9,0-7-3.1-7-7s3.1-7,7-7s7,3.1,7,7
S15.9,23,12,23z"
></path>
<path
d="M30,10.5V5c0-0.6-0.4-1-1-1s-1,0.4-1,1v5.5c0,0.2,0,0.4,0,0.5h-1V5c0-0.6-0.4-1-1-1s-1,0.4-1,1v6h-1c0-0.2,0-0.4,0-0.5V5
c0-0.6-0.4-1-1-1s-1,0.4-1,1v5.5c0,1.9,0.5,3.4,1.4,4.3c0.7,0.8,1,1.8,0.9,2.7l-1,7.3c-0.1,0.8,0.1,1.6,0.6,2.2S25.2,28,26,28
s1.5-0.3,2.1-0.9s0.8-1.4,0.6-2.2l-1-7.3c-0.1-1,0.2-2,0.9-2.8C29.5,13.8,30,12.3,30,10.5z"
></path>
</g>
</svg>
);
}

View File

@ -0,0 +1,16 @@
export default function ServingSizeIconSmall() {
return <>
<svg className="h-5 text-blue-600" fill="currentColor" version="1.1" id="Icons" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32" xmlSpace="preserve">
<g>
<circle cx="12" cy="16" r="5"></circle>
<path d="M12,6C6.5,6,2,10.5,2,16s4.5,10,10,10s10-4.5,10-10S17.5,6,12,6z M12,23c-3.9,0-7-3.1-7-7s3.1-7,7-7s7,3.1,7,7
S15.9,23,12,23z"></path>
<path d="M30,10.5V5c0-0.6-0.4-1-1-1s-1,0.4-1,1v5.5c0,0.2,0,0.4,0,0.5h-1V5c0-0.6-0.4-1-1-1s-1,0.4-1,1v6h-1c0-0.2,0-0.4,0-0.5V5
c0-0.6-0.4-1-1-1s-1,0.4-1,1v5.5c0,1.9,0.5,3.4,1.4,4.3c0.7,0.8,1,1.8,0.9,2.7l-1,7.3c-0.1,0.8,0.1,1.6,0.6,2.2S25.2,28,26,28
s1.5-0.3,2.1-0.9s0.8-1.4,0.6-2.2l-1-7.3c-0.1-1,0.2-2,0.9-2.8C29.5,13.8,30,12.3,30,10.5z"></path>
</g>
</svg>
</>
}

View File

@ -0,0 +1,19 @@
interface ShoppingListIconProps {
current: boolean;
};
export default function ShoppingListIcon({ current }: ShoppingListIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 576 512"
className={`${current ? "text-blue-500" : "text-gray-700 hover:text-blue-400"} duration-150 h-4`}
>
<path
fill="currentColor"
d="M0 24C0 10.7 10.7 0 24 0L69.5 0c22 0 41.5 12.8 50.6 32l411 0c26.3 0 45.5 25 38.6 50.4l-41 152.3c-8.5 31.4-37 53.3-69.5 53.3l-288.5 0 5.4 28.5c2.2 11.3 12.1 19.5 23.6 19.5L488 336c13.3 0 24 10.7 24 24s-10.7 24-24 24l-288.3 0c-34.6 0-64.3-24.6-70.7-58.5L77.4 54.5c-.7-3.8-4-6.5-7.9-6.5L24 48C10.7 48 0 37.3 0 24zM128 464a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zm336-48a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"
/>
</svg>
);
}

View File

@ -0,0 +1,25 @@
interface StarIconProps {
filled: boolean;
size: number;
};
export default function StarIcon({ filled, size = 6 }: StarIconProps) {
return <>
{filled ? (
<svg className={`h-${size} text-blue-600`} fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M23.632 9.201a.628.628 0 0 1-.22.678l-5.726 4.96 1.727 7.394a.606.606 0 0 1-.935.676l-6.503-3.953-6.503 3.953a.713.713 0 0 1-.374.112.57.57 0 0 1-.34-.109.629.629 0 0 1-.222-.679l1.729-7.393L.539 9.879A.607.607 0 0 1 .897 8.78l7.536-.635 2.965-7.083a.62.62 0 0 1 1.155.001l2.965 7.082 7.536.635a.63.63 0 0 1 .578.42z"
></path>
<path fill="none" d="M0 0h24v24H0z"></path>
</svg>
) : (
<svg className={`h-${size} text-gray-500`} fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M23.054 8.781l-7.536-.635-2.965-7.082a.619.619 0 0 0-1.155 0L8.433 8.145.896 8.78a.607.607 0 0 0-.357 1.1l5.726 4.96-1.729 7.395a.63.63 0 0 0 .223.679.573.573 0 0 0 .339.108.717.717 0 0 0 .374-.111l6.503-3.954 6.503 3.953a.606.606 0 0 0 .935-.677l-1.727-7.392 5.725-4.96a.607.607 0 0 0-.357-1.099zm-6.48 5.698l1.662 7.113-6.261-3.806-6.262 3.807 1.663-7.114-5.513-4.776 7.257-.611 2.855-6.817 2.855 6.817 7.257.611z"
></path>
<path fill="none" d="M0 0h24v24H0z"></path>
</svg>
)}
</>
}

View File

@ -0,0 +1,25 @@
interface StarIconSmallProps {
filled: boolean;
};
export default function StarIconSmall({ filled }: StarIconSmallProps) {
return <>
{filled ? (
<svg className="h-4 text-blue-600" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M23.632 9.201a.628.628 0 0 1-.22.678l-5.726 4.96 1.727 7.394a.606.606 0 0 1-.935.676l-6.503-3.953-6.503 3.953a.713.713 0 0 1-.374.112.57.57 0 0 1-.34-.109.629.629 0 0 1-.222-.679l1.729-7.393L.539 9.879A.607.607 0 0 1 .897 8.78l7.536-.635 2.965-7.083a.62.62 0 0 1 1.155.001l2.965 7.082 7.536.635a.63.63 0 0 1 .578.42z">
</path>
<path fill="none" d="M0 0h24v24H0z"></path>
</svg>
) : (
<svg className="h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M23.054 8.781l-7.536-.635-2.965-7.082a.619.619 0 0 0-1.155 0L8.433 8.145.896 8.78a.607.607 0 0 0-.357 1.1l5.726 4.96-1.729 7.395a.63.63 0 0 0 .223.679.573.573 0 0 0 .339.108.717.717 0 0 0 .374-.111l6.503-3.954 6.503 3.953a.606.606 0 0 0 .935-.677l-1.727-7.392 5.725-4.96a.607.607 0 0 0-.357-1.099zm-6.48 5.698l1.662 7.113-6.261-3.806-6.262 3.807 1.663-7.114-5.513-4.776 7.257-.611 2.855-6.817 2.855 6.817 7.257.611z">
</path>
<path fill="none" d="M0 0h24v24H0z"></path>
</svg>
)}
</>
}

View File

@ -0,0 +1,13 @@
export default function TimeIcon() {
return (
<svg className="h-7 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 7V12L14.5 13.5M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
);
}

View File

@ -0,0 +1,9 @@
export default function TimeIconSmall() {
return <>
<svg className="h-5 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 7V12L14.5 13.5M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
</>;
}

View File

@ -0,0 +1,59 @@
import { type ChangeEvent, type Dispatch, type SetStateAction } from "react";
import type { CreateRecipeFormDirtyEntries } from "../../pages/Create";
export interface RecipeCreateDropdownOption {
value: string;
name: string;
}
interface RecipeCreateFormDropdownProps {
label: string;
name: string;
desc: string;
required?: boolean;
valid: boolean;
value: string;
setValue: Dispatch<SetStateAction<string>>;
setDirty: Dispatch<SetStateAction<CreateRecipeFormDirtyEntries>>;
options: RecipeCreateDropdownOption[];
error: string;
parentClasses?: string;
classes: string;
};
export default function RecipeCreateDropdownInput({ label, name, desc, required = false, valid, value, setDirty, setValue, options, error, parentClasses = "", classes }: RecipeCreateFormDropdownProps) {
const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
setDirty(prev => ({ ...prev, [name]: true }));
setValue(e.target.value);
}
return (
<div className={`flex flex-col ${parentClasses}`}>
<label htmlFor={name} className="text-sm">
{label}
{required && <span className="text-red-500">*</span>}
</label>
<p className="text-xs pt-1 pb-2 text-gray-700">
{desc}
</p>
<select
className={`${!valid ? "border-red-500" : ""} ${classes}`}
name={name}
value={value}
onChange={handleChange}
required={required}
>
{options?.map(opt => (
<option key={opt.value} value={opt.value}>{opt.name}</option>
))}
</select>
{!valid && (
<p className="text-xs text-red-500 my-1">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,58 @@
import { type ChangeEvent, type Dispatch, type InputHTMLAttributes, type SetStateAction } from "react";
import type { CreateRecipeFormDirtyEntries } from "../../pages/Create";
interface RecipeCreateFormInputProps
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "name" | "type" | "placeholder" | "required"
> {
label: string;
name: string; // ENSURE THE NAME MATCHES THE VALUE IN THE ENTRIES TYPE
desc: string;
placeholder: string;
type?: string;
required?: boolean;
valid: boolean;
value: string;
setValue: Dispatch<SetStateAction<string>>;
setDirty: Dispatch<SetStateAction<CreateRecipeFormDirtyEntries>>;
error: string;
parentClasses?: string;
classes: string;
};
export default function RecipeCreateFormInput({ label, name, desc, placeholder, type = "text", required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setDirty(prev => ({ ...prev, [name]: true }));
setValue(e.target.value);
}
return (
<div className={`flex flex-col ${parentClasses}`}>
<label htmlFor={name} className="text-sm">
{label}
{required && <span className="text-red-500">*</span>}
</label>
<p className="text-xs pt-1 pb-2 text-gray-700">
{desc}
</p>
<input
className={`${!valid ? "border-red-500" : ""} ${classes}`}
type={type}
name={name}
value={value}
onChange={handleChange}
required={required}
placeholder={placeholder}
{...inputProps}
/>
{!valid && (
<p className="text-xs text-red-500 my-1">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,70 @@
import { useState, type ChangeEvent, type Dispatch, type FormEvent, type SetStateAction } from "react";
interface RecipeCreateFormTagsInputsProps {
tags: string[];
setTags: Dispatch<SetStateAction<string[]>>;
classes: string;
}
export default function RecipeCreateFormTagsInputs({ tags, setTags, classes }: RecipeCreateFormTagsInputsProps) {
const [input, setInput] = useState<string>("");
const changeHandler = (e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value);
const tagCreationHandler = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// why would anyone try this lol
if (input.trim() === "") return;
// Tag already exists, clear input and exit
if (tags.includes(input.toLowerCase())) {
setInput("");
return;
}
setInput("");
setTags(prev => [...prev, input.toLowerCase()]);
}
const tagDeletionHandler = (tag: string) => {
if (!tag) return;
setTags(prev => prev.filter(t => t !== tag));
}
return (
<form onSubmit={tagCreationHandler} className="my-4 flex flex-col gap-x-2">
<div className="flex flex-col flex-grow">
<label htmlFor="tags" className="text-sm">
Recipe Tags
</label>
<p className="text-xs pt-1 pb-2 text-gray-700">
Please provide a list of tags. <span className="italic">e.g., easy, dairy-free, gluten-free, high protein.</span>
</p>
<input
type="text"
value={input}
onChange={changeHandler}
name="tagInput"
maxLength={32}
enterKeyHint="done"
placeholder="e.g., Healthy"
className={classes}
/>
<input type="hidden" name="tags" id="tags" value="" />
</div>
<ul id="tag-list" className="my-2 flex gap-1 flex-wrap">
{tags?.map(tag =>
<li
className="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300"
key={tag}
>
<button tabIndex={-1} type="button" onClick={() => tagDeletionHandler(tag)}>
&times; {tag}
</button>
</li>
)}
</ul>
</form>
);
}

View File

@ -0,0 +1,56 @@
import { type ChangeEvent, type Dispatch, type SetStateAction, type TextareaHTMLAttributes } from "react";
import type { CreateRecipeFormDirtyEntries } from "../../pages/Create";
interface RecipeCreateFormInputProps
extends Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
"value" | "onChange" | "name" | "type" | "placeholder" | "required"
> {
label: string;
name: string;
desc: string;
placeholder: string;
required?: boolean;
valid: boolean;
value: string;
setValue: Dispatch<SetStateAction<string>>;
setDirty: Dispatch<SetStateAction<CreateRecipeFormDirtyEntries>>;
error: string;
parentClasses?: string;
classes: string;
};
export default function RecipeCreateFormTextArea({ label, name, desc, placeholder, required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) {
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setDirty(prev => ({ ...prev, [name]: true }));
setValue(e.target.value);
}
return (
<div className={`flex flex-col ${parentClasses}`}>
<label htmlFor={name} className="text-sm">
{label}
{required && <span className="text-red-500">*</span>}
</label>
<p className="text-xs pt-1 pb-2 text-gray-700">
{desc}
</p>
<textarea
className={`${!valid ? "border-red-500" : ""} ${classes}`}
name={name}
value={value}
onChange={handleChange}
required={required}
placeholder={placeholder}
{...inputProps}
/>
{!valid && (
<p className="text-xs text-red-500 my-1">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,28 @@
import type { ReactNode } from "react";
interface RecipeCreateFormWrapperProps {
label: string;
name: string;
desc: string;
required: boolean;
parentClasses: string;
children: ReactNode;
}
export default function RecipeCreateFormWrapper({ label, name, desc, required = false, parentClasses, children }: RecipeCreateFormWrapperProps) {
const normalized_name = name.toLowerCase().replaceAll(" ", "-");
return (
<div className={`flex flex-col ${parentClasses}`}>
<label htmlFor={normalized_name} className="text-sm">
{label}
{required && <span className="text-red-500">*</span>}
</label>
<p className="text-xs py-1 text-gray-700">
{desc}
</p>
{children}
</div>
);
}

View File

@ -0,0 +1,113 @@
import { use, useEffect, useState, type ChangeEvent, type Dispatch, type FormEvent, type SetStateAction } from "react";
import type { SearchFilters } from "../../types/search";
import FilterButton from "../buttons/FilterButton";
import RecipeSearchFilterDropdown from "./RecipeSearchFilterDropdown";
import { SearchRecipes } from "../../services/RecipeService";
import { isApiError } from "../../types/api/error";
import type { Recipe } from "../../types/recipe";
import { useNavigate } from "react-router-dom";
import { FilterContext } from "../../context/FilterContext";
import ROUTE_CONSTANTS from "../../types/routes";
interface RecipeSearchBarProps {
redirect: boolean;
searchOnLoad: boolean;
favorites: boolean;
setRecipes: Dispatch<SetStateAction<Recipe[]>> | null;
// Loading is optional
loading?: boolean;
setLoading?: Dispatch<SetStateAction<boolean>>;
};
export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, setRecipes, loading, setLoading }: RecipeSearchBarProps) {
const navigate = useNavigate();
const { filters, setFilters } = use(FilterContext);
const [displayDropdown, setDisplayDropdown] = useState<boolean>(false);
// SERVER FUNCTIONS
const fetchSearchResults = async () => {
if (redirect) {
await navigate(ROUTE_CONSTANTS.Search);
return;
}
// Should not allow many queries, thought we should allow redirect through loading
if (loading) return;
if (setLoading) setLoading(true);
try {
const result = await SearchRecipes({ ...filters, Favorites: favorites });
if (isApiError(result)) {
console.error(result.message);
return;
}
if (setRecipes)
setRecipes(result);
} finally {
if (setLoading) setLoading(false);
}
}
// HANDLERS
const toggleDropdownHandler = () => setDisplayDropdown(!displayDropdown);
// TODO: Store filters in a global state somewhere!
const searchHandler = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
await fetchSearchResults();
};
const queryInputHandler = (e: ChangeEvent<HTMLInputElement>) => {
const new_filters: SearchFilters = {
...filters,
Search: e.target.value,
};
setFilters(new_filters);
}
// EFFECTS
// TODO: Learn how to use 'useCallback' here to prevent endless loading and fix warning
useEffect(() => {
if (searchOnLoad)
void fetchSearchResults();
}, [searchOnLoad]);
return (
<form className="w-full px-4 my-8" onSubmit={(e) => void searchHandler(e)}>
<div className="flex w-full gap-x-2">
<div className="relative w-full">
<input type="hidden" name="redirect" value={JSON.stringify(redirect)} />
<input
type="search"
name="search"
placeholder="Search recipes, ingredients..."
value={filters ? filters.Search : ""}
onChange={queryInputHandler}
className="w-full pr-4 pl-10 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button className="absolute left-3 top-1/2 -translate-y-1/2">
<svg
className="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</button>
</div>
<FilterButton click={toggleDropdownHandler} />
</div>
<RecipeSearchFilterDropdown filters={filters} setFilters={setFilters} display={displayDropdown} />
</form>
);
}

Some files were not shown because too many files have changed in this diff Show More