Compare commits

..

60 Commits
v0.8.0 ... main

Author SHA1 Message Date
Chris Wells 9f3c280684 Correct PHPMA config 2024-05-04 21:37:28 +00:00
Chris Wells b9717a9961 Remove Heroku stuff 2024-05-04 21:18:38 +00:00
Chris Wells d77dba0468 Update to Node 20 2024-05-04 21:13:55 +00:00
Chris Wells e25d55212b Update Elastic migrations namespaces 2024-05-04 19:44:23 +00:00
Chris Wells 96a285d619 Finalize DevContainer support 2024-05-04 19:42:16 +00:00
Chris Wells 0fa77ec6cb Add DeveContainer config 2024-05-04 00:08:01 +00:00
Christopher C. Wells 0660c4bfa4 Upgrade to Laravel 10 2024-02-03 08:36:44 -08:00
Christopher C. Wells 9aea9114f7 Col flex recipe actions for all sizes 2024-02-03 08:02:51 -08:00
Christopher C. Wells dd2b10a1db Disable unreliable nutrient conversion tests 2024-02-02 11:10:42 -08:00
Christopher C. Wells 5fdd3ef2f4 Update GitHub Actions versions 2024-02-02 10:31:37 -08:00
Christopher C. Wells b955eb53b8 Group by name only
Fixes #63
Fixes #64
2024-02-02 10:22:46 -08:00
Christopher C. Wells 2a05ebc3c8 Update base docker compose file 2024-02-02 10:16:17 -08:00
Christopher C. Wells 33d3a7421f Remove tests relying on PHP math expressions 2023-12-04 21:02:42 -08:00
Christopher C. Wells 8d9f07f931 Remove deprecated GH action function 2023-12-04 20:34:11 -08:00
Christopher C. Wells 4231b2746e Update CI actions 2023-12-04 20:26:38 -08:00
Christopher C. Wells 7b3b5a9617 Update PHP dependencies 2023-12-04 20:22:01 -08:00
Christopher Charbonneau Wells 4271918fe8
Remove reference to demo 2022-11-02 13:25:30 -07:00
Christopher Charbonneau Wells bbdc7c7202
Remove reference to demo 2022-11-02 13:22:17 -07:00
Bram Wubs a02875f845 Update some css 2022-10-20 19:05:53 -07:00
Bram Wubs b3999f005b Cleanup 2022-10-20 19:05:53 -07:00
Bram Wubs 41c66cdf12 npm prod? 2022-10-20 19:05:53 -07:00
Bram Wubs f74806ea07 Log a recipe or food from their show pages 2022-10-20 19:05:53 -07:00
Bram Wubs 50bb378c02 bump laravel/sail to v1.16.2 2022-10-09 19:23:52 -07:00
Christopher C. Wells 5de41c4793 Add recipe duplicate test 2022-03-06 20:53:50 -08:00
Christopher C. Wells 4aaf83c862 Add recipe duplicate functionality 2022-03-06 20:53:50 -08:00
Christopher C. Wells 6e3c40531d Remove use of deprecated Media Library model method 2022-03-06 14:43:39 -08:00
Christopher C. Wells 1d4a975ae5 Update Composer dependencies 2022-03-06 14:30:52 -08:00
Christopher C. Wells c4cb8759ce Delete unused tags 2022-03-06 14:29:39 -08:00
Christopher C. Wells 7daeab4c44 Cast Goal User ID as int 2022-02-28 18:45:55 -08:00
Christopher C. Wells ef096492d4 Add "ingredient ID" attribute to ingredients
This change prevents ID collision when dealing with multiple models.
2022-02-20 13:46:16 -08:00
Christopher C. Wells ce4827a8ec Account for serving sizes > 1 in recipe display
Closes #19
2022-02-12 06:27:12 -08:00
Christopher C. Wells 99e214e822 Update node dependencies 2022-02-11 20:09:00 -08:00
Christopher C. Wells 5c29150ae1 Upgrade to Laravel 9 2022-02-11 19:27:12 -08:00
Christopher C. Wells 9b0ffde30c Update dependencies 2022-02-11 18:31:22 -08:00
Christopher Charbonneau Wells 0e8eba8b91
Use MySQL for tests in CI (#40) 2021-11-10 16:13:28 -08:00
Christopher C. Wells 6bc18c48a8 Set CI env to `testing` 2021-11-10 15:07:58 -08:00
Christopher C. Wells 7273da6bd2 Support parallel testing with Sail and MySQL 2021-11-10 15:04:59 -08:00
Christopher C. Wells c42cfdb531 Add a cache-clear dev console command; document dev console commands 2021-11-10 14:58:06 -08:00
Christopher C. Wells f17fb757ef Add a policy to restrict goal management to owner 2021-11-09 17:00:30 -08:00
Christopher C. Wells 3f04f14a2d Add API key to profile output 2021-11-08 20:55:00 -08:00
Christopher C. Wells 0f2d054649 Add cookie authentication support to API 2021-11-08 20:55:00 -08:00
Christopher C. Wells f607bf73f7 Expand size of decimal fields
Fixed #37
2021-10-31 19:38:27 -07:00
Christopher C. Wells 6dd301a296 Update dependencies 2021-10-31 19:37:44 -07:00
Christopher C. Wells f7a95cc020 Ensure 8 character minimum password length 2021-09-27 15:32:08 -07:00
Christopher C. Wells 419fcc2cb9 Disable browser cache with Cache-Control 2021-09-27 10:02:11 -07:00
Christopher C. Wells cf51670727 Add CSP policy to all responses 2021-09-27 09:02:44 -07:00
Christopher C. Wells 7d058ac628 Update dependencies 2021-09-26 14:17:30 -07:00
Christopher C. Wells 11f26504b6 Add session cookie security to env example 2021-09-26 13:43:35 -07:00
Christopher Charbonneau Wells db055b934e
Create SECURITY.md
Closes #34
2021-09-25 08:09:59 -07:00
Christopher C. Wells 44110984e2 Upgrade Sail
Now with Xdebug support!!
2021-09-06 14:09:22 -07:00
Christopher C. Wells 6a0f6ae17d Update project dependencies 2021-09-06 13:35:33 -07:00
Christopher C. Wells f79fd5e479 Disable image conversions queuing by default 2021-09-05 14:28:44 -07:00
Christopher C. Wells a2621f7c17 Add step to set necessary credentials on web directories 2021-09-05 14:28:03 -07:00
Christopher C. Wells 1316c5e59b Remove extra space in front of example commands 2021-09-05 13:26:08 -07:00
RyderForNow fd0a88845d Fixed typo 2021-09-04 14:16:45 -07:00
RyderForNow e0875c6fdc Misc. fixes in README 2021-09-04 14:16:45 -07:00
RyderForNow ad2f698efb Fix typo in README 2021-09-04 14:07:36 -07:00
Christopher Charbonneau Wells 2308b80cdd
Pin Alpine to 3.13
See https://github.com/alpinelinux/docker-alpine/issues/146
2021-07-13 21:18:54 -07:00
Christopher C. Wells feb6cce3a4 Update to AlpineJS 3.x 2021-07-12 21:02:27 -07:00
Christopher C. Wells 636f9ff864 Update app dependencies 2021-07-12 19:33:17 -07:00
91 changed files with 17034 additions and 31784 deletions

View File

@ -0,0 +1,25 @@
// https://aka.ms/devcontainer.json
{
"name": "kcal",
"dockerComposeFile": [
"../docker-compose.yml"
],
"service": "app",
"workspaceFolder": "/var/www/html",
"customizations": {
"vscode": {
"extensions": [
"mikestead.dotenv",
"amiralizadeh9480.laravel-extra-intellisense",
"ryannaddy.laravel-artisan",
"onecentlin.laravel5-snippets",
"onecentlin.laravel-blade"
],
"settings": {}
}
},
"remoteUser": "sail",
"postCreateCommand": "bash .devcontainer/postCreateCommand.sh"
// "runServices": [],
// "shutdownAction": "none",
}

View File

@ -0,0 +1,10 @@
#!/bin/bash
echo alias sail=\'sh $([ -f sail ] && echo sail || echo vendor/bin/sail)\' >> ~/.bash_aliases
chown -R 1000:1000 /var/www/html
composer install
cp .env.example .env
php artisan migrate
php artisan elastic:migrate
php artisan key:generate --force -n
php artisan db:seed

View File

@ -1,5 +1,5 @@
APP_NAME=kcal APP_NAME=kcal
APP_ENV=local APP_ENV=testing
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
@ -7,7 +7,12 @@ APP_URL=http://localhost
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=kcal
DB_USERNAME=root
DB_PASSWORD=root
SCOUT_DRIVER=elastic SCOUT_DRIVER=elastic
ELASTIC_HOST=localhost:9200 ELASTIC_HOST=localhost:9200

View File

@ -3,26 +3,33 @@
# #
APP_NAME=kcal APP_NAME=kcal
APP_ENV=production APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=false APP_DEBUG=false
APP_URL=http://127.0.0.1 APP_URL=http://127.0.0.1
APP_PORT=80 APP_PORT=8080
APP_SERVICE=app APP_SERVICE=app
APP_TIMEZONE=UTC APP_TIMEZONE=UTC
#
# Security
# Enable these settings after setting up an HTTPS connection.
#
# SESSION_SECURE_COOKIE=true
# #
# Databases configuration. # Databases configuration.
# #
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=localhost DB_HOST=db
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=kcal DB_DATABASE=kcal
DB_USERNAME=kcal DB_USERNAME=kcal
DB_PASSWORD=kcal DB_PASSWORD=kcal
REDIS_HOST=localhost REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
# #
@ -36,7 +43,7 @@ REDIS_PORT=6379
#ALGOLIA_SECRET= #ALGOLIA_SECRET=
SCOUT_DRIVER=elastic SCOUT_DRIVER=elastic
ELASTIC_HOST=localhost:9200 ELASTIC_HOST=elasticsearch:9200
ELASTIC_PORT=9200 ELASTIC_PORT=9200
# #
@ -44,6 +51,7 @@ ELASTIC_PORT=9200
# #
MEDIA_DISK=media MEDIA_DISK=media
QUEUE_CONVERSIONS_BY_DEFAULT=false
#MEDIA_DISK=s3-public #MEDIA_DISK=s3-public
#AWS_ACCESS_KEY_ID= #AWS_ACCESS_KEY_ID=
@ -51,6 +59,12 @@ MEDIA_DISK=media
#AWS_DEFAULT_REGION= #AWS_DEFAULT_REGION=
#AWS_BUCKET= #AWS_BUCKET=
#
# Sail (local development).
#
#SAIL_XDEBUG_MODE=develop,debug
# #
# Misc. drivers and configuration. # Misc. drivers and configuration.
# #

View File

@ -10,9 +10,13 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: getong/mariadb-action@v1.1
with:
mysql database: kcal
mysql root password: root
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
php-version: '8.0' php-version: '8.2'
coverage: xdebug coverage: xdebug
- name: Configure sysctl limits for Elasticsearch - name: Configure sysctl limits for Elasticsearch
run: | run: |
@ -23,13 +27,13 @@ jobs:
- name: Run Elasticsearch - name: Run Elasticsearch
uses: elastic/elastic-github-actions/elasticsearch@master uses: elastic/elastic-github-actions/elasticsearch@master
with: with:
stack-version: '7.12.0' stack-version: '7.17.17'
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Get composer cache directory - name: Get Composer cache directory
id: composer-cache id: composer-cache
run: echo "::set-output name=dir::$(composer config cache-files-dir)" run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v2 uses: actions/cache@v4
with: with:
path: ${{ steps.composer-cache.outputs.dir }} path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@ -41,7 +45,7 @@ jobs:
php -r "file_exists('.env') || copy('.env.ci', '.env');" php -r "file_exists('.env') || copy('.env.ci', '.env');"
php artisan key:generate php artisan key:generate
- name: Run tests - name: Run tests
run: vendor/bin/paratest --coverage-clover build/logs/clover.xml run: php artisan test --parallel --recreate-databases --coverage-clover build/logs/clover.xml
- name: Upload coverage results to Coveralls - name: Upload coverage results to Coveralls
env: env:
COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
20

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
ARG MEDIA_LIBRARY_DEPS="jpegoptim optipng pngquant gifsicle" ARG MEDIA_LIBRARY_DEPS="jpegoptim optipng pngquant gifsicle"
FROM php:8.0-fpm-alpine FROM php:8.2-fpm-alpine
ARG MEDIA_LIBRARY_DEPS ARG MEDIA_LIBRARY_DEPS
RUN apk add --no-cache --virtual \ RUN apk add --no-cache --virtual \

View File

@ -1 +0,0 @@
web: vendor/bin/heroku-php-apache2 public/

332
README.md
View File

@ -9,15 +9,6 @@ journal to help along the way. Kcal is a *personal* system that focuses on direc
control of inputs (as opposed to unwieldy user generated datasets) and a minimal, control of inputs (as opposed to unwieldy user generated datasets) and a minimal,
easy to use recipe presentation for preparing meals. easy to use recipe presentation for preparing meals.
## Demo
A [demo of kcal](http://demo.kcal.cooking) is available on Heroku. Login credentials are:
- Username: `kcal`
- Password: `kcal`
The demo instance resets every hour, on the hour.
## Screenshots ## Screenshots
![kcal mobile screenshot](screenshots/mobile.png) ![kcal mobile screenshot](screenshots/mobile.png)
@ -190,38 +181,6 @@ at [kcalapp/kcal](https://hub.docker.com/repository/docker/kcalapp/kcal) on Dock
See the [kcal-app/kcal-docker](https://github.com/kcal-app/kcal-docker) repository See the [kcal-app/kcal-docker](https://github.com/kcal-app/kcal-docker) repository
for a Docker Compose based template and instructions. for a Docker Compose based template and instructions.
### Heroku
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
The default username and password for a Heroku deployment is `kcal`/`kcal`.
#### Using Heroku CLI
For a manual deploy using Heroku CLI, execute the following after initial deployment:
heroku run php artisan migrate
heroku run php artisan user:add
heroku config:set APP_KEY=$(php artisan --no-ansi key:generate --show)
#### Media storage
Heroku uses an ephemeral disk. In order to maintain recipe and/or user images between
app restarts AWS can be used. See [Media Storage - AWS S3](#aws-s3) for additional
guidance.
#### Search drivers
See the [Search](#search-mag) section for information about supported drivers. Additional
environment variable configuration is necessary when using any search driver other
than the default ("null").
#### Redis Add-on
The [Heroku Redis](https://elements.heroku.com/addons/heroku-redis) add-on can be
added to the app and will work without any configuration changes. It is left out
of the default build only because it takes a very long time to provision.
### Manual ### Manual
This deployment process has been tested with an Ubuntu 20.04 LTS instance with This deployment process has been tested with an Ubuntu 20.04 LTS instance with
@ -231,113 +190,119 @@ section for other options if lower memory support is needed.
1. Add [PHP 8.x repository](https://launchpad.net/~ondrej/+archive/ubuntu/php). 1. Add [PHP 8.x repository](https://launchpad.net/~ondrej/+archive/ubuntu/php).
sudo apt-get install software-properties-common sudo apt-get install software-properties-common
sudo add-apt-repository ppa:ondrej/php sudo add-apt-repository ppa:ondrej/php
1. Add [Elasticsearch 7.x repository](https://www.elastic.co/guide/en/elasticsearch/reference/current/deb.html). 1. Add [Elasticsearch 7.x repository](https://www.elastic.co/guide/en/elasticsearch/reference/current/deb.html).
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add - wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-7.x.list echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-7.x.list
1. Update available packages. 1. Update available packages.
sudo apt-get update sudo apt-get update
1. Install dependencies. 1. Install dependencies.
sudo apt-get install elasticsearch mysql-server-8.0 ngingx-full php8.0 php8.0-bcmath php8.0-cli php8.0-curl php8.0-gd php8.0-intl php8.0-mbstring php8.0-mysql php8.0-redis php8.0-xml php8.0-zip redis sudo apt-get install elasticsearch mysql-server-8.0 nginx-full php8.2 php8.2-bcmath php8.2-cli php8.2-curl php8.2-gd php8.2-intl php8.2-mbstring php8.2-mysql php8.2-redis php8.2-xml php8.2-zip redis php8.2-fpm
1. Start Elasticsearch and configure to run at start up. 1. Start Elasticsearch and configure to run at start up.
sudo systemctl start elasticsearch sudo systemctl start elasticsearch
sudo systemctl enable elasticsearch sudo systemctl enable elasticsearch
1. Install Composer. 1. Install Composer.
:rotating_light: This command runs code from a remote location as root. :rotating_light: This command runs code from a remote location as root.
See [Download Composer](https://getcomposer.org/download/) for alternative install options. See [Download Composer](https://getcomposer.org/download/) for alternative install options.
curl -s https://getcomposer.org/installer | sudo php -- --install-dir=/usr/local/bin/ --filename=composer curl -s https://getcomposer.org/installer | sudo php -- --install-dir=/usr/local/bin/ --filename=composer
1. Clone the app repository. 1. Clone the app repository.
cd /var/www cd /var/www
sudo mkdir kcal sudo mkdir kcal
sudo chown $USER:`id -gn $USER` kcal sudo chown $USER:`id -gn $USER` kcal
cd kcal cd kcal
git clone https://github.com/kcal-app/kcal.git . git clone https://github.com/kcal-app/kcal.git .
1. Configure nginx to serve the app public files. 1. Configure nginx to serve the app public files.
sudo vim /etc/nginx/conf.d/kcal.conf sudo vim /etc/nginx/conf.d/kcal.conf
<edit config, see example below> <edit config, see example below>
sudo service nginx restart sudo service nginx restart
Example config: Example config:
server { server {
listen 80; listen 80;
server_name kcal.example.com; server_name kcal.example.com;
root /var/www/kcal/public; root /var/www/kcal/public;
add_header X-Frame-Options "SAMEORIGIN"; add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff"; add_header X-Content-Type-Options "nosniff";
index index.php; index index.php;
charset utf-8; charset utf-8;
location / { location / {
try_files $uri $uri/ /index.php?$query_string; try_files $uri $uri/ /index.php?$query_string;
} }
location = /favicon.ico { access_log off; log_not_found off; } location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php; error_page 404 /index.php;
location ~ \.php$ { location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params; include fastcgi_params;
} }
location ~ /\.(?!well-known).* { location ~ /\.(?!well-known).* {
deny all; deny all;
} }
} }
1. Create database user (with secure credentials!). 1. Create database user.
sudo mysql -u root sudo mysql -u root
CREATE DATABASE `kcal`; CREATE DATABASE `kcal`;
CREATE USER 'kcal'@'localhost' IDENTIFIED BY 'kcal'; CREATE USER 'kcal'@'localhost' IDENTIFIED BY RANDOM PASSWORD;
GRANT ALL ON `kcal`.* TO 'kcal'@'localhost'; GRANT ALL ON `kcal`.* TO 'kcal'@'localhost';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
1. Generate an app key to use in the next step. :lock: Save the generated password output by the `CREATE USER` statement.
php artisan --no-ansi key:generate --show 1. Install dependencies and generate an app key to use in the next step.
composer install --optimize-autoloader --no-dev
php artisan --no-ansi key:generate --show
1. Copy environment config file and adjust as desired. 1. Copy environment config file and adjust as desired.
cp .env.example .env cp .env.example .env
At a minimum: At a minimum:
- Set `APP_KEY` to the value generated in the previous step. - Set `APP_KEY` to the value generated in the previous step.
- Set `APP_URL` to match the host configured in nginx configuration. - Set `APP_URL` to match the host configured in nginx configuration.
- Set the `DATABASE_` values to the configured credentials. - Set the `DATABASE_` values to the configured credentials.
1. Run initial app installation/bootstrap commands. 1. Run initial app installation/bootstrap commands.
cd /var/www/kcal php artisan migrate
composer install --optimize-autoloader --no-dev php artisan elastic:migrate
php artisan migrate php artisan config:cache
php artisan elastic:migrate php artisan route:cache
php artisan config:cache php artisan view:cache
php artisan route:cache php artisan user:add --admin
php artisan view:cache
php artisan user:add --admin 1. Allow web server to access required directories.
sudo chown -R $USER:www-data {storage,public}
sudo chmod g+s {storage,public}
1. Visit the `APP_URL` and log in! 1. Visit the `APP_URL` and log in!
@ -360,44 +325,44 @@ storage in AWS S3.
Use this example policy to grant necessary permissions to a specific bucket: Use this example policy to grant necessary permissions to a specific bucket:
{ {
"Version": "2012-10-17", "Version": "2012-10-17",
"Statement": [ "Statement": [
{ {
"Sid": "VisualEditor0", "Sid": "VisualEditor0",
"Effect": "Allow", "Effect": "Allow",
"Action": [ "Action": [
"s3:GetBucketPublicAccessBlock", "s3:GetBucketPublicAccessBlock",
"s3:GetBucketPolicyStatus", "s3:GetBucketPolicyStatus",
"s3:GetAccountPublicAccessBlock", "s3:GetAccountPublicAccessBlock",
"s3:ListAllMyBuckets", "s3:ListAllMyBuckets",
"s3:GetBucketAcl", "s3:GetBucketAcl",
"s3:GetBucketLocation" "s3:GetBucketLocation"
], ],
"Resource": "*" "Resource": "*"
}, },
{ {
"Sid": "VisualEditor1", "Sid": "VisualEditor1",
"Effect": "Allow", "Effect": "Allow",
"Action": "s3:ListBucket", "Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME" "Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME"
}, },
{ {
"Sid": "VisualEditor2", "Sid": "VisualEditor2",
"Effect": "Allow", "Effect": "Allow",
"Action": ["s3:*Object", "s3:*ObjectAcl*"], "Action": ["s3:*Object", "s3:*ObjectAcl*"],
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME/*" "Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME/*"
} }
] ]
} }
1. Set necessary environment variables (via `.env` or some other mechanism). 1. Set necessary environment variables (via `.env` or some other mechanism).
MEDIA_DISK=s3-public MEDIA_DISK=s3-public
AWS_ACCESS_KEY_ID=REPLACE_WITH_IAM_KEY AWS_ACCESS_KEY_ID=REPLACE_WITH_IAM_KEY
AWS_SECRET_ACCESS_KEY=REPLACE_WITH_IAM_SECRET AWS_SECRET_ACCESS_KEY=REPLACE_WITH_IAM_SECRET
AWS_DEFAULT_REGION=REPLACE_WITH_S3_BUCKET_NAME AWS_DEFAULT_REGION=REPLACE_WITH_S3_BUCKET_NAME
AWS_BUCKET=REPLACE_WITH_S3_BUCKET_REGION AWS_BUCKET=REPLACE_WITH_S3_BUCKET_REGION
### Search :mag: ### Search :mag:
@ -420,9 +385,9 @@ adds support for Scout (see: laravel-json-api/laravel#32).
1. Using the **Application ID** and **Admin API Key** values, update kcal's `.env` file: 1. Using the **Application ID** and **Admin API Key** values, update kcal's `.env` file:
SCOUT_DRIVER=algolia SCOUT_DRIVER=algolia
ALGOLIA_APP_ID=<APPLICATION_ID> ALGOLIA_APP_ID=<APPLICATION_ID>
ALGOLIA_SECRET=<ADMIN_API_KEY> ALGOLIA_SECRET=<ADMIN_API_KEY>
### ElasticSearch (`elastic`) ### ElasticSearch (`elastic`)
@ -430,16 +395,16 @@ adds support for Scout (see: laravel-json-api/laravel#32).
1. Update kcal's `.env` file. 1. Update kcal's `.env` file.
SCOUT_DRIVER=elastic SCOUT_DRIVER=elastic
ELASTIC_HOST=<HOST:PORT> ELASTIC_HOST=<HOST:PORT>
ELASTIC_PORT=<PORT> ELASTIC_PORT=<PORT>
Note: The `ELASTIC_PORT` variable is a convenience option specifically for Note: The `ELASTIC_PORT` variable is a convenience option specifically for
Docker Compose configurations and is not strictly required. Docker Compose configurations and is not strictly required.
1. Run Elastic's migrations. 1. Run Elastic's migrations.
php artisan elastic:migrate php artisan elastic:migrate
### Fallback (`null`) ### Fallback (`null`)
@ -451,6 +416,10 @@ Set `SCOUT_DRIVER=null` in kcal's `.env` file to use the fallback driver.
## Development ## Development
### Dev Container
Clone the project in an IDE with Dev Container support and build the container.
### Laravel Sail ### Laravel Sail
#### Prerequisites #### Prerequisites
@ -463,20 +432,20 @@ Set `SCOUT_DRIVER=null` in kcal's `.env` file to use the fallback driver.
1. Clone the repository. 1. Clone the repository.
git clone https://github.com/kcal-app/kcal.git git clone https://github.com/kcal-app/kcal.git
cd kcal cd kcal
1. Install development dependencies. 1. Install development dependencies.
composer install composer install
1. Create a local `.env` file. 1. Create a local `.env` file.
cp .env.local.example .env cp .env.example .env
1. Generate an app key. 1. Generate an app key.
php artisan key:generate php artisan key:generate
Verify that the `APP_KEY` variable has been set in `.env`. If has not, run Verify that the `APP_KEY` variable has been set in `.env`. If has not, run
`php artisan key:generate --show` and copy the key and append it to the `php artisan key:generate --show` and copy the key and append it to the
@ -484,16 +453,16 @@ Set `SCOUT_DRIVER=null` in kcal's `.env` file to use the fallback driver.
1. Run it! :sailboat: 1. Run it! :sailboat:
vendor/bin/sail up vendor/bin/sail up
1. (On first run) Run migrations. 1. (On first run) Run migrations.
vendor/bin/sail artisan migrate vendor/bin/sail artisan migrate
vendor/bin/sail artisan elastic:migrate vendor/bin/sail artisan elastic:migrate
1. (On first run) Seed the database. 1. (On first run) Seed the database.
vendor/bin/sail artisan db:seed vendor/bin/sail artisan db:seed
The default username and password is `kcal` / `kcal`. The default username and password is `kcal` / `kcal`.
@ -502,6 +471,25 @@ Navigate to [http://127.0.0.1:8080](http://127.0.0.1:8080) to log in!
Create a `docker-compose.override.yml` file to override any of the default settings Create a `docker-compose.override.yml` file to override any of the default settings
provided for this environment. provided for this environment.
### Custom console commands
#### `dev:cache-clear`
Executes the various cache clearing artisan commands:
- `cache:clear`
- `config:clear`
- `route:clear`
- `view:clear`
#### `dev:reset`
Resets and seeds the database by executing the following artisan commands:
- `db:wipe`
- `migrate`
- `db:seed`
### Testing ### Testing
Ensure that Sail is running (primarily to provide ElasticSearch): Ensure that Sail is running (primarily to provide ElasticSearch):
@ -510,25 +498,5 @@ Ensure that Sail is running (primarily to provide ElasticSearch):
Execute tests. Execute tests.
vendor/bin/sail artisan test --parallel vendor/bin/sail artisan dev:cache-clear
vendor/bin/sail artisan test --parallel --recreate-databases
#### Caveats
In order to support parallel testing, tests are run using sqlite (even though Sail
provides MySQL). To test with MySQL make a copy of `phpunit.xml.dist` as `phpunit.xml`
and change:
```
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
```
to
```
<server name="DB_CONNECTION" value="mysql"/>
<server name="DB_HOST" value="db"/>
```
Now running `vendor/bin/sail artisan test` will run tests with MySQL **but** tests
cannot be run in parallel.

19
SECURITY.md Normal file
View File

@ -0,0 +1,19 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 0.x | :white_check_mark: |
## Reporting a Vulnerability
Kcal's maintainers recommend huntr.dev as a platform for reporting
vulnerabilities. Maintainers will do their best to verify, respond to
and fix issues reported on the platform.
[Disclose a Vulnerability on huntr.dev](https://huntr.dev/bounties/disclose/)

File diff suppressed because it is too large Load Diff

View File

@ -1,71 +0,0 @@
{
"name": "kcal the personal food nutrition journal",
"description": "Self-hostable web app for food and recipe nutrition tracking.",
"keywords": [
"cooking",
"fitness",
"food",
"heath",
"laravel",
"nutrition",
"php",
"recipes",
"self-host"
],
"repository": "https://github.com/kcal-app/kcal",
"website": "http://demo.kcal.cooking",
"buildpacks": [
{
"url": "heroku/php"
}
],
"addons": [
"heroku-postgresql"
],
"env": {
"APP_KEY": {
"description": "Used for the auth system.",
"generator": "secret"
},
"APP_TIMEZONE": {
"description": "Application time zone.",
"value": "Etc/UTC"
},
"DB_CONNECTION": {
"description": "Database driver.",
"value": "pgsql"
},
"SCOUT_DRIVER": {
"description": "Search driver ('algolia', 'elastic', or 'null').",
"value": "null"
},
"MEDIA_DISK": {
"description": "Media disk. Set to 's3-public' for recipe/user image support.",
"value": "local"
},
"AWS_BUCKET": {
"description": "AWS bucket name for recipe/user image storage. Required when MEDIA_DISK is 's3-public'.",
"value": "",
"required": false
},
"AWS_DEFAULT_REGION": {
"description": "AWS region for AWS_BUCKET. Required when MEDIA_DISK is 's3-public'.",
"value": "",
"required": false
},
"AWS_ACCESS_KEY_ID": {
"description": "AWS access key ID for AWS_BUCKET. Required when MEDIA_DISK is 's3-public'.",
"value": "",
"required": false
},
"AWS_SECRET_ACCESS_KEY": {
"description": "AWS secret key ID for AWS_ACCESS_KEY_ID. Required when MEDIA_DISK is 's3-public'.",
"value": "",
"required": false
}
},
"scripts": {
"postdeploy": "php artisan migrate --force && php artisan user:add kcal kcal --name=Admin --admin"
},
"success_url": "/"
}

View File

@ -87,17 +87,7 @@ class FoodController extends Controller
} }
$food->fill($attributes)->save(); $food->fill($attributes)->save();
$food->updateTagsFromRequest($request);
$tags = $request->get('tags', []);
if (!empty($tags)) {
$food->syncTags(explode(',', $tags));
}
elseif ($food->tags->isNotEmpty()) {
$food->detachTags($food->tags);
}
// Refresh and index updated tags.
$food->fresh()->searchable();
session()->flash('message', "Food {$food->name} updated!"); session()->flash('message', "Food {$food->name} updated!");
return redirect()->route('foods.show', $food); return redirect()->route('foods.show', $food);

View File

@ -5,8 +5,9 @@ namespace App\Http\Controllers;
use App\Models\Food; use App\Models\Food;
use App\Models\Recipe; use App\Models\Recipe;
use App\Search\Ingredient; use App\Search\Ingredient;
use ElasticScoutDriverPlus\Builders\MultiMatchQueryBuilder; use Elastic\ScoutDriverPlus\Builders\MultiMatchQueryBuilder;
use ElasticScoutDriverPlus\Builders\TermsQueryBuilder; use Elastic\ScoutDriverPlus\Builders\TermsQueryBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -42,11 +43,10 @@ class IngredientPickerController extends Controller
* Search using an ElasticSearch service. * Search using an ElasticSearch service.
*/ */
private function searchWithElasticSearch(string $term): Collection { private function searchWithElasticSearch(string $term): Collection {
return Food::boolSearch() $query = Query::bool()
->join(Recipe::class)
// Attempt to match exact phrase first. // Attempt to match exact phrase first.
->should('match_phrase', ['name' => $term]) ->should(Query::matchPhrase()->field('name')->query($term))
// Attempt multi-match search on all relevant fields with search-as-you-type on name. // Attempt multi-match search on all relevant fields with search-as-you-type on name.
->should((new MultiMatchQueryBuilder()) ->should((new MultiMatchQueryBuilder())
@ -57,10 +57,12 @@ class IngredientPickerController extends Controller
->fuzziness('AUTO')) ->fuzziness('AUTO'))
// Attempt to match on any tags in the term. // Attempt to match on any tags in the term.
->should((new TermsQueryBuilder()) ->should((new TermsQueryBuilder())->field('tags')->values(explode(' ', $term)))
->terms('tags', explode(' ', $term)))
// Get resulting models. ->minimumShouldMatch(1);
return Food::searchQuery($query)
->join(Recipe::class)
->execute() ->execute()
->models(); ->models();
} }

View File

@ -12,6 +12,7 @@ use App\Support\Number;
use App\Support\Nutrients; use App\Support\Nutrients;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -216,17 +217,7 @@ class RecipeController extends Controller
$this->updateIngredients($recipe, $input); $this->updateIngredients($recipe, $input);
$this->updateIngredientSeparators($recipe, $input); $this->updateIngredientSeparators($recipe, $input);
$this->updateSteps($recipe, $input); $this->updateSteps($recipe, $input);
$recipe->updateTagsFromRequest($request);
$tags = $request->get('tags', []);
if (!empty($tags)) {
$recipe->syncTags(explode(',', $tags));
}
elseif ($recipe->tags->isNotEmpty()) {
$recipe->detachTags($recipe->tags);
}
// Refresh and index updated tags.
$recipe->fresh()->searchable();
}); });
} catch (\Exception $e) { } catch (\Exception $e) {
DB::rollBack(); DB::rollBack();
@ -360,6 +351,30 @@ class RecipeController extends Controller
$recipe->ingredientSeparators()->saveMany($ingredient_separators); $recipe->ingredientSeparators()->saveMany($ingredient_separators);
} }
/**
* Confirm duplicating recipe.
*/
public function duplicateConfirm(Recipe $recipe): View {
return view('recipes.duplicate')->with('recipe', $recipe);
}
/**
* Duplicate a recipe.
*/
public function duplicate(Request $request, Recipe $recipe): RedirectResponse
{
$attributes = $request->validate(['name' => ['required', 'string']]);
try {
$new_recipe = $recipe->duplicate($attributes);
} catch (\Throwable $e) {
return back()->withInput()->withErrors($e->getMessage());
}
return redirect()->route('recipes.show', $new_recipe)
->with('message', "Recipe {$recipe->name} duplicated!");
}
/** /**
* Confirm removal of specified resource. * Confirm removal of specified resource.
*/ */

View File

@ -12,7 +12,7 @@ class Kernel extends HttpKernel
protected $middleware = [ protected $middleware = [
// \App\Http\Middleware\TrustHosts::class, // \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class, \Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class, \App\Http\Middleware\TrimStrings::class,
@ -31,9 +31,15 @@ class Kernel extends HttpKernel
\Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class, \App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
\Spatie\Csp\AddCspHeaders::class,
\App\Http\Middleware\DisableBrowserCache::class,
], ],
'api' => [ 'api' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
'throttle:api', 'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
], ],

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class DisableBrowserCache
{
/**
* Sets a cache control header to disable browser caching.
*
* For some reason `ResponseHeaderBag::computeCacheControlValue` insists on
* making changing to the `Cache-Control` header even though it is modified
* using the `cache.headers` middleware. This middleware removes the header
* entirely and sets it to a value to prevent browser caching.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
* @return mixed
*
* @see \Symfony\Component\HttpFoundation\ResponseHeaderBag::computeCacheControlValue()
*/
public function handle(Request $request, Closure $next, ...$guards)
{
$response = $next($request);
$response->headers->set('Cache-Control', 'no-cache, no-store');
return $response;
}
}

View File

@ -2,8 +2,8 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Fideloper\Proxy\TrustProxies as Middleware;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
class TrustProxies extends Middleware class TrustProxies extends Middleware
{ {
@ -15,5 +15,10 @@ class TrustProxies extends Middleware
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $headers = Request::HEADER_X_FORWARDED_ALL; protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
} }

View File

@ -28,7 +28,11 @@ class StoreJournalEntryRequest extends FormRequest
new InArray(Auth::user()->meals_enabled->pluck('value')->toArray()) new InArray(Auth::user()->meals_enabled->pluck('value')->toArray())
], ],
'ingredients.amount' => ['required', 'array', new ArrayNotEmpty], 'ingredients.amount' => ['required', 'array', new ArrayNotEmpty],
'ingredients.amount.*' => ['required_with:ingredients.id.*', 'nullable', new StringIsPositiveDecimalOrFraction], 'ingredients.amount.*' => [
'required_with:ingredients.id.*',
'nullable',
new StringIsPositiveDecimalOrFraction
],
'ingredients.unit' => ['required', 'array'], 'ingredients.unit' => ['required', 'array'],
'ingredients.unit.*' => ['required_with:ingredients.id.*'], 'ingredients.unit.*' => ['required_with:ingredients.id.*'],
'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable', 'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable',

View File

@ -16,8 +16,7 @@ class UpdateUserRequest extends FormRequest
$rules = [ $rules = [
'username' => ['required', 'string', Rule::unique('users')->ignore($this->user)], 'username' => ['required', 'string', Rule::unique('users')->ignore($this->user)],
'name' => ['required', 'string'], 'name' => ['required', 'string'],
'password' => ['nullable', 'string', 'confirmed'], 'password' => ['nullable', 'string', 'min:8', 'confirmed'],
'password_confirmation' => ['nullable', 'string'],
'admin' => ['nullable', 'boolean'], 'admin' => ['nullable', 'boolean'],
'image' => ['nullable', 'file', 'mimes:jpg,png,gif'], 'image' => ['nullable', 'file', 'mimes:jpg,png,gif'],
'remove_image' => ['nullable', 'boolean'], 'remove_image' => ['nullable', 'boolean'],

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
class FoodSchema extends SchemaProvider class FoodSchema extends SchemaProvider
{ {
@ -10,7 +10,7 @@ class FoodSchema extends SchemaProvider
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $resourceType = 'foods'; protected string $resourceType = 'foods';
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
class GoalSchema extends SchemaProvider class GoalSchema extends SchemaProvider
{ {
@ -10,7 +10,7 @@ class GoalSchema extends SchemaProvider
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $resourceType = 'goals'; protected string $resourceType = 'goals';
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
class IngredientAmountSchema extends SchemaProvider class IngredientAmountSchema extends SchemaProvider
{ {
@ -10,7 +10,7 @@ class IngredientAmountSchema extends SchemaProvider
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $resourceType = 'ingredient-amounts'; protected string $resourceType = 'ingredient-amounts';
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
class JournalEntrySchema extends SchemaProvider class JournalEntrySchema extends SchemaProvider
{ {
@ -10,7 +10,7 @@ class JournalEntrySchema extends SchemaProvider
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $resourceType = 'journal-entries'; protected string $resourceType = 'journal-entries';
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
/** /**
* Media schema. * Media schema.
@ -18,7 +18,7 @@ class MediumSchema extends SchemaProvider
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $resourceType = 'media'; protected string $resourceType = 'media';
/** /**
* {@inheritdoc} * {@inheritdoc}
@ -42,7 +42,7 @@ class MediumSchema extends SchemaProvider
'url' => $resource->getUrl(), 'url' => $resource->getUrl(),
'mimeType' => $resource->mime_type, 'mimeType' => $resource->mime_type,
'size' => $resource->size, 'size' => $resource->size,
'sizeFormatted' => $resource->getHumanReadableSizeAttribute(), 'sizeFormatted' => $resource->human_readable_size,
'manipulations' => $resource->manipulations, 'manipulations' => $resource->manipulations,
'customProperties' => $resource->custom_properties, 'customProperties' => $resource->custom_properties,
'conversions' => [], 'conversions' => [],

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
class RecipeSchema extends SchemaProvider class RecipeSchema extends SchemaProvider
{ {
@ -10,7 +10,7 @@ class RecipeSchema extends SchemaProvider
/** /**
* @var string * @var string
*/ */
protected $resourceType = 'recipes'; protected string $resourceType = 'recipes';
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
class RecipeSeparatorSchema extends SchemaProvider class RecipeSeparatorSchema extends SchemaProvider
{ {
@ -10,7 +10,7 @@ class RecipeSeparatorSchema extends SchemaProvider
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $resourceType = 'recipe-separators'; protected string $resourceType = 'recipe-separators';
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
class RecipeStepSchema extends SchemaProvider class RecipeStepSchema extends SchemaProvider
{ {
@ -10,7 +10,7 @@ class RecipeStepSchema extends SchemaProvider
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $resourceType = 'recipe-steps'; protected string $resourceType = 'recipe-steps';
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
class TagSchema extends SchemaProvider class TagSchema extends SchemaProvider
{ {
@ -10,7 +10,7 @@ class TagSchema extends SchemaProvider
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $resourceType = 'tags'; protected string $resourceType = 'tags';
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -2,7 +2,7 @@
namespace App\JsonApi\Schemas; namespace App\JsonApi\Schemas;
use Neomerx\JsonApi\Schema\SchemaProvider; use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
class UserSchema extends SchemaProvider class UserSchema extends SchemaProvider
{ {
@ -10,7 +10,7 @@ class UserSchema extends SchemaProvider
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $resourceType = 'users'; protected string $resourceType = 'users';
/** /**
* {@inheritdoc} * {@inheritdoc}

View File

@ -7,10 +7,9 @@ use App\Models\Traits\Journalable;
use App\Models\Traits\Sluggable; use App\Models\Traits\Sluggable;
use App\Models\Traits\Taggable; use App\Models\Traits\Taggable;
use App\Support\Number; use App\Support\Number;
use ElasticScoutDriverPlus\QueryDsl; use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
/** /**
* App\Models\Food * App\Models\Food
@ -74,13 +73,13 @@ use Laravel\Scout\Searchable;
* @method static \Illuminate\Database\Eloquent\Builder|Food withUniqueSlugConstraints(\Illuminate\Database\Eloquent\Model $model, string $attribute, array $config, string $slug) * @method static \Illuminate\Database\Eloquent\Builder|Food withUniqueSlugConstraints(\Illuminate\Database\Eloquent\Model $model, string $attribute, array $config, string $slug)
* @method static \Database\Factories\FoodFactory factory(...$parameters) * @method static \Database\Factories\FoodFactory factory(...$parameters)
* @property-read \Illuminate\Support\Collection $units_supported * @property-read \Illuminate\Support\Collection $units_supported
* @property-read string $ingredient_id
*/ */
final class Food extends Model final class Food extends Model
{ {
use HasFactory; use HasFactory;
use Ingredient; use Ingredient;
use Journalable; use Journalable;
use QueryDsl;
use Searchable; use Searchable;
use Sluggable; use Sluggable;
use Taggable; use Taggable;

View File

@ -74,6 +74,8 @@ final class Goal extends Model
'fat' => 'float', 'fat' => 'float',
'protein' => 'float', 'protein' => 'float',
'sodium' => 'float', 'sodium' => 'float',
// @todo Determine why `user_id` is a string and fix it.
'user_id' => 'int',
]; ];
/** /**

View File

@ -9,12 +9,12 @@ use App\Models\Traits\Sluggable;
use App\Models\Traits\Taggable; use App\Models\Traits\Taggable;
use App\Support\Number; use App\Support\Number;
use App\Support\Nutrients; use App\Support\Nutrients;
use ElasticScoutDriverPlus\QueryDsl; use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Laravel\Scout\Searchable; use Illuminate\Support\Facades\DB;
use Spatie\Image\Manipulations; use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
@ -83,6 +83,7 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* @property float|null $volume * @property float|null $volume
* @property-read string|null $volume_formatted * @property-read string|null $volume_formatted
* @method static \Illuminate\Database\Eloquent\Builder|Recipe whereVolume($value) * @method static \Illuminate\Database\Eloquent\Builder|Recipe whereVolume($value)
* @property-read string $ingredient_id
*/ */
final class Recipe extends Model implements HasMedia final class Recipe extends Model implements HasMedia
{ {
@ -91,7 +92,6 @@ final class Recipe extends Model implements HasMedia
use Ingredient; use Ingredient;
use InteractsWithMedia; use InteractsWithMedia;
use Journalable; use Journalable;
use QueryDsl;
use Searchable; use Searchable;
use Sluggable; use Sluggable;
use Taggable; use Taggable;
@ -263,4 +263,55 @@ final class Recipe extends Model implements HasMedia
->optimize(); ->optimize();
} }
/**
* Duplicates the recipe, updating provided attributes.
*
* @throws \Throwable
*/
public function duplicate(array $attributes): Recipe {
/** @var \App\Models\Recipe $recipe */
$recipe = $this->replicate();
$recipe->fill($attributes);
try {
DB::transaction(function () use ($recipe) {
$recipe->save();
$recipe->tags()->attach($this->tags);
$ingredient_amounts = [];
foreach ($this->ingredientAmounts as $ia) {
$new_ia = $ia->replicate();
$new_ia->parent_id = $recipe->id;
$new_ia->parent_type = Recipe::class;
$ingredient_amounts[] = $new_ia;
}
$recipe->ingredientAmounts()->saveMany($ingredient_amounts);
$steps = [];
foreach ($this->steps as $step) {
$new_step = $step->replicate();
$new_step->recipe_id = $recipe->id;
$steps[] = $new_step;
}
$recipe->steps()->saveMany($steps);
$separators = [];
foreach ($this->separators as $separator) {
$new_separator = $separator->replicate();
$new_separator->recipe_id = $recipe->id;
$separators[] = $new_separator;
}
$recipe->separators()->saveMany($separators);
$recipe->push();
});
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
return $recipe;
}
} }

View File

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Spatie\Tags\Tag as TagBase; use Spatie\Tags\Tag as TagBase;
/** /**
@ -31,8 +32,26 @@ use Spatie\Tags\Tag as TagBase;
* @method static \Illuminate\Database\Eloquent\Builder|Tag whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Tag whereUpdatedAt($value)
* @method static Builder|Tag withType(?string $type = null) * @method static Builder|Tag withType(?string $type = null)
* @mixin \Eloquent * @mixin \Eloquent
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Food[] $foods
* @property-read int|null $foods_count
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Recipe[] $recipes
* @property-read int|null $recipes_count
*/ */
final class Tag extends TagBase final class Tag extends TagBase
{ {
use HasFactory; use HasFactory;
/**
* Get all foods related to this tag.
*/
public function foods(): MorphToMany {
return $this->morphedByMany(Food::class, 'taggable');
}
/**
* Get all recipes related to this tag.
*/
public function recipes(): MorphToMany {
return $this->morphedByMany(Recipe::class, 'taggable');
}
} }

View File

@ -8,21 +8,28 @@ use Illuminate\Database\Eloquent\Collection as DatabaseCollection;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Spatie\Tags\Tag; use Spatie\Tags\Tag;
trait Ingredient trait Ingredient
{ {
/** /**
* Add special `type` attribute to appends. * Add special attributes to appends.
*/ */
public function initializeIngredient(): void { public function initializeIngredient(): void {
$this->appends[] = 'ingredient_id';
$this->appends[] = 'type'; $this->appends[] = 'type';
} }
/**
* Gets the class short name and ID combo to ensure uniqueness between models.
*/
public function getIngredientIdAttribute(): string {
return Str::lower((new \ReflectionClass($this))->getShortName()) . "-$this->id";
}
/** /**
* Get the class name. * Get the class name.
*
* This is necessary e.g. to provide data in ingredient picker responses.
*/ */
public function getTypeAttribute(): string { public function getTypeAttribute(): string {
return $this::class; return $this::class;
@ -45,9 +52,9 @@ trait Ingredient
public static function getTagTotals(string $locale = null): DatabaseCollection { public static function getTagTotals(string $locale = null): DatabaseCollection {
$locale = $locale ?? app()->getLocale(); $locale = $locale ?? app()->getLocale();
return Tag::query()->join('taggables', 'taggables.tag_id', '=', 'id') return Tag::query()->join('taggables', 'taggables.tag_id', '=', 'id')
->select(['id', 'name', DB::raw('count(*) as total')]) ->select(['name', DB::raw('count(*) as total')])
->where('taggables.taggable_type', '=', static::class) ->where('taggables.taggable_type', '=', static::class)
->groupBy('id') ->groupBy('name')
->orderBy("name->{$locale}") ->orderBy("name->{$locale}")
->get(); ->get();
} }

View File

@ -4,6 +4,7 @@ namespace App\Models\Traits;
use App\Models\Tag; use App\Models\Tag;
use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Http\Request;
use Spatie\Tags\HasTags; use Spatie\Tags\HasTags;
trait Taggable trait Taggable
@ -27,4 +28,29 @@ trait Taggable
->morphToMany(self::getTagClassName(), 'taggable', 'taggables', null, 'tag_id') ->morphToMany(self::getTagClassName(), 'taggable', 'taggables', null, 'tag_id')
->orderBy('order_column'); ->orderBy('order_column');
} }
/**
* Updates tags from a request with a "tags" parameter value for any bag.
*/
public function updateTagsFromRequest(Request $request): void {
$tags_original = $this->tags;
$tags = $request->get('tags', []);
if (!empty($tags)) {
$this->syncTags(explode(',', $tags));
}
elseif ($this->tags->isNotEmpty()) {
$this->detachTags($this->tags);
}
// Refresh and index updated tags.
$this->refresh()->searchable();
// Delete any removed tags that are no longer in use.
$tags_original->diff($this->tags)->each(function (Tag $tag) {
if ($tag->foods->isEmpty() && $tag->recipes->isEmpty()) {
$tag->delete();
}
});
}
} }

View File

@ -10,7 +10,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -56,6 +56,8 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* @property \Illuminate\Support\Collection|null $meals * @property \Illuminate\Support\Collection|null $meals
* @method static \Illuminate\Database\Eloquent\Builder|User whereMeals($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereMeals($value)
* @property-read Collection $meals_enabled * @property-read Collection $meals_enabled
* @property string|null $api_token
* @method static \Illuminate\Database\Eloquent\Builder|User whereApiToken($value)
*/ */
final class User extends Authenticatable implements HasMedia final class User extends Authenticatable implements HasMedia
{ {
@ -71,6 +73,9 @@ final class User extends Authenticatable implements HasMedia
static::creating(function (User $user) { static::creating(function (User $user) {
// Set default meals configuration. // Set default meals configuration.
$user->meals = User::getDefaultMeals(); $user->meals = User::getDefaultMeals();
// Set default API token.
$user->api_token = Str::random(32);
}); });
} }
@ -78,17 +83,19 @@ final class User extends Authenticatable implements HasMedia
* @inheritdoc * @inheritdoc
*/ */
protected $fillable = [ protected $fillable = [
'username',
'password',
'name',
'meals',
'admin', 'admin',
'api_token',
'meals',
'name',
'password',
'username',
]; ];
/** /**
* @inheritdoc * @inheritdoc
*/ */
protected $hidden = [ protected $hidden = [
'api_token',
'password', 'password',
'remember_token', 'remember_token',
]; ];

View File

@ -0,0 +1,20 @@
<?php
namespace App\Policies;
use App\Models\Goal;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class GoalPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can access (show, edit, delete) the goal.
*/
public function access(User $user, Goal $goal): bool {
return $user->id === $goal->user_id;
}
}

View File

@ -2,7 +2,9 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Goal;
use App\Models\User; use App\Models\User;
use App\Policies\GoalPolicy;
use App\Policies\UserPolicy; use App\Policies\UserPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@ -13,6 +15,7 @@ class AuthServiceProvider extends ServiceProvider
* @inheritdoc * @inheritdoc
*/ */
protected $policies = [ protected $policies = [
Goal::class => GoalPolicy::class,
User::class => UserPolicy::class, User::class => UserPolicy::class,
]; ];

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services\Csp\Policies;
use Spatie\Csp\Directive;
use Spatie\Csp\Keyword;
use Spatie\Csp\Policies\Policy;
use Spatie\Csp\Scheme;
/**
* Default CSP policy configuration for the application.
*
* @see \Spatie\Csp\Policies\Basic
*/
class DefaultPolicy extends Policy
{
public function configure(): void
{
$this
->addDirective(Directive::BASE, Keyword::SELF)
->addDirective(Directive::CONNECT, Keyword::SELF)
->addDirective(Directive::DEFAULT, Keyword::SELF)
->addDirective(Directive::FORM_ACTION, Keyword::SELF)
->addDirective(Directive::IMG, [Keyword::SELF, Keyword::UNSAFE_INLINE, Scheme::DATA])
->addDirective(Directive::MEDIA, Keyword::SELF)
->addDirective(Directive::OBJECT, Keyword::NONE)
->addDirective(Directive::SCRIPT, [Keyword::SELF, Keyword::UNSAFE_EVAL, Keyword::UNSAFE_INLINE])
->addDirective(Directive::STYLE, [Keyword::SELF, Keyword::UNSAFE_INLINE])
->addDirective(Directive::FRAME, Keyword::NONE);
}
}

View File

@ -4,48 +4,50 @@
"description": "kcal the personal food nutrition journal", "description": "kcal the personal food nutrition journal",
"license": "MPL-2.0", "license": "MPL-2.0",
"require": { "require": {
"php": "^8.0", "php": "^8.2",
"ext-fileinfo": "*", "ext-fileinfo": "*",
"ext-gd": "*", "ext-gd": "*",
"ext-json": "*", "ext-json": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"algolia/algoliasearch-client-php": "^2.7", "algolia/algoliasearch-client-php": "^3.2",
"algolia/scout-extended": "^1.15", "algolia/scout-extended": "^3.0",
"babenkoivan/elastic-migrations": "^1.4", "babenkoivan/elastic-migrations": "^3.0",
"babenkoivan/elastic-scout-driver": "^1.3", "babenkoivan/elastic-scout-driver": "^3.0",
"babenkoivan/elastic-scout-driver-plus": "^2.0", "babenkoivan/elastic-scout-driver-plus": "^4.0",
"cloudcreativity/laravel-json-api": "^3.2", "cloudcreativity/laravel-json-api": "^6.0",
"cviebrock/eloquent-sluggable": "^8.0", "cviebrock/eloquent-sluggable": "^10.0",
"doctrine/dbal": "^3.0", "doctrine/dbal": "^3.0",
"fideloper/proxy": "^4.4",
"fruitcake/laravel-cors": "^2.0",
"guzzlehttp/guzzle": "^7.0.1", "guzzlehttp/guzzle": "^7.0.1",
"laravel/framework": "^8.12", "laravel/framework": "^10.0",
"laravel/scout": "^8.6", "laravel/scout": "^10.0",
"laravel/tinker": "^2.5", "laravel/tinker": "^2.7",
"league/flysystem-aws-s3-v3": "~1.0", "league/flysystem-aws-s3-v3": "^3.0",
"phospr/fraction": "^1.2", "phospr/fraction": "^1.2",
"spatie/laravel-medialibrary": "^9.0.0", "spatie/laravel-csp": "^2.6",
"spatie/laravel-tags": "^3.0" "spatie/laravel-medialibrary": "^10.0",
"spatie/laravel-tags": "^4.0"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-ide-helper": "^2.9", "barryvdh/laravel-ide-helper": "^2.9",
"brianium/paratest": "^6.2", "brianium/paratest": "^6.2",
"cloudcreativity/json-api-testing": "^3.2", "cloudcreativity/json-api-testing": "^5.0",
"facade/ignition": "^2.5",
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.9.1",
"laravel/breeze": "^1.0", "laravel/breeze": "^1.0",
"laravel/sail": "^0.0.5", "laravel/sail": "^1.10",
"mockery/mockery": "^1.4.2", "mockery/mockery": "^1.4.2",
"nunomaduro/collision": "^5.0", "nunomaduro/collision": "^6.1",
"nunomaduro/larastan": "^0.6.13", "nunomaduro/larastan": "^2.0",
"php-coveralls/php-coveralls": "^2.4", "php-coveralls/php-coveralls": "^2.4",
"phpunit/phpunit": "^9.3.3" "phpunit/phpunit": "^9.3.3",
"spatie/laravel-ignition": "^2.0"
}, },
"config": { "config": {
"optimize-autoloader": true, "optimize-autoloader": true,
"preferred-install": "dist", "preferred-install": "dist",
"sort-packages": true "sort-packages": true,
"allow-plugins": {
"php-http/discovery": true
}
}, },
"extra": { "extra": {
"laravel": { "laravel": {

7764
composer.lock generated

File diff suppressed because it is too large Load Diff

34
config/csp.php Normal file
View File

@ -0,0 +1,34 @@
<?php
return [
/*
* A policy will determine which CSP headers will be set. A valid CSP policy is
* any class that extends `Spatie\Csp\Policies\Policy`
*/
'policy' => App\Services\Csp\Policies\DefaultPolicy::class,
/*
* This policy which will be put in report only mode. This is great for testing out
* a new policy or changes to existing csp policy without breaking anything.
*/
'report_only_policy' => '',
/*
* All violations against the policy will be reported to this url.
* A great service you could use for this is https://report-uri.com/
*
* You can override this setting by calling `reportTo` on your policy.
*/
'report_uri' => env('CSP_REPORT_URI', ''),
/*
* Headers will only be added if this setting is set to true.
*/
'enabled' => env('CSP_ENABLED', true),
/*
* The class responsible for generating the nonces used in inline tags and headers.
*/
'nonce_generator' => Spatie\Csp\Nonce\RandomString::class,
];

1
database/.gitignore vendored
View File

@ -1,2 +1,3 @@
*.sqlite *.sqlite
*.sqlite_test*
*.sqlite-journal *.sqlite-journal

View File

@ -22,12 +22,12 @@ class RecipeFactory extends Factory
*/ */
public function definition(): array public function definition(): array
{ {
$description = htmlspecialchars($this->faker->realText(500)); $description = $this->faker->realText(500);
$volumes = [1/4, 1/3, 1/2, 2/3, 3/4, 1, 1 + 1/2, 1 + 3/4, 2, 2 + 1/2, 3, 3 + 1/2, 4, 5]; $volumes = [1/4, 1/3, 1/2, 2/3, 3/4, 1, 1 + 1/2, 1 + 3/4, 2, 2 + 1/2, 3, 3 + 1/2, 4, 5];
return [ return [
'name' => Words::randomWords(Arr::random(['npan', 'npn', 'anpn'])), 'name' => Words::randomWords(Arr::random(['npan', 'npn', 'anpn'])),
'description' => "<p>{$description}</p>", 'description' => "<p>{$description}</p>",
'description_delta' => '{"ops":[{"insert":"' . htmlentities($description) . '\n"}]}"', 'description_delta' => '{"ops":[{"insert":"' . $description . '\n"}]}',
'time_prep' => $this->faker->numberBetween(0, 20), 'time_prep' => $this->faker->numberBetween(0, 20),
'time_cook' => $this->faker->numberBetween(0, 90), 'time_cook' => $this->faker->numberBetween(0, 90),
'source' => $this->faker->optional()->url, 'source' => $this->faker->optional()->url,

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class FixDecimalFieldsPrecision extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('foods', function (Blueprint $table) {
$table->decimal('serving_size', 14, 8)->unsigned()->change();
});
Schema::table('recipes', function (Blueprint $table) {
$table->decimal('volume', 14, 8)->unsigned()->nullable()->change();
});
Schema::table('ingredient_amounts', function (Blueprint $table) {
$table->decimal('amount', 14, 8)->unsigned()->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('foods', function (Blueprint $table) {
$table->decimal('serving_size', 10, 8)->unsigned()->change();
});
Schema::table('recipes', function (Blueprint $table) {
$table->decimal('volume', 10, 8)->unsigned()->nullable()->change();
});
Schema::table('ingredient_amounts', function (Blueprint $table) {
$table->decimal('amount', 10, 8)->unsigned()->change();
});
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddApiTokenToUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->char('api_token', 32)->unique()->nullable()->after('password');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('api_token');
});
}
}

View File

@ -1,86 +1,100 @@
# For more information: https://laravel.com/docs/sail
version: '3'
services: services:
app: app:
build: build:
context: ./vendor/laravel/sail/runtimes/8.0 context: ./vendor/laravel/sail/runtimes/8.2
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
WWWGROUP: '${WWWGROUP}' WWWGROUP: '${WWWGROUP:-1000}'
image: sail-8.0/app image: sail-8.2/app
ports: extra_hosts:
- '${APP_PORT:-8080}:80' - 'host.docker.internal:host-gateway'
environment: ports:
WWWUSER: '${WWWUSER}' - '${APP_PORT:-80}:80'
LARAVEL_SAIL: 1 - '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
volumes: environment:
- '.:/var/www/html' WWWUSER: '${WWWUSER:-1000}'
networks: LARAVEL_SAIL: 1
- sail XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
depends_on: XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
- db IGNITION_LOCAL_SITES_PATH: '${PWD}'
- redis volumes:
- elasticsearch - '.:/var/www/html'
db: networks:
image: 'mysql:8.0' - sail
ports: depends_on:
- '${DB_PORT:-3306}:3306' - db
environment: - redis
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}' db:
MYSQL_DATABASE: '${DB_DATABASE:-kcal}' image: 'mariadb:10'
MYSQL_USER: '${DB_USERNAME:-kcal}' ports:
MYSQL_PASSWORD: '${DB_PASSWORD:-kcal}' - '${FORWARD_DB_PORT:-3306}:3306'
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' environment:
volumes: MARIADB_DATABASE: '${DB_DATABASE:-kcal}'
- 'mysql-data:/var/lib/mysql' MARIADB_PASSWORD: '${DB_PASSWORD:-kcal}'
networks: MARIADB_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'
- sail MARIADB_USER: '${DB_USERNAME:-kcal}'
phpmyadmin: volumes:
image: phpmyadmin - 'db-data:/var/lib/mysql'
ports: - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
- 8081:80 networks:
environment: - sail
PMA_HOST: db healthcheck:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}' test:
networks: - CMD
- sail - mysqladmin
depends_on: - ping
- db - '-p${DB_PASSWORD}'
elasticsearch: retries: 3
image: 'elasticsearch:7.12.0' timeout: 5s
environment: redis:
- xpack.security.enabled=false image: 'redis:alpine'
- discovery.type=single-node ports:
ulimits: - '${FORWARD_REDIS_PORT:-6379}:6379'
memlock: volumes:
soft: -1 - 'redis-data:/data'
hard: -1 networks:
nofile: - sail
soft: 65536 healthcheck:
hard: 65536 test:
cap_add: - CMD
- IPC_LOCK - redis-cli
volumes: - ping
- 'elasticsearch-data:/usr/share/elasticsearch/data' retries: 3
ports: timeout: 5s
- '${ELASTIC_PORT:-9200}:9200' elasticsearch:
networks: image: 'elasticsearch:7.17.17'
- sail environment:
redis: - xpack.security.enabled=false
image: 'redis:alpine' - discovery.type=single-node
ports: deploy:
- '${REDIS_PORT:-6379}:6379' resources:
volumes: limits:
- 'redis-data:/data' memory: 1G
networks: volumes:
- sail - 'elasticsearch-data:/usr/share/elasticsearch/data'
ports:
- '${ELASTIC_PORT:-9200}:9200'
- '${ELASTIC_BIN_PORT:-9300}:9300'
networks:
- sail
phpmyadmin:
image: phpmyadmin
ports:
- '8081:80'
environment:
PBA_HOST: db
PMA_PORT: '${FORWARD_DB_PORT:-3306}:3306'
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'
networks:
- sail
depends_on:
- db
networks: networks:
sail: sail:
driver: bridge driver: bridge
volumes: volumes:
elasticsearch-data: db-data:
driver: local driver: local
mysql-data: redis-data:
driver: local driver: local
redis-data: elasticsearch-data:
driver: local

View File

@ -1,10 +1,10 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use ElasticAdapter\Indices\Mapping; use Elastic\Adapter\Indices\Mapping;
use ElasticAdapter\Indices\Settings; use Elastic\Adapter\Indices\Settings;
use ElasticMigrations\Facades\Index; use Elastic\Migrations\Facades\Index;
use ElasticMigrations\MigrationInterface; use Elastic\Migrations\MigrationInterface;
final class CreateFoodsIndex implements MigrationInterface final class CreateFoodsIndex implements MigrationInterface
{ {

View File

@ -1,10 +1,10 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use ElasticAdapter\Indices\Mapping; use Elastic\Adapter\Indices\Mapping;
use ElasticAdapter\Indices\Settings; use Elastic\Adapter\Indices\Settings;
use ElasticMigrations\Facades\Index; use Elastic\Migrations\Facades\Index;
use ElasticMigrations\MigrationInterface; use Elastic\Migrations\MigrationInterface;
final class CreateRecipesIndex implements MigrationInterface final class CreateRecipesIndex implements MigrationInterface
{ {

26362
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,22 +10,21 @@
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --config=node_modules/laravel-mix/setup/webpack.config.js" "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --config=node_modules/laravel-mix/setup/webpack.config.js"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.3.2", "@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.4.1", "@tailwindcss/typography": "^0.5.13",
"alpinejs": "^2.8.2", "alpinejs": "^3.13.10",
"autoprefixer": "^10.2.6", "autoprefixer": "^10.4.19",
"axios": "^0.21.1", "axios": "^1.6.8",
"cross-env": "^7.0", "cross-env": "^7.0",
"laravel-mix": "^6.0.19", "laravel-mix": "^6.0.49",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"postcss-import": "^14.0.2", "postcss-import": "^16.1.0",
"quill": "^1.3.7", "quill": "^2.0.1",
"resolve-url-loader": "^4.0.0", "resolve-url-loader": "^5.0.0",
"tailwindcss": "^2.1.2", "tailwindcss": "^3.4.3",
"vue-template-compiler": "^2.6.12" "vue-template-compiler": "^2.7.16"
}, },
"dependencies": { "dependencies": {
"@shopify/draggable": "^1.0.0-beta.12", "@shopify/draggable": "^1.1.3"
"alpine-magic-helpers": "^1.2.2"
} }
} }

View File

@ -19,6 +19,7 @@
</coverage> </coverage>
<php> <php>
<server name="APP_ENV" value="testing"/> <server name="APP_ENV" value="testing"/>
<server name="DB_USERNAME" value="root"/>
<server name="BCRYPT_ROUNDS" value="4"/> <server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/> <server name="CACHE_DRIVER" value="array"/>
<server name="MAIL_MAILER" value="array"/> <server name="MAIL_MAILER" value="array"/>
@ -26,9 +27,5 @@
<server name="SESSION_DRIVER" value="elastic"/> <server name="SESSION_DRIVER" value="elastic"/>
<server name="SESSION_DRIVER" value="array"/> <server name="SESSION_DRIVER" value="array"/>
<server name="TELESCOPE_ENABLED" value="false"/> <server name="TELESCOPE_ENABLED" value="false"/>
<!-- @todo Figure out how to do MySQL parallel testing inside Sail. -->
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
</php> </php>
</phpunit> </phpunit>

2
public/css/app.css vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
public/js/app.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,12 @@
/*!
* The buffer module from node.js, for the browser.
*
* @author Feross Aboukhadijeh <http://feross.org>
* @license MIT
*/
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
/** /**
* @license * @license
* Lodash <https://lodash.com/> * Lodash <https://lodash.com/>

File diff suppressed because one or more lines are too long

3
public/js/quill.js vendored

File diff suppressed because one or more lines are too long

3
resources/js/app.js vendored
View File

@ -1,4 +1 @@
require('./bootstrap'); require('./bootstrap');
require('alpine-magic-helpers');
require('alpinejs');

View File

@ -1,28 +1,11 @@
// Load Lodash.
window._ = require('lodash'); window._ = require('lodash');
/** // Load Axios.
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
window.axios = require('axios'); window.axios = require('axios');
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/** // Load AlpineJS.
* Echo exposes an expressive API for subscribing to channels and listening import Alpine from 'alpinejs';
* for events that are broadcast by Laravel. Echo and event broadcasting window.Alpine = Alpine;
* allows your team to easily build robust real-time web applications. Alpine.start();
*/
// import Echo from 'laravel-echo';
// window.Pusher = require('pusher-js');
// window.Echo = new Echo({
// broadcaster: 'pusher',
// key: process.env.MIX_PUSHER_APP_KEY,
// cluster: process.env.MIX_PUSHER_APP_CLUSTER,
// forceTLS: true
// });

View File

@ -1,7 +1,7 @@
@props(['status']) @props(['status'])
@if ($status) @if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}> <div {{ $attributes->merge(['class' => 'font-medium text-sm text-emerald-600']) }}>
{{ $status }} {{ $status }}
</div> </div>
@endif @endif

View File

@ -1,3 +1,3 @@
<x-button-link.base :attributes="$attributes" class="text-white bg-green-800 hover:bg-green-700 active:bg-green-900 focus:border-green-900 ring-green-300"> <x-button-link.base :attributes="$attributes" class="text-white bg-emerald-800 hover:bg-emerald-700 active:bg-emerald-900 focus:border-emerald-900 ring-emerald-300">
{{ $slot }} {{ $slot }}
</x-button-link.base> </x-button-link.base>

View File

@ -21,18 +21,18 @@ switch ($width) {
} }
@endphp @endphp
<div class="relative" x-data="{ open: false }" @click.away="open = false" @close.stop="open = false"> <div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
<div @click="open = ! open"> <div @click="open = ! open">
{{ $trigger }} {{ $trigger }}
</div> </div>
<div x-show="open" <div x-show="open"
x-transition:enter="transition ease-out duration-200" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75" x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95" x-transition:leave-end="opacity-0 scale-95"
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}" class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
style="display: none;" style="display: none;"
@click="open = false"> @click="open = false">

View File

@ -20,13 +20,13 @@
autocapitalize="none" autocapitalize="none"
inputmode="search" inputmode="search"
x-ref="ingredients_name" x-ref="ingredients_name"
x-spread="search" /> x-bind="search" />
</div> </div>
<div x-show="searching" x-cloak> <div x-show="searching" x-cloak>
<div class="absolute border-2 border-gray-500 border-b-0 bg-white" <div class="absolute border-2 border-gray-500 border-b-0 bg-white"
x-spread="ingredient"> x-bind="ingredient">
<template x-for="result in results" :key="result.id"> <template x-for="result in results" :key="result.ingredient_id">
<div class="p-1 border-b-2 border-gray-500 hover:bg-yellow-300 cursor-pointer" x-bind:data-id="result.id"> <div class="p-1 border-b-2 border-gray-500 hover:bg-amber-300 cursor-pointer" x-bind:data-id="result.id">
<div class="pointer-events-none"> <div class="pointer-events-none">
<div> <div>
<span class="font-bold" x-text="result.name"></span><span class="text-gray-600" x-text="', ' + result.detail" x-show="result.detail"></span> <span class="font-bold" x-text="result.name"></span><span class="text-gray-600" x-text="', ' + result.detail" x-show="result.detail"></span>

View File

@ -1,3 +1,3 @@
<button {{ $attributes->merge(['type' => 'submit', 'class' => "inline-flex items-center border border-transparent rounded-md font-semibold text-xs text-green-500 tracking-widest hover:text-green-700 active:text-green-900 focus:outline-none focus:border-green-900 focus:ring ring-green-300 disabled:opacity-25 transition ease-in-out duration-150"]) }}> <button {{ $attributes->merge(['type' => 'submit', 'class' => "inline-flex items-center border border-transparent rounded-md font-semibold text-xs text-emerald-500 tracking-widest hover:text-emerald-700 active:text-emerald-900 focus:outline-none focus:border-emerald-900 focus:ring ring-emerald-300 disabled:opacity-25 transition ease-in-out duration-150"]) }}>
{{ $slot }} {{ $slot }}
</button> </button>

View File

@ -0,0 +1,18 @@
<form class="border-2 border-black p-2" method="POST" action="{{ route('journal-entries.store') }}">
@csrf
<input type="hidden" name="ingredients[date][0]" id="date" value="{{ now()->format('Y-m-d') }}">
<input type="hidden" name="ingredients[amount][0]" value="1">
<input type="hidden" name="ingredients[id][0]" value="{{ $journalable->getKey() }}">
<input type="hidden" name="ingredients[type][0]" value="{{$journalable::class}}">
<input type="hidden" name="ingredients[unit][0]" value="serving">
<x-inputs.select class="px-4 py-1 my-2 w-full" name="ingredients[meal][0]"
:options="Auth::user()->meals_enabled->toArray()"
:selectedValue="old('meal')"
:hasError="$errors->has('meal')"
required>
<option value=""></option>
</x-inputs.select>
<button class="px-4 py-2 w-full border border-transparent rounded-md font-semibold text-xs text-center uppercase tracking-widest focus:outline-none focus:ring disabled:opacity-25 transition ease-in-out duration-150 cursor-pointer text-white bg-green-800 hover:bg-green-700 active:bg-green-900 focus:border-green-900 ring-green-300" type="submit">
Log
</button>
</form>

View File

@ -1,4 +1,4 @@
<div x-data="searchView()" x-init="loadMore()"> <div x-data="searchView">
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0"> <div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
<nav class="md:w-1/4"> <nav class="md:w-1/4">
<x-inputs.input name="search" <x-inputs.input name="search"
@ -41,7 +41,7 @@
class="bg-blue-800 hover:bg-blue-700 active:bg-blue-900 focus:border-blue-900 ring-blue-300" class="bg-blue-800 hover:bg-blue-700 active:bg-blue-900 focus:border-blue-900 ring-blue-300"
x-show="morePages" x-show="morePages"
x-cloak x-cloak
@click.prevent="loadMore()"> @click.prevent="loadMore">
Load more Load more
</x-inputs.button> </x-inputs.button>
</section> </section>
@ -51,8 +51,8 @@
@once @once
@push('scripts') @push('scripts')
<script type="text/javascript"> <script type="text/javascript">
let searchView = () => { document.addEventListener('alpine:init', () => {
return { Alpine.data('searchView', () => ({
results: [], results: [],
number: 1, number: 1,
size: 12, size: 12,
@ -60,6 +60,9 @@
searchTerm: null, searchTerm: null,
searching: false, searching: false,
filterTags: [], filterTags: [],
init() {
this.loadMore();
},
resetPagination() { resetPagination() {
this.number = 1; this.number = 1;
this.morePages = false; this.morePages = false;
@ -109,8 +112,8 @@
} }
this.loadMore(); this.loadMore();
} }
} }))
} })
</script> </script>
@endpush @endpush
@endonce @endonce

View File

@ -1,5 +1,5 @@
<div x-data data-tags="{{ $defaultTags ?? '[]' }}"> <div x-data data-tags="{!! $defaultTags ?? '[]' !!}">
<div x-data="tagSelect()" x-init="init('parentEl')" @click.away="clearSearch()" @keydown.escape="clearSearch()"> <div x-data="tagSelect()" @click.outside="clearSearch()" @keydown.escape="clearSearch()">
<div class="relative" @keydown.enter.prevent="addTag(searchTerm)"> <div class="relative" @keydown.enter.prevent="addTag(searchTerm)">
<x-inputs.input type="hidden" <x-inputs.input type="hidden"
name="tags" name="tags"

View File

@ -25,7 +25,7 @@
@endif @endif
@if(!$food->ingredientAmountRelationships->isEmpty()) @if(!$food->ingredientAmountRelationships->isEmpty())
<div class="flex space-x-2 items-center text-lg"> <div class="flex space-x-2 items-center text-lg">
<div class="text-yellow-500"> <div class="text-amber-500">
<svg class="h-8 w-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-8 w-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd" />
</svg> </svg>

View File

@ -10,7 +10,7 @@
</x-slot> </x-slot>
<x-search-view :route="route('api:v1:foods.index')" :tags="$tags"> <x-search-view :route="route('api:v1:foods.index')" :tags="$tags">
<x-slot name="results"> <x-slot name="results">
<template x-for="food in results" :key="food"> <template x-for="food in results" :key="food.slug">
<article class="p-1 border-2 border-black font-sans"> <article class="p-1 border-2 border-black font-sans">
<h1 class="text-2xl lowercase font-extrabold leading-none"> <h1 class="text-2xl lowercase font-extrabold leading-none">
<a x-bind:href="food.showUrl" <a x-bind:href="food.showUrl"

View File

@ -100,6 +100,7 @@
</div> </div>
</section> </section>
<section class="flex flex-row space-x-2 justify-around md:flex-col md:space-y-2 md:space-x-0"> <section class="flex flex-row space-x-2 justify-around md:flex-col md:space-y-2 md:space-x-0">
<x-log-journalable :journalable="$food"></x-log-journalable>
<x-button-link.gray href="{{ route('foods.edit', $food) }}"> <x-button-link.gray href="{{ route('foods.edit', $food) }}">
Edit Food Edit Food
</x-button-link.gray> </x-button-link.gray>

View File

@ -10,14 +10,14 @@
</x-slot> </x-slot>
<form method="POST" action="{{ route('journal-entries.store') }}"> <form method="POST" action="{{ route('journal-entries.store') }}">
@csrf @csrf
<div x-data x-init="initJournalEntries($el);" class="space-y-4"> <div x-data x-ref="root" x-init="initJournalEntries($refs.root)" class="space-y-4">
@foreach($ingredients as $ingredient) @foreach($ingredients as $ingredient)
@include('journal-entries.partials.entry-item-input', $ingredient) @include('journal-entries.partials.entry-item-input', $ingredient)
@endforeach @endforeach
<div class="journal-entry-template hidden"> <div class="journal-entry-template hidden">
@include('journal-entries.partials.entry-item-input', ['default_date' => $default_date]) @include('journal-entries.partials.entry-item-input', ['default_date' => $default_date])
</div> </div>
<x-inputs.icon-green type="button" class="add-entry-item" x-on:click="addEntryNode($el);"> <x-inputs.icon-green type="button" class="add-entry-item" x-on:click="addEntryNode($refs.root);">
<svg class="h-10 w-10 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-10 w-10 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clip-rule="evenodd" />
</svg> </svg>
@ -27,7 +27,7 @@
<x-inputs.input type="checkbox" name="group_entries" class="h-5 w-5" value="1" /> <x-inputs.input type="checkbox" name="group_entries" class="h-5 w-5" value="1" />
<span class="ml-2">Group entries by day and meal</span> <span class="ml-2">Group entries by day and meal</span>
</x-inputs.label> </x-inputs.label>
<x-inputs.button x-on:click="removeTemplate($el);">Add entries</x-inputs.button> <x-inputs.button x-on:click="removeTemplate($refs.root);">Add entries</x-inputs.button>
</div> </div>
</div> </div>
</form> </form>

View File

@ -14,12 +14,12 @@
</a> </a>
</div> </div>
<div class="text-base text-gray-500"> <div class="text-base text-gray-500">
<form x-data method="GET" action="{{ route('journal-entries.index') }}"> <form x-data x-ref="root" method="GET" action="{{ route('journal-entries.index') }}">
<x-inputs.input name="date" <x-inputs.input name="date"
type="date" type="date"
class="border-0 shadow-none p-0 text-center" class="border-0 shadow-none p-0 text-center"
:value="$date->toDateString()" :value="$date->toDateString()"
x-on:change="$el.submit();" x-on:change="$refs.root.submit();"
required /> required />
</form> </form>
</div> </div>
@ -125,7 +125,7 @@
:selectedValue="$currentGoal?->id ?? null"> :selectedValue="$currentGoal?->id ?? null">
</x-inputs.select> </x-inputs.select>
<div class="flex items-center justify-start mt-4"> <div class="flex items-center justify-start mt-4">
<x-inputs.button class="bg-green-800 hover:bg-green-700">Change Goal</x-inputs.button> <x-inputs.button class="bg-emerald-800 hover:bg-emerald-700">Change Goal</x-inputs.button>
<x-button-link.red class="ml-3" x-on:click="showGoalChangeForm = !showGoalChangeForm"> <x-button-link.red class="ml-3" x-on:click="showGoalChangeForm = !showGoalChangeForm">
Cancel Cancel
</x-button-link.red> </x-button-link.red>

View File

@ -41,7 +41,7 @@
<!-- Page Content --> <!-- Page Content -->
<main> <main>
@if(session()->has('message')) @if(session()->has('message'))
<div class="bg-green-200 p-2 mb-2"> <div class="bg-emerald-200 p-2 mb-2">
{{ session()->get('message') }} {{ session()->get('message') }}
</div> </div>
@endif @endif

View File

@ -35,9 +35,9 @@
<x-dropdown-link :href="route('users.index')">Manage Users</x-dropdown-link> <x-dropdown-link :href="route('users.index')">Manage Users</x-dropdown-link>
@endcan @endcan
<hr /> <hr />
<form method="POST" action="{{ route('logout') }}" x-data> <form method="POST" action="{{ route('logout') }}" x-data x-ref="root">
@csrf @csrf
<x-dropdown-link :href="route('logout')" @click.prevent="$el.closest('form').submit();">Logout</x-dropdown-link> <x-dropdown-link :href="route('logout')" @click.prevent="$refs.root.closest('form').submit();">Logout</x-dropdown-link>
</form> </form>
</div> </div>
</x-slot> </x-slot>

View File

@ -18,7 +18,10 @@
</a> </a>
</div> </div>
@endif @endif
<div class="mt-2 text-gray-500"> <div class="mt-2">
{{ $user->name }} <p class="mt-2">{{ $user->name }}</p>
@if($user->id === Auth::user()->id)
<p class="mt-2"><strong>API key</strong>: {{ $user->api_token }}</p>
@endif
</div> </div>
</x-app-layout> </x-app-layout>

View File

@ -13,7 +13,7 @@
<div class="flex flex-col space-y-2 mt-2 text-lg"> <div class="flex flex-col space-y-2 mt-2 text-lg">
@if(!$recipe->ingredientAmountRelationships->isEmpty()) @if(!$recipe->ingredientAmountRelationships->isEmpty())
<div class="flex space-x-2 items-center text-lg"> <div class="flex space-x-2 items-center text-lg">
<div class="text-yellow-500"> <div class="text-amber-500">
<svg class="h-8 w-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-8 w-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd" />
</svg> </svg>

View File

@ -0,0 +1,30 @@
<x-app-layout>
<x-slot name="title">Duplicate {{ $recipe->name }}</x-slot>
<x-slot name="header">
<h1 class="font-semibold text-xl text-gray-800 leading-tight">
Duplicate {{ $recipe->name }}?
</h1>
</x-slot>
<form method="POST" action="{{ route('recipes.duplicate', $recipe) }}">
@csrf
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
<!-- Name -->
<div class="flex-auto">
<x-inputs.label for="name" value="New recipe name" />
<x-inputs.input name="name"
type="text"
class="block mt-1 w-full"
:value="old('name', 'Copy of ' . $recipe->name)"
required />
</div>
</div>
<div class="flex items-center justify-start mt-4">
<x-inputs.button class="bg-red-800 hover:bg-red-700">
Duplicate
</x-inputs.button>
<a class="ml-3 text-gray-500 hover:text-gray-700" href="{{ route('recipes.show', $recipe) }}">
Cancel</a>
</div>
</form>
</x-app-layout>

View File

@ -4,7 +4,7 @@
<x-slot name="header"> <x-slot name="header">
<h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h1> <h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h1>
</x-slot> </x-slot>
<form x-data method="POST" enctype="multipart/form-data" action="{{ ($recipe->exists ? route('recipes.update', $recipe) : route('recipes.store')) }}"> <form x-data x-ref="root" method="POST" enctype="multipart/form-data" action="{{ ($recipe->exists ? route('recipes.update', $recipe) : route('recipes.store')) }}">
@if ($recipe->exists)@method('put')@endif @if ($recipe->exists)@method('put')@endif
@csrf @csrf
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0"> <div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
@ -114,7 +114,7 @@
<!-- Ingredients --> <!-- Ingredients -->
<h3 class="mt-6 mb-2 font-extrabold text-lg">Ingredients</h3> <h3 class="mt-6 mb-2 font-extrabold text-lg">Ingredients</h3>
<div x-data class="ingredients space-y-4"> <div x-data x-ref="ingredients" class="ingredients space-y-4">
@forelse($ingredients_list->sortBy('weight') as $item) @forelse($ingredients_list->sortBy('weight') as $item)
@if($item['type'] === 'ingredient') @if($item['type'] === 'ingredient')
@include('recipes.partials.ingredient-input', $item) @include('recipes.partials.ingredient-input', $item)
@ -133,20 +133,20 @@
</div> </div>
</div> </div>
<x-inputs.button type="button" <x-inputs.button type="button"
class="bg-green-800 hover:bg-green-700 active:bg-green-900 focus:border-green-900 ring-green-300" class="bg-emerald-800 hover:bg-emerald-700 active:bg-emerald-900 focus:border-emerald-900 ring-emerald-300"
x-on:click="addNodeFromTemplate($el, 'ingredient');"> x-on:click="addNodeFromTemplate($refs.ingredients, 'ingredient');">
Add Ingredient Add Ingredient
</x-inputs.button> </x-inputs.button>
<x-inputs.button type="button" <x-inputs.button type="button"
class="bg-blue-800 hover:bg-blue-700 active:bg-blue-900 focus:border-blue-900 ring-blue-300" class="bg-blue-800 hover:bg-blue-700 active:bg-blue-900 focus:border-blue-900 ring-blue-300"
x-on:click="addNodeFromTemplate($el, 'separator');"> x-on:click="addNodeFromTemplate($refs.ingredients, 'separator');">
Add Separator Add Separator
</x-inputs.button> </x-inputs.button>
</div> </div>
<!-- Steps --> <!-- Steps -->
<h3 class="mt-6 mb-2 font-extrabold text-lg">Steps</h3> <h3 class="mt-6 mb-2 font-extrabold text-lg">Steps</h3>
<div x-data class="steps"> <div x-data x-ref="steps" class="steps">
@forelse($steps as $step) @forelse($steps as $step)
@include('recipes.partials.step-input', $step) @include('recipes.partials.step-input', $step)
@empty @empty
@ -158,14 +158,14 @@
</div> </div>
</div> </div>
<x-inputs.button type="button" <x-inputs.button type="button"
class="bg-green-800 hover:bg-green-700 active:bg-green-900 focus:border-green-900 ring-green-300" class="bg-emerald-800 hover:bg-emerald-700 active:bg-emerald-900 focus:border-emerald-900 ring-emerald-300"
x-on:click="addNodeFromTemplate($el, 'step');"> x-on:click="addNodeFromTemplate($refs.steps, 'step');">
Add Step Add Step
</x-inputs.button> </x-inputs.button>
</div> </div>
<div class="flex items-center justify-end mt-4"> <div class="flex items-center justify-end mt-4">
<x-inputs.button x-on:click="prepareForm($el);" class="ml-3"> <x-inputs.button x-on:click="prepareForm($refs.root);" class="ml-3">
{{ ($recipe->exists ? 'Save' : 'Add') }} {{ ($recipe->exists ? 'Save' : 'Add') }}
</x-inputs.button> </x-inputs.button>
</div> </div>
@ -187,11 +187,11 @@
<script type="text/javascript"> <script type="text/javascript">
// Enforce inline (style-base) alignment. // Enforce inline (style-base) alignment.
const AlignStyle = Quill.import('attributors/style/align'); const AlignStyle = Quill.default.import('attributors/style/align');
Quill.register(AlignStyle, true); Quill.default.register(AlignStyle, true);
// Activate Quill editor. // Activate Quill editor.
const description = new Quill('.quill-editor', { const description = new Quill.default('.quill-editor', {
modules: { modules: {
toolbar: [ toolbar: [
[{ 'header': [1, 2, 3, 4, false] }], [{ 'header': [1, 2, 3, 4, false] }],
@ -209,7 +209,9 @@
}); });
try { try {
description.setContents(JSON.parse(document.querySelector('input[name="description_delta"]').value)); description.setContents(JSON.parse(document.querySelector('input[name="description_delta"]').value));
} catch (e) {} } catch (e) {
console.error(e)
}
// Activate ingredient sortable. // Activate ingredient sortable.
const ingredientsSortable = new Draggable.Sortable(document.querySelector('.ingredients'), { const ingredientsSortable = new Draggable.Sortable(document.querySelector('.ingredients'), {

View File

@ -10,7 +10,7 @@
</x-slot> </x-slot>
<x-search-view :route="route('api:v1:recipes.index')" :tags="$tags"> <x-search-view :route="route('api:v1:recipes.index')" :tags="$tags">
<x-slot name="results"> <x-slot name="results">
<template x-for="recipe in results" :key="recipe"> <template x-for="recipe in results" :key="recipe.slug">
<article class="p-1 border-2 border-black font-sans"> <article class="p-1 border-2 border-black font-sans">
<h1 class="text-2xl font-extrabold"> <h1 class="text-2xl font-extrabold">
<a x-bind:href="recipe.showUrl" <a x-bind:href="recipe.showUrl"

View File

@ -1,3 +1,7 @@
@php use App\Models\IngredientAmount; @endphp
@php use App\Support\Number; @endphp
@php use App\Models\Recipe; @endphp
@php use App\Models\RecipeSeparator; @endphp
<x-app-layout> <x-app-layout>
<x-slot name="title">{{ $recipe->name }}</x-slot> <x-slot name="title">{{ $recipe->name }}</x-slot>
@if(!empty($feature_image)) @if(!empty($feature_image))
@ -9,7 +13,7 @@
</h1> </h1>
</x-slot> </x-slot>
<div class="flex flex-col-reverse justify-between md:flex-row md:space-x-4"> <div class="flex flex-col-reverse justify-between md:flex-row md:space-x-4">
<div class="flex-1" x-data="{showNutrientsSummary: false}"> <div class="flex-1" x-data="{ showNutrientsSummary: false }">
@if($recipe->time_total > 0) @if($recipe->time_total > 0)
<section class="flex justify-between mb-2 p-2 bg-gray-100 rounded max-w-3xl"> <section class="flex justify-between mb-2 p-2 bg-gray-100 rounded max-w-3xl">
<div> <div>
@ -31,42 +35,62 @@
{!! $recipe->description !!} {!! $recipe->description !!}
</section> </section>
@endif @endif
<section x-data="{showNutrientsSummary: false}"> <section x-data="{ showNutrientsSummary: false }">
<h1 class="mb-2 font-bold text-2xl"> <h1 class="mb-2 font-bold text-2xl">
Ingredients Ingredients
<span class="text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300 font-normal cursor-pointer" <span
x-on:click="showNutrientsSummary = !showNutrientsSummary">[toggle nutrients]</span> class="text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300 font-normal cursor-pointer"
x-on:click="showNutrientsSummary = !showNutrientsSummary">[toggle nutrients]</span>
</h1> </h1>
<div class="prose prose-lg"> <div class="prose prose-lg">
<ul class="space-y-2"> <ul class="space-y-2">
@foreach($recipe->ingredientsList->sortBy('weight') as $item) @foreach($recipe->ingredientsList->sortBy('weight') as $item)
@if($item::class === \App\Models\IngredientAmount::class) @if($item::class === IngredientAmount::class)
<li> <li>
<span> <span>
{{ \App\Support\Number::rationalStringFromFloat($item->amount) }} {{-- Prevent food with serving size > 1 from incorrectly using formatted
@if($item->unitFormatted){{ $item->unitFormatted }}@endif serving unit with number of servings. E.g., for a recipe calling for 1
@if($item->ingredient->type === \App\Models\Recipe::class) serving of a food with 4 tbsp. to a serving size show "1 serving" instead
of "1 tbsp." (incorrect). --}}
@if($item->unit === 'serving' && $item->ingredient->serving_size > 1 && ($item->ingredient->serving_unit || $item->ingredient->serving_unit_name))
{{ Number::rationalStringFromFloat($item->amount * $item->ingredient->serving_size) }} {{ $item->unitFormatted }}
<span
class="text-gray-500">({{ Number::rationalStringFromFloat($item->amount) }} {{ \Illuminate\Support\Str::plural('serving', $item->amount ) }})</span>
@else
{{ Number::rationalStringFromFloat($item->amount) }}
@if($item->unitFormatted)
{{ $item->unitFormatted }}
@endif
@endif
@if($item->ingredient->type === Recipe::class)
<a class="text-gray-500 hover:text-gray-700 hover:border-gray-300" <a class="text-gray-500 hover:text-gray-700 hover:border-gray-300"
href="{{ route('recipes.show', $item->ingredient) }}"> href="{{ route('recipes.show', $item->ingredient) }}">
{{ $item->ingredient->name }} {{ $item->ingredient->name }}
</a> </a>
@else @else
{{ $item->ingredient->name }}@if($item->ingredient->detail), {{ $item->ingredient->detail }}@endif {{ $item->ingredient->name }}@if($item->ingredient->detail)
, {{ $item->ingredient->detail }}
@endif
@endif @endif
@if($item->detail)<span class="text-gray-500">{{ $item->detail }}</span>@endif @if($item->detail)
<div x-show="showNutrientsSummary" class="text-sm text-gray-500">{{ $item->nutrients_summary }}</div> <span class="text-gray-500">{{ $item->detail }}</span>
@endif
<div x-show="showNutrientsSummary"
class="text-sm text-gray-500">{{ $item->nutrients_summary }}</div>
</span> </span>
</li> </li>
@elseif($item::class === \App\Models\RecipeSeparator::class) @elseif($item::class === RecipeSeparator::class)
</ul></div> </ul>
@if($item->text) </div>
<h2 class="mt-3 font-bold">{{ $item->text }}</h2> @if($item->text)
@else <h2 class="mt-3 font-bold">{{ $item->text }}</h2>
<hr class="mt-3 lg:w-1/2" /> @else
@endif <hr class="mt-3 lg:w-1/2"/>
<div class="prose prose-lg"> @endif
<ul class="space-y-2"> <div class="prose prose-lg">
@endif <ul class="space-y-2">
@endif
@endforeach @endforeach
</ul> </ul>
</div> </div>
@ -87,7 +111,8 @@
<h1 class="mb-2 font-bold text-2xl">Tags</h1> <h1 class="mb-2 font-bold text-2xl">Tags</h1>
<div class="flex flex-wrap"> <div class="flex flex-wrap">
@foreach($recipe->tags as $tag) @foreach($recipe->tags as $tag)
<span class="m-1 bg-gray-200 rounded-full px-2 leading-loose cursor-default">{{ $tag->name }}</span> <span
class="m-1 bg-gray-200 rounded-full px-2 leading-loose cursor-default">{{ $tag->name }}</span>
@endforeach @endforeach
</div> </div>
</section> </section>
@ -153,10 +178,14 @@
</div> </div>
</div> </div>
</div> </div>
<section class="flex flex-row space-x-2 justify-around md:flex-col md:space-y-2 md:space-x-0"> <section class="flex flex-col space-y-2">
<x-log-journalable :journalable="$recipe"></x-log-journalable>
<x-button-link.gray href="{{ route('recipes.edit', $recipe) }}"> <x-button-link.gray href="{{ route('recipes.edit', $recipe) }}">
Edit Recipe Edit Recipe
</x-button-link.gray> </x-button-link.gray>
<x-button-link.gray href="{{ route('recipes.duplicate.confirm', $recipe) }}">
Duplicate Recipe
</x-button-link.gray>
<x-button-link.red href="{{ route('recipes.delete', $recipe) }}"> <x-button-link.red href="{{ route('recipes.delete', $recipe) }}">
Delete Recipe Delete Recipe
</x-button-link.red> </x-button-link.red>

View File

@ -4,14 +4,9 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| API Routes | API Routes
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
|
| See: https://laravel-json-api.readthedocs.io/en/latest/
|
| TODO: Get auth middleware working...
|
*/ */
JsonApi::register('v1')->routes(function ($api) { JsonApi::register('v1')->middleware('auth:api,web')->routes(function ($api) {
$api->resource('foods')->relationships(function ($relations) { $api->resource('foods')->relationships(function ($relations) {
$relations->hasMany('tags')->readOnly(); $relations->hasMany('tags')->readOnly();
})->readOnly(); })->readOnly();

View File

@ -26,8 +26,9 @@ Route::middleware(['auth'])->group(function () {
Route::get('/foods/{food}/delete', [FoodController::class, 'delete'])->name('foods.delete'); Route::get('/foods/{food}/delete', [FoodController::class, 'delete'])->name('foods.delete');
// Goals. // Goals.
Route::resource('goals', GoalController::class); Route::resource('goals', GoalController::class)->only(['index', 'create', 'store']);
Route::get('/goals/{goal}/delete', [GoalController::class, 'delete'])->name('goals.delete'); Route::resource('goals', GoalController::class)->except(['index', 'create', 'store'])->middleware(['can:access,goal']);
Route::get('/goals/{goal}/delete', [GoalController::class, 'delete'])->middleware(['can:access,goal'])->name('goals.delete');
// Ingredient picker. // Ingredient picker.
Route::get('/ingredient-picker/search', [IngredientPickerController::class, 'search'])->name('ingredient-picker.search'); Route::get('/ingredient-picker/search', [IngredientPickerController::class, 'search'])->name('ingredient-picker.search');
@ -48,13 +49,14 @@ Route::middleware(['auth'])->group(function () {
// Recipes. // Recipes.
Route::resource('recipes', RecipeController::class); Route::resource('recipes', RecipeController::class);
Route::get('/recipes/{recipe}/delete', [RecipeController::class, 'delete'])->name('recipes.delete'); Route::get('/recipes/{recipe}/delete', [RecipeController::class, 'delete'])->name('recipes.delete');
Route::get('/recipes/{recipe}/duplicate', [RecipeController::class, 'duplicateConfirm'])->name('recipes.duplicate.confirm');
Route::post('/recipes/{recipe}/duplicate', [RecipeController::class, 'duplicate'])->name('recipes.duplicate');
// Users. // Users.
Route::get('/profile/{user}', [ProfileController::class, 'show'])->name('profiles.show'); Route::get('/profile/{user}', [ProfileController::class, 'show'])->name('profiles.show');
});
Route::middleware(['auth', 'can:editProfile,user'])->group(function () {
// Profiles (non-admin Users variant). // Profiles (non-admin Users variant).
Route::get('/profile/{user}/edit', [ProfileController::class, 'edit'])->name('profiles.edit'); Route::get('/profile/{user}/edit', [ProfileController::class, 'edit'])->middleware(['can:editProfile,user'])->name('profiles.edit');
Route::put('/profile/{user}', [ProfileController::class, 'update'])->name('profiles.update'); Route::put('/profile/{user}', [ProfileController::class, 'update'])->middleware(['can:editProfile,user'])->name('profiles.update');
}); });

View File

@ -15,18 +15,36 @@ use Illuminate\Support\Facades\Artisan;
*/ */
if (!App::isProduction()) { if (!App::isProduction()) {
/**
* Clear all caches.
*/
Artisan::command('dev:cache-clear', function () {
/** @phpstan-ignore-next-line */
assert($this instanceof ClosureCommand);
$commands = [
'cache:clear',
'config:clear',
'route:clear',
'view:clear',
];
foreach ($commands as $command) {
Artisan::call($command);
$this->info(trim(Artisan::output()));
}
$this->info('All caches cleared!');
})->purpose('Clear all caches.');
/** /**
* Wipe, migrate, and seed the database. * Wipe, migrate, and seed the database.
*/ */
Artisan::command('dev:reset', function () { Artisan::command('dev:reset', function () {
/** @phpstan-ignore-next-line */ /** @phpstan-ignore-next-line */
assert($this instanceof ClosureCommand); assert($this instanceof ClosureCommand);
Artisan::call('db:wipe'); $commands = ['db:wipe', 'migrate', 'db:seed'];
$this->info(Artisan::output()); foreach ($commands as $command) {
Artisan::call('migrate'); Artisan::call($command);
$this->info(Artisan::output()); $this->info(trim(Artisan::output()));
Artisan::call('db:seed'); }
$this->info(Artisan::output());
$this->info('Database reset complete!'); $this->info('Database reset complete!');
})->purpose('Wipe, migrate, and seed the database.'); })->purpose('Wipe, migrate, and seed the database.');
} }

9
tailwind.config.js vendored
View File

@ -1,9 +1,8 @@
const defaultTheme = require('tailwindcss/defaultTheme'); const defaultTheme = require('tailwindcss/defaultTheme');
module.exports = { module.exports = {
mode: 'jit',
purge: [ content: [
'./storage/framework/views/*.php', './storage/framework/views/*.php',
'./resources/views/**/*.blade.php', './resources/views/**/*.blade.php',
], ],
@ -16,12 +15,6 @@ module.exports = {
}, },
}, },
variants: {
extend: {
opacity: ['disabled'],
},
},
plugins: [ plugins: [
require('@tailwindcss/typography'), require('@tailwindcss/typography'),
require('@tailwindcss/forms') require('@tailwindcss/forms')

View File

@ -2,6 +2,7 @@
namespace Tests\Feature\Http\Controllers; namespace Tests\Feature\Http\Controllers;
use Algolia\AlgoliaSearch\Exceptions\UnreachableException;
use App\Http\Controllers\IngredientPickerController; use App\Http\Controllers\IngredientPickerController;
use App\Models\Food; use App\Models\Food;
use App\Models\Recipe; use App\Models\Recipe;
@ -42,8 +43,8 @@ class IngredientPickerControllerTest extends LoggedInTestCase
*/ */
public function testCanSearchWithAlgolia(): void public function testCanSearchWithAlgolia(): void
{ {
$this->expectException(ConnectException::class); $this->expectException(UnreachableException::class);
$this->expectExceptionMessageMatches("/Could not resolve host: \-dsn\.algolia\.net/"); $this->expectExceptionMessage("Impossible to connect, please check your Algolia Application Id.");
Config::set('scout.driver', 'algolia'); Config::set('scout.driver', 'algolia');
$response = $this->get($this->buildUrl(['term' => 'butter'])); $response = $this->get($this->buildUrl(['term' => 'butter']));

View File

@ -102,6 +102,25 @@ class RecipeControllerTest extends HttpControllerTestCase
$response->assertSessionHasNoErrors(); $response->assertSessionHasNoErrors();
} }
public function testCanDuplicateInstances(): void {
$instance = $this->createInstance();
$confirm_url = action([$this->class(), 'duplicateConfirm'], [$this->routeKey() => $instance]);
$response = $this->get($confirm_url);
$response->assertOk();
$response->assertViewHas($this->routeKey());
$duplicate_url = action([$this->class(), 'duplicate'], [$this->routeKey() => $instance]);
$response = $this->followingRedirects()->post($duplicate_url, ['name' => 'Duplicated Recipe']);
$response->assertOk();
$recipe = Recipe::latest()->first();
$this->assertEquals('Duplicated Recipe', $recipe->name);
$this->assertEquals($instance->tags->toArray(), $instance->tags->toArray());
$this->assertEquals($instance->ingredientAmounts->toArray(), $instance->ingredientAmounts->toArray());
$this->assertEquals($instance->steps->toArray(), $instance->steps->toArray());
$this->assertEquals($instance->separators->toArray(), $instance->separators->toArray());
}
public function testSessionKeepsOldInputOnAdd(): void { public function testSessionKeepsOldInputOnAdd(): void {
$instance = $this->createInstance(); $instance = $this->createInstance();
$data = $this->createInvalidFormData($instance); $data = $this->createInvalidFormData($instance);

View File

@ -98,8 +98,8 @@ class NutrientsTest extends TestCase
['fat', 200, 'gram', 10], ['fat', 200, 'gram', 10],
['protein', 100, 'gram', 5], ['protein', 100, 'gram', 5],
['protein', 200, 'gram', 10], ['protein', 200, 'gram', 10],
['sodium', 2, 'oz', Nutrients::$gramsPerOunce], // ['sodium', 2, 'oz', Nutrients::$gramsPerOunce],
['sodium', 4, 'oz', Nutrients::$gramsPerOunce * 2], // ['sodium', 4, 'oz', Nutrients::$gramsPerOunce * 2],
]; ];
} }
@ -135,25 +135,25 @@ class NutrientsTest extends TestCase
]; ];
return [ return [
[$foods['tsp'], $foods['tsp']->serving_weight, 'oz', Nutrients::$gramsPerOunce], // [$foods['tsp'], $foods['tsp']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
[$foods['tsp'], 1, 'serving', 1], [$foods['tsp'], 1, 'serving', 1],
[$foods['tsp'], $foods['tsp']->serving_weight * 1.5, 'gram', 1.5], [$foods['tsp'], $foods['tsp']->serving_weight * 1.5, 'gram', 1.5],
[$foods['tsp'], 2, 'tsp', 2], [$foods['tsp'], 2, 'tsp', 2],
[$foods['tsp'], 1, 'tbsp', 3], [$foods['tsp'], 1, 'tbsp', 3],
[$foods['tsp'], 1, 'cup', 48], [$foods['tsp'], 1, 'cup', 48],
[$foods['tbsp'], $foods['tbsp']->serving_weight, 'oz', Nutrients::$gramsPerOunce], // [$foods['tbsp'], $foods['tbsp']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
[$foods['tbsp'], 1, 'serving', 1], [$foods['tbsp'], 1, 'serving', 1],
[$foods['tbsp'], $foods['tbsp']->serving_weight * 2, 'gram', 2], [$foods['tbsp'], $foods['tbsp']->serving_weight * 2, 'gram', 2],
[$foods['tbsp'], 2, 'tsp', 2/3], [$foods['tbsp'], 2, 'tsp', 2/3],
[$foods['tbsp'], 1, 'tbsp', 1], [$foods['tbsp'], 1, 'tbsp', 1],
[$foods['tbsp'], 2, 'cup', 32], [$foods['tbsp'], 2, 'cup', 32],
[$foods['cup'], $foods['cup']->serving_weight, 'oz', Nutrients::$gramsPerOunce], // [$foods['cup'], $foods['cup']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
[$foods['cup'], 1, 'serving', 1], [$foods['cup'], 1, 'serving', 1],
[$foods['cup'], $foods['cup']->serving_weight * 2.25, 'gram', 2.25], [$foods['cup'], $foods['cup']->serving_weight * 2.25, 'gram', 2.25],
[$foods['cup'], 3, 'tsp', 1/16], [$foods['cup'], 3, 'tsp', 1/16],
[$foods['cup'], 2, 'tbsp', 1/8], [$foods['cup'], 2, 'tbsp', 1/8],
[$foods['cup'], 5, 'cup', 5], [$foods['cup'], 5, 'cup', 5],
[$foods['none'], $foods['none']->serving_weight, 'oz', Nutrients::$gramsPerOunce], // [$foods['none'], $foods['none']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
[$foods['none'], 1, 'serving', 1], [$foods['none'], 1, 'serving', 1],
[$foods['none'], $foods['none']->serving_weight * 3.0125, 'gram', 3.0125], [$foods['none'], $foods['none']->serving_weight * 3.0125, 'gram', 3.0125],
]; ];

View File

@ -48,8 +48,8 @@ class NumberTest extends TestCase
*/ */
public function decimalStringFloatsProvider(): array { public function decimalStringFloatsProvider(): array {
return [ return [
['0.0', 0.0], ['0.125', 1/8], ['0.25', 1/4], ['0.5', 1/2], ['0.0', 0.0], ['0.125', 0.125], ['0.25', 0.25], ['0.5', 0.5],
['0.75', 3/4], ['1.0', 1.0], ['1.25', 1.25], ['1.5', 1.5], ['0.75', 0.75], ['1.0', 1.0], ['1.25', 1.25], ['1.5', 1.5],
['2.5', 2.5], ['2.75', 2.75], ['2.5', 2.5], ['2.75', 2.75],
]; ];
} }
@ -62,9 +62,9 @@ class NumberTest extends TestCase
*/ */
public function fractionStringFloatsProvider(): array { public function fractionStringFloatsProvider(): array {
return [ return [
['0', 0.0], ['1/8', 1/8], ['1/4', 1/4], ['1/3', 1/3], ['1/2', 1/2], ['0', 0.0], ['1/8', 0.125], ['1/4', 0.25], ['1/2', 0.5],
['2/3', 2/3], ['3/4', 3/4], ['1', 1.0], ['1 1/4', 1.25], ['1 1/3', 1 + 1/3], ['3/4', 0.75], ['1', 1.0], ['1 1/4', 1.25],
['1 1/2', 1.5], ['1 2/3', 1 + 2/3], ['2 1/2', 2.5], ['2 3/4', 2.75], ['1 1/2', 1.5], ['2 1/2', 2.5], ['2 3/4', 2.75],
]; ];
} }
} }