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_ENV=local
APP_ENV=testing
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
@ -7,7 +7,12 @@ APP_URL=http://localhost
LOG_CHANNEL=stack
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
ELASTIC_HOST=localhost:9200

View File

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

View File

@ -10,9 +10,13 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: getong/mariadb-action@v1.1
with:
mysql database: kcal
mysql root password: root
- uses: shivammathur/setup-php@v2
with:
php-version: '8.0'
php-version: '8.2'
coverage: xdebug
- name: Configure sysctl limits for Elasticsearch
run: |
@ -23,13 +27,13 @@ jobs:
- name: Run Elasticsearch
uses: elastic/elastic-github-actions/elasticsearch@master
with:
stack-version: '7.12.0'
- uses: actions/checkout@v2
- name: Get composer cache directory
stack-version: '7.17.17'
- uses: actions/checkout@v4
- name: Get Composer cache directory
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
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@ -41,7 +45,7 @@ jobs:
php -r "file_exists('.env') || copy('.env.ci', '.env');"
php artisan key:generate
- 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
env:
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"
FROM php:8.0-fpm-alpine
FROM php:8.2-fpm-alpine
ARG MEDIA_LIBRARY_DEPS
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,
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
![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
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
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).
sudo apt-get install software-properties-common
sudo add-apt-repository ppa:ondrej/php
sudo apt-get install software-properties-common
sudo add-apt-repository ppa:ondrej/php
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 -
echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-7.x.list
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
1. Update available packages.
sudo apt-get update
sudo apt-get update
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.
sudo systemctl start elasticsearch
sudo systemctl enable elasticsearch
sudo systemctl start elasticsearch
sudo systemctl enable elasticsearch
1. Install Composer.
:rotating_light: This command runs code from a remote location as root.
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.
cd /var/www
sudo mkdir kcal
sudo chown $USER:`id -gn $USER` kcal
cd kcal
git clone https://github.com/kcal-app/kcal.git .
cd /var/www
sudo mkdir kcal
sudo chown $USER:`id -gn $USER` kcal
cd kcal
git clone https://github.com/kcal-app/kcal.git .
1. Configure nginx to serve the app public files.
sudo vim /etc/nginx/conf.d/kcal.conf
<edit config, see example below>
sudo service nginx restart
sudo vim /etc/nginx/conf.d/kcal.conf
<edit config, see example below>
sudo service nginx restart
Example config:
server {
listen 80;
server_name kcal.example.com;
root /var/www/kcal/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
server {
listen 80;
server_name kcal.example.com;
root /var/www/kcal/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
1. Create database user (with secure credentials!).
1. Create database user.
sudo mysql -u root
CREATE DATABASE `kcal`;
CREATE USER 'kcal'@'localhost' IDENTIFIED BY 'kcal';
GRANT ALL ON `kcal`.* TO 'kcal'@'localhost';
FLUSH PRIVILEGES;
sudo mysql -u root
CREATE DATABASE `kcal`;
CREATE USER 'kcal'@'localhost' IDENTIFIED BY RANDOM PASSWORD;
GRANT ALL ON `kcal`.* TO 'kcal'@'localhost';
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.
cp .env.example .env
cp .env.example .env
At a minimum:
- Set `APP_KEY` to the value generated in the previous step.
- Set `APP_URL` to match the host configured in nginx configuration.
- Set the `DATABASE_` values to the configured credentials.
- Set `APP_KEY` to the value generated in the previous step.
- Set `APP_URL` to match the host configured in nginx configuration.
- Set the `DATABASE_` values to the configured credentials.
1. Run initial app installation/bootstrap commands.
cd /var/www/kcal
composer install --optimize-autoloader --no-dev
php artisan migrate
php artisan elastic:migrate
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan user:add --admin
php artisan migrate
php artisan elastic:migrate
php artisan config:cache
php artisan route:cache
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!
@ -360,44 +325,44 @@ storage in AWS S3.
Use this example policy to grant necessary permissions to a specific bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"s3:GetBucketPublicAccessBlock",
"s3:GetBucketPolicyStatus",
"s3:GetAccountPublicAccessBlock",
"s3:ListAllMyBuckets",
"s3:GetBucketAcl",
"s3:GetBucketLocation"
],
"Resource": "*"
},
{
"Sid": "VisualEditor1",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME"
},
{
"Sid": "VisualEditor2",
"Effect": "Allow",
"Action": ["s3:*Object", "s3:*ObjectAcl*"],
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME/*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"s3:GetBucketPublicAccessBlock",
"s3:GetBucketPolicyStatus",
"s3:GetAccountPublicAccessBlock",
"s3:ListAllMyBuckets",
"s3:GetBucketAcl",
"s3:GetBucketLocation"
],
"Resource": "*"
},
{
"Sid": "VisualEditor1",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME"
},
{
"Sid": "VisualEditor2",
"Effect": "Allow",
"Action": ["s3:*Object", "s3:*ObjectAcl*"],
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME/*"
}
]
}
1. Set necessary environment variables (via `.env` or some other mechanism).
MEDIA_DISK=s3-public
AWS_ACCESS_KEY_ID=REPLACE_WITH_IAM_KEY
AWS_SECRET_ACCESS_KEY=REPLACE_WITH_IAM_SECRET
AWS_DEFAULT_REGION=REPLACE_WITH_S3_BUCKET_NAME
AWS_BUCKET=REPLACE_WITH_S3_BUCKET_REGION
MEDIA_DISK=s3-public
AWS_ACCESS_KEY_ID=REPLACE_WITH_IAM_KEY
AWS_SECRET_ACCESS_KEY=REPLACE_WITH_IAM_SECRET
AWS_DEFAULT_REGION=REPLACE_WITH_S3_BUCKET_NAME
AWS_BUCKET=REPLACE_WITH_S3_BUCKET_REGION
### 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:
SCOUT_DRIVER=algolia
ALGOLIA_APP_ID=<APPLICATION_ID>
ALGOLIA_SECRET=<ADMIN_API_KEY>
SCOUT_DRIVER=algolia
ALGOLIA_APP_ID=<APPLICATION_ID>
ALGOLIA_SECRET=<ADMIN_API_KEY>
### ElasticSearch (`elastic`)
@ -430,16 +395,16 @@ adds support for Scout (see: laravel-json-api/laravel#32).
1. Update kcal's `.env` file.
SCOUT_DRIVER=elastic
ELASTIC_HOST=<HOST:PORT>
ELASTIC_PORT=<PORT>
SCOUT_DRIVER=elastic
ELASTIC_HOST=<HOST:PORT>
ELASTIC_PORT=<PORT>
Note: The `ELASTIC_PORT` variable is a convenience option specifically for
Docker Compose configurations and is not strictly required.
1. Run Elastic's migrations.
php artisan elastic:migrate
php artisan elastic:migrate
### Fallback (`null`)
@ -451,6 +416,10 @@ Set `SCOUT_DRIVER=null` in kcal's `.env` file to use the fallback driver.
## Development
### Dev Container
Clone the project in an IDE with Dev Container support and build the container.
### Laravel Sail
#### Prerequisites
@ -463,20 +432,20 @@ Set `SCOUT_DRIVER=null` in kcal's `.env` file to use the fallback driver.
1. Clone the repository.
git clone https://github.com/kcal-app/kcal.git
cd kcal
git clone https://github.com/kcal-app/kcal.git
cd kcal
1. Install development dependencies.
composer install
composer install
1. Create a local `.env` file.
cp .env.local.example .env
cp .env.example .env
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
`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:
vendor/bin/sail up
vendor/bin/sail up
1. (On first run) Run migrations.
vendor/bin/sail artisan migrate
vendor/bin/sail artisan elastic:migrate
vendor/bin/sail artisan migrate
vendor/bin/sail artisan elastic:migrate
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`.
@ -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
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
Ensure that Sail is running (primarily to provide ElasticSearch):
@ -510,25 +498,5 @@ Ensure that Sail is running (primarily to provide ElasticSearch):
Execute tests.
vendor/bin/sail artisan test --parallel
#### 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.
vendor/bin/sail artisan dev:cache-clear
vendor/bin/sail artisan test --parallel --recreate-databases

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();
$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();
$food->updateTagsFromRequest($request);
session()->flash('message', "Food {$food->name} updated!");
return redirect()->route('foods.show', $food);

View File

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

View File

@ -12,6 +12,7 @@ use App\Support\Number;
use App\Support\Nutrients;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@ -216,17 +217,7 @@ class RecipeController extends Controller
$this->updateIngredients($recipe, $input);
$this->updateIngredientSeparators($recipe, $input);
$this->updateSteps($recipe, $input);
$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();
$recipe->updateTagsFromRequest($request);
});
} catch (\Exception $e) {
DB::rollBack();
@ -360,6 +351,30 @@ class RecipeController extends Controller
$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.
*/

View File

@ -12,7 +12,7 @@ class Kernel extends HttpKernel
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
@ -31,9 +31,15 @@ class Kernel extends HttpKernel
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Spatie\Csp\AddCspHeaders::class,
\App\Http\Middleware\DisableBrowserCache::class,
],
'api' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
'throttle:api',
\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;
use Fideloper\Proxy\TrustProxies as Middleware;
use Illuminate\Http\Request;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
class TrustProxies extends Middleware
{
@ -15,5 +15,10 @@ class TrustProxies extends Middleware
/**
* {@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())
],
'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_with:ingredients.id.*'],
'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,10 +7,9 @@ use App\Models\Traits\Journalable;
use App\Models\Traits\Sluggable;
use App\Models\Traits\Taggable;
use App\Support\Number;
use ElasticScoutDriverPlus\QueryDsl;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
/**
* 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 \Database\Factories\FoodFactory factory(...$parameters)
* @property-read \Illuminate\Support\Collection $units_supported
* @property-read string $ingredient_id
*/
final class Food extends Model
{
use HasFactory;
use Ingredient;
use Journalable;
use QueryDsl;
use Searchable;
use Sluggable;
use Taggable;

View File

@ -74,6 +74,8 @@ final class Goal extends Model
'fat' => 'float',
'protein' => '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\Support\Number;
use App\Support\Nutrients;
use ElasticScoutDriverPlus\QueryDsl;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Laravel\Scout\Searchable;
use Illuminate\Support\Facades\DB;
use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
@ -83,6 +83,7 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* @property float|null $volume
* @property-read string|null $volume_formatted
* @method static \Illuminate\Database\Eloquent\Builder|Recipe whereVolume($value)
* @property-read string $ingredient_id
*/
final class Recipe extends Model implements HasMedia
{
@ -91,7 +92,6 @@ final class Recipe extends Model implements HasMedia
use Ingredient;
use InteractsWithMedia;
use Journalable;
use QueryDsl;
use Searchable;
use Sluggable;
use Taggable;
@ -263,4 +263,55 @@ final class Recipe extends Model implements HasMedia
->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;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
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 Builder|Tag withType(?string $type = null)
* @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
{
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\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Spatie\Tags\Tag;
trait Ingredient
{
/**
* Add special `type` attribute to appends.
* Add special attributes to appends.
*/
public function initializeIngredient(): void {
$this->appends[] = 'ingredient_id';
$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.
*
* This is necessary e.g. to provide data in ingredient picker responses.
*/
public function getTypeAttribute(): string {
return $this::class;
@ -45,9 +52,9 @@ trait Ingredient
public static function getTagTotals(string $locale = null): DatabaseCollection {
$locale = $locale ?? app()->getLocale();
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)
->groupBy('id')
->groupBy('name')
->orderBy("name->{$locale}")
->get();
}

View File

@ -4,6 +4,7 @@ namespace App\Models\Traits;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Http\Request;
use Spatie\Tags\HasTags;
trait Taggable
@ -27,4 +28,29 @@ trait Taggable
->morphToMany(self::getTagClassName(), 'taggable', 'taggables', null, 'tag_id')
->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\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -56,6 +56,8 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* @property \Illuminate\Support\Collection|null $meals
* @method static \Illuminate\Database\Eloquent\Builder|User whereMeals($value)
* @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
{
@ -71,6 +73,9 @@ final class User extends Authenticatable implements HasMedia
static::creating(function (User $user) {
// Set default meals configuration.
$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
*/
protected $fillable = [
'username',
'password',
'name',
'meals',
'admin',
'api_token',
'meals',
'name',
'password',
'username',
];
/**
* @inheritdoc
*/
protected $hidden = [
'api_token',
'password',
'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;
use App\Models\Goal;
use App\Models\User;
use App\Policies\GoalPolicy;
use App\Policies\UserPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@ -13,6 +15,7 @@ class AuthServiceProvider extends ServiceProvider
* @inheritdoc
*/
protected $policies = [
Goal::class => GoalPolicy::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",
"license": "MPL-2.0",
"require": {
"php": "^8.0",
"php": "^8.2",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
"algolia/algoliasearch-client-php": "^2.7",
"algolia/scout-extended": "^1.15",
"babenkoivan/elastic-migrations": "^1.4",
"babenkoivan/elastic-scout-driver": "^1.3",
"babenkoivan/elastic-scout-driver-plus": "^2.0",
"cloudcreativity/laravel-json-api": "^3.2",
"cviebrock/eloquent-sluggable": "^8.0",
"algolia/algoliasearch-client-php": "^3.2",
"algolia/scout-extended": "^3.0",
"babenkoivan/elastic-migrations": "^3.0",
"babenkoivan/elastic-scout-driver": "^3.0",
"babenkoivan/elastic-scout-driver-plus": "^4.0",
"cloudcreativity/laravel-json-api": "^6.0",
"cviebrock/eloquent-sluggable": "^10.0",
"doctrine/dbal": "^3.0",
"fideloper/proxy": "^4.4",
"fruitcake/laravel-cors": "^2.0",
"guzzlehttp/guzzle": "^7.0.1",
"laravel/framework": "^8.12",
"laravel/scout": "^8.6",
"laravel/tinker": "^2.5",
"league/flysystem-aws-s3-v3": "~1.0",
"laravel/framework": "^10.0",
"laravel/scout": "^10.0",
"laravel/tinker": "^2.7",
"league/flysystem-aws-s3-v3": "^3.0",
"phospr/fraction": "^1.2",
"spatie/laravel-medialibrary": "^9.0.0",
"spatie/laravel-tags": "^3.0"
"spatie/laravel-csp": "^2.6",
"spatie/laravel-medialibrary": "^10.0",
"spatie/laravel-tags": "^4.0"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^2.9",
"brianium/paratest": "^6.2",
"cloudcreativity/json-api-testing": "^3.2",
"facade/ignition": "^2.5",
"cloudcreativity/json-api-testing": "^5.0",
"fakerphp/faker": "^1.9.1",
"laravel/breeze": "^1.0",
"laravel/sail": "^0.0.5",
"laravel/sail": "^1.10",
"mockery/mockery": "^1.4.2",
"nunomaduro/collision": "^5.0",
"nunomaduro/larastan": "^0.6.13",
"nunomaduro/collision": "^6.1",
"nunomaduro/larastan": "^2.0",
"php-coveralls/php-coveralls": "^2.4",
"phpunit/phpunit": "^9.3.3"
"phpunit/phpunit": "^9.3.3",
"spatie/laravel-ignition": "^2.0"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
"sort-packages": true,
"allow-plugins": {
"php-http/discovery": true
}
},
"extra": {
"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_test*
*.sqlite-journal

View File

@ -22,12 +22,12 @@ class RecipeFactory extends Factory
*/
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];
return [
'name' => Words::randomWords(Arr::random(['npan', 'npn', 'anpn'])),
'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_cook' => $this->faker->numberBetween(0, 90),
'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:
app:
build:
context: ./vendor/laravel/sail/runtimes/8.0
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
image: sail-8.0/app
ports:
- '${APP_PORT:-8080}:80'
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- db
- redis
- elasticsearch
db:
image: 'mysql:8.0'
ports:
- '${DB_PORT:-3306}:3306'
environment:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'
MYSQL_DATABASE: '${DB_DATABASE:-kcal}'
MYSQL_USER: '${DB_USERNAME:-kcal}'
MYSQL_PASSWORD: '${DB_PASSWORD:-kcal}'
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
volumes:
- 'mysql-data:/var/lib/mysql'
networks:
- sail
phpmyadmin:
image: phpmyadmin
ports:
- 8081:80
environment:
PMA_HOST: db
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'
networks:
- sail
depends_on:
- db
elasticsearch:
image: 'elasticsearch:7.12.0'
environment:
- xpack.security.enabled=false
- discovery.type=single-node
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
cap_add:
- IPC_LOCK
volumes:
- 'elasticsearch-data:/usr/share/elasticsearch/data'
ports:
- '${ELASTIC_PORT:-9200}:9200'
networks:
- sail
redis:
image: 'redis:alpine'
ports:
- '${REDIS_PORT:-6379}:6379'
volumes:
- 'redis-data:/data'
networks:
- sail
app:
build:
context: ./vendor/laravel/sail/runtimes/8.2
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP:-1000}'
image: sail-8.2/app
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '${APP_PORT:-80}:80'
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
environment:
WWWUSER: '${WWWUSER:-1000}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
IGNITION_LOCAL_SITES_PATH: '${PWD}'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- db
- redis
db:
image: 'mariadb:10'
ports:
- '${FORWARD_DB_PORT:-3306}:3306'
environment:
MARIADB_DATABASE: '${DB_DATABASE:-kcal}'
MARIADB_PASSWORD: '${DB_PASSWORD:-kcal}'
MARIADB_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'
MARIADB_USER: '${DB_USERNAME:-kcal}'
volumes:
- 'db-data:/var/lib/mysql'
- './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
networks:
- sail
healthcheck:
test:
- CMD
- mysqladmin
- ping
- '-p${DB_PASSWORD}'
retries: 3
timeout: 5s
redis:
image: 'redis:alpine'
ports:
- '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
- 'redis-data:/data'
networks:
- sail
healthcheck:
test:
- CMD
- redis-cli
- ping
retries: 3
timeout: 5s
elasticsearch:
image: 'elasticsearch:7.17.17'
environment:
- xpack.security.enabled=false
- discovery.type=single-node
deploy:
resources:
limits:
memory: 1G
volumes:
- '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:
sail:
driver: bridge
sail:
driver: bridge
volumes:
elasticsearch-data:
driver: local
mysql-data:
driver: local
redis-data:
driver: local
db-data:
driver: local
redis-data:
driver: local
elasticsearch-data:

View File

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

View File

@ -1,10 +1,10 @@
<?php
declare(strict_types=1);
use ElasticAdapter\Indices\Mapping;
use ElasticAdapter\Indices\Settings;
use ElasticMigrations\Facades\Index;
use ElasticMigrations\MigrationInterface;
use Elastic\Adapter\Indices\Mapping;
use Elastic\Adapter\Indices\Settings;
use Elastic\Migrations\Facades\Index;
use Elastic\Migrations\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"
},
"devDependencies": {
"@tailwindcss/forms": "^0.3.2",
"@tailwindcss/typography": "^0.4.1",
"alpinejs": "^2.8.2",
"autoprefixer": "^10.2.6",
"axios": "^0.21.1",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"alpinejs": "^3.13.10",
"autoprefixer": "^10.4.19",
"axios": "^1.6.8",
"cross-env": "^7.0",
"laravel-mix": "^6.0.19",
"laravel-mix": "^6.0.49",
"lodash": "^4.17.21",
"postcss-import": "^14.0.2",
"quill": "^1.3.7",
"resolve-url-loader": "^4.0.0",
"tailwindcss": "^2.1.2",
"vue-template-compiler": "^2.6.12"
"postcss-import": "^16.1.0",
"quill": "^2.0.1",
"resolve-url-loader": "^5.0.0",
"tailwindcss": "^3.4.3",
"vue-template-compiler": "^2.7.16"
},
"dependencies": {
"@shopify/draggable": "^1.0.0-beta.12",
"alpine-magic-helpers": "^1.2.2"
"@shopify/draggable": "^1.1.3"
}
}

View File

@ -19,6 +19,7 @@
</coverage>
<php>
<server name="APP_ENV" value="testing"/>
<server name="DB_USERNAME" value="root"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="MAIL_MAILER" value="array"/>
@ -26,9 +27,5 @@
<server name="SESSION_DRIVER" value="elastic"/>
<server name="SESSION_DRIVER" value="array"/>
<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>
</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
* 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('alpine-magic-helpers');
require('alpinejs');

View File

@ -1,28 +1,11 @@
// Load Lodash.
window._ = require('lodash');
/**
* 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.
*/
// Load Axios.
window.axios = require('axios');
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allows your team to easily build robust real-time web applications.
*/
// 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
// });
// Load AlpineJS.
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();

View File

@ -1,7 +1,7 @@
@props(['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 }}
</div>
@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 }}
</x-button-link.base>

View File

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

View File

@ -20,13 +20,13 @@
autocapitalize="none"
inputmode="search"
x-ref="ingredients_name"
x-spread="search" />
x-bind="search" />
</div>
<div x-show="searching" x-cloak>
<div class="absolute border-2 border-gray-500 border-b-0 bg-white"
x-spread="ingredient">
<template x-for="result in results" :key="result.id">
<div class="p-1 border-b-2 border-gray-500 hover:bg-yellow-300 cursor-pointer" x-bind:data-id="result.id">
x-bind="ingredient">
<template x-for="result in results" :key="result.ingredient_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>
<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 }}
</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">
<nav class="md:w-1/4">
<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"
x-show="morePages"
x-cloak
@click.prevent="loadMore()">
@click.prevent="loadMore">
Load more
</x-inputs.button>
</section>
@ -51,8 +51,8 @@
@once
@push('scripts')
<script type="text/javascript">
let searchView = () => {
return {
document.addEventListener('alpine:init', () => {
Alpine.data('searchView', () => ({
results: [],
number: 1,
size: 12,
@ -60,6 +60,9 @@
searchTerm: null,
searching: false,
filterTags: [],
init() {
this.loadMore();
},
resetPagination() {
this.number = 1;
this.morePages = false;
@ -109,8 +112,8 @@
}
this.loadMore();
}
}
}
}))
})
</script>
@endpush
@endonce

View File

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

View File

@ -25,7 +25,7 @@
@endif
@if(!$food->ingredientAmountRelationships->isEmpty())
<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">
<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>

View File

@ -10,7 +10,7 @@
</x-slot>
<x-search-view :route="route('api:v1:foods.index')" :tags="$tags">
<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">
<h1 class="text-2xl lowercase font-extrabold leading-none">
<a x-bind:href="food.showUrl"

View File

@ -100,6 +100,7 @@
</div>
</section>
<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) }}">
Edit Food
</x-button-link.gray>

View File

@ -10,14 +10,14 @@
</x-slot>
<form method="POST" action="{{ route('journal-entries.store') }}">
@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)
@include('journal-entries.partials.entry-item-input', $ingredient)
@endforeach
<div class="journal-entry-template hidden">
@include('journal-entries.partials.entry-item-input', ['default_date' => $default_date])
</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">
<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>
@ -27,7 +27,7 @@
<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>
</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>
</form>

View File

@ -14,12 +14,12 @@
</a>
</div>
<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"
type="date"
class="border-0 shadow-none p-0 text-center"
:value="$date->toDateString()"
x-on:change="$el.submit();"
x-on:change="$refs.root.submit();"
required />
</form>
</div>
@ -125,7 +125,7 @@
:selectedValue="$currentGoal?->id ?? null">
</x-inputs.select>
<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">
Cancel
</x-button-link.red>

View File

@ -41,7 +41,7 @@
<!-- Page Content -->
<main>
@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') }}
</div>
@endif

View File

@ -35,9 +35,9 @@
<x-dropdown-link :href="route('users.index')">Manage Users</x-dropdown-link>
@endcan
<hr />
<form method="POST" action="{{ route('logout') }}" x-data>
<form method="POST" action="{{ route('logout') }}" x-data x-ref="root">
@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>
</div>
</x-slot>

View File

@ -18,7 +18,10 @@
</a>
</div>
@endif
<div class="mt-2 text-gray-500">
{{ $user->name }}
<div class="mt-2">
<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>
</x-app-layout>

View File

@ -13,7 +13,7 @@
<div class="flex flex-col space-y-2 mt-2 text-lg">
@if(!$recipe->ingredientAmountRelationships->isEmpty())
<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">
<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>

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">
<h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h1>
</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
@csrf
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
@ -114,7 +114,7 @@
<!-- Ingredients -->
<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)
@if($item['type'] === 'ingredient')
@include('recipes.partials.ingredient-input', $item)
@ -133,20 +133,20 @@
</div>
</div>
<x-inputs.button type="button"
class="bg-green-800 hover:bg-green-700 active:bg-green-900 focus:border-green-900 ring-green-300"
x-on:click="addNodeFromTemplate($el, 'ingredient');">
class="bg-emerald-800 hover:bg-emerald-700 active:bg-emerald-900 focus:border-emerald-900 ring-emerald-300"
x-on:click="addNodeFromTemplate($refs.ingredients, 'ingredient');">
Add Ingredient
</x-inputs.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"
x-on:click="addNodeFromTemplate($el, 'separator');">
x-on:click="addNodeFromTemplate($refs.ingredients, 'separator');">
Add Separator
</x-inputs.button>
</div>
<!-- Steps -->
<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)
@include('recipes.partials.step-input', $step)
@empty
@ -158,14 +158,14 @@
</div>
</div>
<x-inputs.button type="button"
class="bg-green-800 hover:bg-green-700 active:bg-green-900 focus:border-green-900 ring-green-300"
x-on:click="addNodeFromTemplate($el, 'step');">
class="bg-emerald-800 hover:bg-emerald-700 active:bg-emerald-900 focus:border-emerald-900 ring-emerald-300"
x-on:click="addNodeFromTemplate($refs.steps, 'step');">
Add Step
</x-inputs.button>
</div>
<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') }}
</x-inputs.button>
</div>
@ -187,11 +187,11 @@
<script type="text/javascript">
// Enforce inline (style-base) alignment.
const AlignStyle = Quill.import('attributors/style/align');
Quill.register(AlignStyle, true);
const AlignStyle = Quill.default.import('attributors/style/align');
Quill.default.register(AlignStyle, true);
// Activate Quill editor.
const description = new Quill('.quill-editor', {
const description = new Quill.default('.quill-editor', {
modules: {
toolbar: [
[{ 'header': [1, 2, 3, 4, false] }],
@ -209,7 +209,9 @@
});
try {
description.setContents(JSON.parse(document.querySelector('input[name="description_delta"]').value));
} catch (e) {}
} catch (e) {
console.error(e)
}
// Activate ingredient sortable.
const ingredientsSortable = new Draggable.Sortable(document.querySelector('.ingredients'), {

View File

@ -10,7 +10,7 @@
</x-slot>
<x-search-view :route="route('api:v1:recipes.index')" :tags="$tags">
<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">
<h1 class="text-2xl font-extrabold">
<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-slot name="title">{{ $recipe->name }}</x-slot>
@if(!empty($feature_image))
@ -9,7 +13,7 @@
</h1>
</x-slot>
<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)
<section class="flex justify-between mb-2 p-2 bg-gray-100 rounded max-w-3xl">
<div>
@ -31,42 +35,62 @@
{!! $recipe->description !!}
</section>
@endif
<section x-data="{showNutrientsSummary: false}">
<section x-data="{ showNutrientsSummary: false }">
<h1 class="mb-2 font-bold text-2xl">
Ingredients
<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>
<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>
<div class="prose prose-lg">
<ul class="space-y-2">
@foreach($recipe->ingredientsList->sortBy('weight') as $item)
@if($item::class === \App\Models\IngredientAmount::class)
@if($item::class === IngredientAmount::class)
<li>
<span>
{{ \App\Support\Number::rationalStringFromFloat($item->amount) }}
@if($item->unitFormatted){{ $item->unitFormatted }}@endif
@if($item->ingredient->type === \App\Models\Recipe::class)
{{-- Prevent food with serving size > 1 from incorrectly using formatted
serving unit with number of servings. E.g., for a recipe calling for 1
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"
href="{{ route('recipes.show', $item->ingredient) }}">
{{ $item->ingredient->name }}
</a>
@else
{{ $item->ingredient->name }}@if($item->ingredient->detail), {{ $item->ingredient->detail }}@endif
{{ $item->ingredient->name }}@if($item->ingredient->detail)
, {{ $item->ingredient->detail }}
@endif
@endif
@if($item->detail)<span class="text-gray-500">{{ $item->detail }}</span>@endif
<div x-show="showNutrientsSummary" class="text-sm text-gray-500">{{ $item->nutrients_summary }}</div>
@if($item->detail)
<span class="text-gray-500">{{ $item->detail }}</span>
@endif
<div x-show="showNutrientsSummary"
class="text-sm text-gray-500">{{ $item->nutrients_summary }}</div>
</span>
</li>
@elseif($item::class === \App\Models\RecipeSeparator::class)
</ul></div>
@if($item->text)
<h2 class="mt-3 font-bold">{{ $item->text }}</h2>
@else
<hr class="mt-3 lg:w-1/2" />
@endif
<div class="prose prose-lg">
<ul class="space-y-2">
@endif
@elseif($item::class === RecipeSeparator::class)
</ul>
</div>
@if($item->text)
<h2 class="mt-3 font-bold">{{ $item->text }}</h2>
@else
<hr class="mt-3 lg:w-1/2"/>
@endif
<div class="prose prose-lg">
<ul class="space-y-2">
@endif
@endforeach
</ul>
</div>
@ -87,7 +111,8 @@
<h1 class="mb-2 font-bold text-2xl">Tags</h1>
<div class="flex flex-wrap">
@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
</div>
</section>
@ -153,10 +178,14 @@
</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) }}">
Edit Recipe
</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) }}">
Delete Recipe
</x-button-link.red>

View File

@ -4,14 +4,9 @@
|--------------------------------------------------------------------------
| 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) {
$relations->hasMany('tags')->readOnly();
})->readOnly();

View File

@ -26,8 +26,9 @@ Route::middleware(['auth'])->group(function () {
Route::get('/foods/{food}/delete', [FoodController::class, 'delete'])->name('foods.delete');
// Goals.
Route::resource('goals', GoalController::class);
Route::get('/goals/{goal}/delete', [GoalController::class, 'delete'])->name('goals.delete');
Route::resource('goals', GoalController::class)->only(['index', 'create', 'store']);
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.
Route::get('/ingredient-picker/search', [IngredientPickerController::class, 'search'])->name('ingredient-picker.search');
@ -48,13 +49,14 @@ Route::middleware(['auth'])->group(function () {
// Recipes.
Route::resource('recipes', RecipeController::class);
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.
Route::get('/profile/{user}', [ProfileController::class, 'show'])->name('profiles.show');
});
Route::middleware(['auth', 'can:editProfile,user'])->group(function () {
// Profiles (non-admin Users variant).
Route::get('/profile/{user}/edit', [ProfileController::class, 'edit'])->name('profiles.edit');
Route::put('/profile/{user}', [ProfileController::class, 'update'])->name('profiles.update');
Route::get('/profile/{user}/edit', [ProfileController::class, 'edit'])->middleware(['can:editProfile,user'])->name('profiles.edit');
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()) {
/**
* 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.
*/
Artisan::command('dev:reset', function () {
/** @phpstan-ignore-next-line */
assert($this instanceof ClosureCommand);
Artisan::call('db:wipe');
$this->info(Artisan::output());
Artisan::call('migrate');
$this->info(Artisan::output());
Artisan::call('db:seed');
$this->info(Artisan::output());
$commands = ['db:wipe', 'migrate', 'db:seed'];
foreach ($commands as $command) {
Artisan::call($command);
$this->info(trim(Artisan::output()));
}
$this->info('Database reset complete!');
})->purpose('Wipe, migrate, and seed the database.');
}

9
tailwind.config.js vendored
View File

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

View File

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

View File

@ -102,6 +102,25 @@ class RecipeControllerTest extends HttpControllerTestCase
$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 {
$instance = $this->createInstance();
$data = $this->createInvalidFormData($instance);

View File

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

View File

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