mirror of https://github.com/kcal-app/kcal.git
Compare commits
60 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
9f3c280684 | |
|
|
b9717a9961 | |
|
|
d77dba0468 | |
|
|
e25d55212b | |
|
|
96a285d619 | |
|
|
0fa77ec6cb | |
|
|
0660c4bfa4 | |
|
|
9aea9114f7 | |
|
|
dd2b10a1db | |
|
|
5fdd3ef2f4 | |
|
|
b955eb53b8 | |
|
|
2a05ebc3c8 | |
|
|
33d3a7421f | |
|
|
8d9f07f931 | |
|
|
4231b2746e | |
|
|
7b3b5a9617 | |
|
|
4271918fe8 | |
|
|
bbdc7c7202 | |
|
|
a02875f845 | |
|
|
b3999f005b | |
|
|
41c66cdf12 | |
|
|
f74806ea07 | |
|
|
50bb378c02 | |
|
|
5de41c4793 | |
|
|
4aaf83c862 | |
|
|
6e3c40531d | |
|
|
1d4a975ae5 | |
|
|
c4cb8759ce | |
|
|
7daeab4c44 | |
|
|
ef096492d4 | |
|
|
ce4827a8ec | |
|
|
99e214e822 | |
|
|
5c29150ae1 | |
|
|
9b0ffde30c | |
|
|
0e8eba8b91 | |
|
|
6bc18c48a8 | |
|
|
7273da6bd2 | |
|
|
c42cfdb531 | |
|
|
f17fb757ef | |
|
|
3f04f14a2d | |
|
|
0f2d054649 | |
|
|
f607bf73f7 | |
|
|
6dd301a296 | |
|
|
f7a95cc020 | |
|
|
419fcc2cb9 | |
|
|
cf51670727 | |
|
|
7d058ac628 | |
|
|
11f26504b6 | |
|
|
db055b934e | |
|
|
44110984e2 | |
|
|
6a0f6ae17d | |
|
|
f79fd5e479 | |
|
|
a2621f7c17 | |
|
|
1316c5e59b | |
|
|
fd0a88845d | |
|
|
e0875c6fdc | |
|
|
ad2f698efb | |
|
|
2308b80cdd | |
|
|
feb6cce3a4 | |
|
|
636f9ff864 |
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
9
.env.ci
9
.env.ci
|
|
@ -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
|
||||||
|
|
|
||||||
24
.env.example
24
.env.example
|
|
@ -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.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
2481
.phpstorm.meta.php
2481
.phpstorm.meta.php
File diff suppressed because it is too large
Load Diff
|
|
@ -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 \
|
||||||
|
|
|
||||||
332
README.md
332
README.md
|
|
@ -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
|
||||||
|
|
||||||

|

|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
[](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.
|
|
||||||
|
|
|
||||||
|
|
@ -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/)
|
||||||
10550
_ide_helper.php
10550
_ide_helper.php
File diff suppressed because it is too large
Load Diff
71
app.json
71
app.json
|
|
@ -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": "/"
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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' => [],
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,2 +1,3 @@
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
*.sqlite_test*
|
||||||
*.sqlite-journal
|
*.sqlite-journal
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1 @@
|
||||||
require('./bootstrap');
|
require('./bootstrap');
|
||||||
|
|
||||||
require('alpine-magic-helpers');
|
|
||||||
require('alpinejs');
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
// });
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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'), {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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']));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue