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_ENV=local
|
||||
APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
|
@ -7,7 +7,12 @@ APP_URL=http://localhost
|
|||
LOG_CHANNEL=stack
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=kcal
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=root
|
||||
|
||||
SCOUT_DRIVER=elastic
|
||||
ELASTIC_HOST=localhost:9200
|
||||
|
|
|
|||
24
.env.example
24
.env.example
|
|
@ -3,26 +3,33 @@
|
|||
#
|
||||
|
||||
APP_NAME=kcal
|
||||
APP_ENV=production
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://127.0.0.1
|
||||
APP_PORT=80
|
||||
APP_PORT=8080
|
||||
APP_SERVICE=app
|
||||
APP_TIMEZONE=UTC
|
||||
|
||||
#
|
||||
# Security
|
||||
# Enable these settings after setting up an HTTPS connection.
|
||||
#
|
||||
|
||||
# SESSION_SECURE_COOKIE=true
|
||||
|
||||
#
|
||||
# Databases configuration.
|
||||
#
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=localhost
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=kcal
|
||||
DB_USERNAME=kcal
|
||||
DB_PASSWORD=kcal
|
||||
|
||||
REDIS_HOST=localhost
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
#
|
||||
|
|
@ -36,7 +43,7 @@ REDIS_PORT=6379
|
|||
#ALGOLIA_SECRET=
|
||||
|
||||
SCOUT_DRIVER=elastic
|
||||
ELASTIC_HOST=localhost:9200
|
||||
ELASTIC_HOST=elasticsearch:9200
|
||||
ELASTIC_PORT=9200
|
||||
|
||||
#
|
||||
|
|
@ -44,6 +51,7 @@ ELASTIC_PORT=9200
|
|||
#
|
||||
|
||||
MEDIA_DISK=media
|
||||
QUEUE_CONVERSIONS_BY_DEFAULT=false
|
||||
|
||||
#MEDIA_DISK=s3-public
|
||||
#AWS_ACCESS_KEY_ID=
|
||||
|
|
@ -51,6 +59,12 @@ MEDIA_DISK=media
|
|||
#AWS_DEFAULT_REGION=
|
||||
#AWS_BUCKET=
|
||||
|
||||
#
|
||||
# Sail (local development).
|
||||
#
|
||||
|
||||
#SAIL_XDEBUG_MODE=develop,debug
|
||||
|
||||
#
|
||||
# Misc. drivers and configuration.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: getong/mariadb-action@v1.1
|
||||
with:
|
||||
mysql database: kcal
|
||||
mysql root password: root
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.0'
|
||||
php-version: '8.2'
|
||||
coverage: xdebug
|
||||
- name: Configure sysctl limits for Elasticsearch
|
||||
run: |
|
||||
|
|
@ -23,13 +27,13 @@ jobs:
|
|||
- name: Run Elasticsearch
|
||||
uses: elastic/elastic-github-actions/elasticsearch@master
|
||||
with:
|
||||
stack-version: '7.12.0'
|
||||
- uses: actions/checkout@v2
|
||||
- name: Get composer cache directory
|
||||
stack-version: '7.17.17'
|
||||
- uses: actions/checkout@v4
|
||||
- name: Get Composer cache directory
|
||||
id: composer-cache
|
||||
run: echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
|
|
@ -41,7 +45,7 @@ jobs:
|
|||
php -r "file_exists('.env') || copy('.env.ci', '.env');"
|
||||
php artisan key:generate
|
||||
- name: Run tests
|
||||
run: vendor/bin/paratest --coverage-clover build/logs/clover.xml
|
||||
run: php artisan test --parallel --recreate-databases --coverage-clover build/logs/clover.xml
|
||||
- name: Upload coverage results to Coveralls
|
||||
env:
|
||||
COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
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"
|
||||
|
||||
FROM php:8.0-fpm-alpine
|
||||
FROM php:8.2-fpm-alpine
|
||||
|
||||
ARG MEDIA_LIBRARY_DEPS
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||

|
||||
|
|
@ -190,38 +181,6 @@ at [kcalapp/kcal](https://hub.docker.com/repository/docker/kcalapp/kcal) on Dock
|
|||
See the [kcal-app/kcal-docker](https://github.com/kcal-app/kcal-docker) repository
|
||||
for a Docker Compose based template and instructions.
|
||||
|
||||
### Heroku
|
||||
|
||||
[](https://heroku.com/deploy)
|
||||
|
||||
The default username and password for a Heroku deployment is `kcal`/`kcal`.
|
||||
|
||||
#### Using Heroku CLI
|
||||
|
||||
For a manual deploy using Heroku CLI, execute the following after initial deployment:
|
||||
|
||||
heroku run php artisan migrate
|
||||
heroku run php artisan user:add
|
||||
heroku config:set APP_KEY=$(php artisan --no-ansi key:generate --show)
|
||||
|
||||
#### Media storage
|
||||
|
||||
Heroku uses an ephemeral disk. In order to maintain recipe and/or user images between
|
||||
app restarts AWS can be used. See [Media Storage - AWS S3](#aws-s3) for additional
|
||||
guidance.
|
||||
|
||||
#### Search drivers
|
||||
|
||||
See the [Search](#search-mag) section for information about supported drivers. Additional
|
||||
environment variable configuration is necessary when using any search driver other
|
||||
than the default ("null").
|
||||
|
||||
#### Redis Add-on
|
||||
|
||||
The [Heroku Redis](https://elements.heroku.com/addons/heroku-redis) add-on can be
|
||||
added to the app and will work without any configuration changes. It is left out
|
||||
of the default build only because it takes a very long time to provision.
|
||||
|
||||
### Manual
|
||||
|
||||
This deployment process has been tested with an Ubuntu 20.04 LTS instance with
|
||||
|
|
@ -231,113 +190,119 @@ section for other options if lower memory support is needed.
|
|||
|
||||
1. Add [PHP 8.x repository](https://launchpad.net/~ondrej/+archive/ubuntu/php).
|
||||
|
||||
sudo apt-get install software-properties-common
|
||||
sudo add-apt-repository ppa:ondrej/php
|
||||
sudo apt-get install software-properties-common
|
||||
sudo add-apt-repository ppa:ondrej/php
|
||||
|
||||
1. Add [Elasticsearch 7.x repository](https://www.elastic.co/guide/en/elasticsearch/reference/current/deb.html).
|
||||
|
||||
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
|
||||
echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-7.x.list
|
||||
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
|
||||
echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-7.x.list
|
||||
|
||||
1. Update available packages.
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get update
|
||||
|
||||
1. Install dependencies.
|
||||
|
||||
sudo apt-get install elasticsearch mysql-server-8.0 ngingx-full php8.0 php8.0-bcmath php8.0-cli php8.0-curl php8.0-gd php8.0-intl php8.0-mbstring php8.0-mysql php8.0-redis php8.0-xml php8.0-zip redis
|
||||
sudo apt-get install elasticsearch mysql-server-8.0 nginx-full php8.2 php8.2-bcmath php8.2-cli php8.2-curl php8.2-gd php8.2-intl php8.2-mbstring php8.2-mysql php8.2-redis php8.2-xml php8.2-zip redis php8.2-fpm
|
||||
|
||||
1. Start Elasticsearch and configure to run at start up.
|
||||
|
||||
sudo systemctl start elasticsearch
|
||||
sudo systemctl enable elasticsearch
|
||||
sudo systemctl start elasticsearch
|
||||
sudo systemctl enable elasticsearch
|
||||
|
||||
1. Install Composer.
|
||||
|
||||
:rotating_light: This command runs code from a remote location as root.
|
||||
See [Download Composer](https://getcomposer.org/download/) for alternative install options.
|
||||
|
||||
curl -s https://getcomposer.org/installer | sudo php -- --install-dir=/usr/local/bin/ --filename=composer
|
||||
curl -s https://getcomposer.org/installer | sudo php -- --install-dir=/usr/local/bin/ --filename=composer
|
||||
|
||||
1. Clone the app repository.
|
||||
|
||||
cd /var/www
|
||||
sudo mkdir kcal
|
||||
sudo chown $USER:`id -gn $USER` kcal
|
||||
cd kcal
|
||||
git clone https://github.com/kcal-app/kcal.git .
|
||||
cd /var/www
|
||||
sudo mkdir kcal
|
||||
sudo chown $USER:`id -gn $USER` kcal
|
||||
cd kcal
|
||||
git clone https://github.com/kcal-app/kcal.git .
|
||||
|
||||
1. Configure nginx to serve the app public files.
|
||||
|
||||
sudo vim /etc/nginx/conf.d/kcal.conf
|
||||
<edit config, see example below>
|
||||
sudo service nginx restart
|
||||
sudo vim /etc/nginx/conf.d/kcal.conf
|
||||
<edit config, see example below>
|
||||
sudo service nginx restart
|
||||
|
||||
Example config:
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name kcal.example.com;
|
||||
root /var/www/kcal/public;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
|
||||
index index.php;
|
||||
|
||||
charset utf-8;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
error_page 404 /index.php;
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
server_name kcal.example.com;
|
||||
root /var/www/kcal/public;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
|
||||
index index.php;
|
||||
|
||||
charset utf-8;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
error_page 404 /index.php;
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
1. Create database user (with secure credentials!).
|
||||
1. Create database user.
|
||||
|
||||
sudo mysql -u root
|
||||
CREATE DATABASE `kcal`;
|
||||
CREATE USER 'kcal'@'localhost' IDENTIFIED BY 'kcal';
|
||||
GRANT ALL ON `kcal`.* TO 'kcal'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
sudo mysql -u root
|
||||
CREATE DATABASE `kcal`;
|
||||
CREATE USER 'kcal'@'localhost' IDENTIFIED BY RANDOM PASSWORD;
|
||||
GRANT ALL ON `kcal`.* TO 'kcal'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
1. Generate an app key to use in the next step.
|
||||
:lock: Save the generated password output by the `CREATE USER` statement.
|
||||
|
||||
php artisan --no-ansi key:generate --show
|
||||
1. Install dependencies and generate an app key to use in the next step.
|
||||
|
||||
composer install --optimize-autoloader --no-dev
|
||||
php artisan --no-ansi key:generate --show
|
||||
|
||||
1. Copy environment config file and adjust as desired.
|
||||
|
||||
cp .env.example .env
|
||||
cp .env.example .env
|
||||
|
||||
At a minimum:
|
||||
- Set `APP_KEY` to the value generated in the previous step.
|
||||
- Set `APP_URL` to match the host configured in nginx configuration.
|
||||
- Set the `DATABASE_` values to the configured credentials.
|
||||
- Set `APP_KEY` to the value generated in the previous step.
|
||||
- Set `APP_URL` to match the host configured in nginx configuration.
|
||||
- Set the `DATABASE_` values to the configured credentials.
|
||||
|
||||
1. Run initial app installation/bootstrap commands.
|
||||
|
||||
cd /var/www/kcal
|
||||
composer install --optimize-autoloader --no-dev
|
||||
php artisan migrate
|
||||
php artisan elastic:migrate
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan user:add --admin
|
||||
php artisan migrate
|
||||
php artisan elastic:migrate
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan user:add --admin
|
||||
|
||||
1. Allow web server to access required directories.
|
||||
|
||||
sudo chown -R $USER:www-data {storage,public}
|
||||
sudo chmod g+s {storage,public}
|
||||
|
||||
1. Visit the `APP_URL` and log in!
|
||||
|
||||
|
|
@ -360,44 +325,44 @@ storage in AWS S3.
|
|||
|
||||
Use this example policy to grant necessary permissions to a specific bucket:
|
||||
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "VisualEditor0",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetBucketPublicAccessBlock",
|
||||
"s3:GetBucketPolicyStatus",
|
||||
"s3:GetAccountPublicAccessBlock",
|
||||
"s3:ListAllMyBuckets",
|
||||
"s3:GetBucketAcl",
|
||||
"s3:GetBucketLocation"
|
||||
],
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "VisualEditor1",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME"
|
||||
},
|
||||
{
|
||||
"Sid": "VisualEditor2",
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*Object", "s3:*ObjectAcl*"],
|
||||
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "VisualEditor0",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetBucketPublicAccessBlock",
|
||||
"s3:GetBucketPolicyStatus",
|
||||
"s3:GetAccountPublicAccessBlock",
|
||||
"s3:ListAllMyBuckets",
|
||||
"s3:GetBucketAcl",
|
||||
"s3:GetBucketLocation"
|
||||
],
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "VisualEditor1",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME"
|
||||
},
|
||||
{
|
||||
"Sid": "VisualEditor2",
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*Object", "s3:*ObjectAcl*"],
|
||||
"Resource": "arn:aws:s3:::REPLACE_WITH_S3_BUCKET_NAME/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1. Set necessary environment variables (via `.env` or some other mechanism).
|
||||
|
||||
MEDIA_DISK=s3-public
|
||||
AWS_ACCESS_KEY_ID=REPLACE_WITH_IAM_KEY
|
||||
AWS_SECRET_ACCESS_KEY=REPLACE_WITH_IAM_SECRET
|
||||
AWS_DEFAULT_REGION=REPLACE_WITH_S3_BUCKET_NAME
|
||||
AWS_BUCKET=REPLACE_WITH_S3_BUCKET_REGION
|
||||
MEDIA_DISK=s3-public
|
||||
AWS_ACCESS_KEY_ID=REPLACE_WITH_IAM_KEY
|
||||
AWS_SECRET_ACCESS_KEY=REPLACE_WITH_IAM_SECRET
|
||||
AWS_DEFAULT_REGION=REPLACE_WITH_S3_BUCKET_NAME
|
||||
AWS_BUCKET=REPLACE_WITH_S3_BUCKET_REGION
|
||||
|
||||
### Search :mag:
|
||||
|
||||
|
|
@ -420,9 +385,9 @@ adds support for Scout (see: laravel-json-api/laravel#32).
|
|||
|
||||
1. Using the **Application ID** and **Admin API Key** values, update kcal's `.env` file:
|
||||
|
||||
SCOUT_DRIVER=algolia
|
||||
ALGOLIA_APP_ID=<APPLICATION_ID>
|
||||
ALGOLIA_SECRET=<ADMIN_API_KEY>
|
||||
SCOUT_DRIVER=algolia
|
||||
ALGOLIA_APP_ID=<APPLICATION_ID>
|
||||
ALGOLIA_SECRET=<ADMIN_API_KEY>
|
||||
|
||||
### ElasticSearch (`elastic`)
|
||||
|
||||
|
|
@ -430,16 +395,16 @@ adds support for Scout (see: laravel-json-api/laravel#32).
|
|||
|
||||
1. Update kcal's `.env` file.
|
||||
|
||||
SCOUT_DRIVER=elastic
|
||||
ELASTIC_HOST=<HOST:PORT>
|
||||
ELASTIC_PORT=<PORT>
|
||||
SCOUT_DRIVER=elastic
|
||||
ELASTIC_HOST=<HOST:PORT>
|
||||
ELASTIC_PORT=<PORT>
|
||||
|
||||
Note: The `ELASTIC_PORT` variable is a convenience option specifically for
|
||||
Docker Compose configurations and is not strictly required.
|
||||
|
||||
1. Run Elastic's migrations.
|
||||
|
||||
php artisan elastic:migrate
|
||||
php artisan elastic:migrate
|
||||
|
||||
### Fallback (`null`)
|
||||
|
||||
|
|
@ -451,6 +416,10 @@ Set `SCOUT_DRIVER=null` in kcal's `.env` file to use the fallback driver.
|
|||
|
||||
## Development
|
||||
|
||||
### Dev Container
|
||||
|
||||
Clone the project in an IDE with Dev Container support and build the container.
|
||||
|
||||
### Laravel Sail
|
||||
|
||||
#### Prerequisites
|
||||
|
|
@ -463,20 +432,20 @@ Set `SCOUT_DRIVER=null` in kcal's `.env` file to use the fallback driver.
|
|||
|
||||
1. Clone the repository.
|
||||
|
||||
git clone https://github.com/kcal-app/kcal.git
|
||||
cd kcal
|
||||
git clone https://github.com/kcal-app/kcal.git
|
||||
cd kcal
|
||||
|
||||
1. Install development dependencies.
|
||||
|
||||
composer install
|
||||
composer install
|
||||
|
||||
1. Create a local `.env` file.
|
||||
|
||||
cp .env.local.example .env
|
||||
cp .env.example .env
|
||||
|
||||
1. Generate an app key.
|
||||
|
||||
php artisan key:generate
|
||||
php artisan key:generate
|
||||
|
||||
Verify that the `APP_KEY` variable has been set in `.env`. If has not, run
|
||||
`php artisan key:generate --show` and copy the key and append it to the
|
||||
|
|
@ -484,16 +453,16 @@ Set `SCOUT_DRIVER=null` in kcal's `.env` file to use the fallback driver.
|
|||
|
||||
1. Run it! :sailboat:
|
||||
|
||||
vendor/bin/sail up
|
||||
vendor/bin/sail up
|
||||
|
||||
1. (On first run) Run migrations.
|
||||
|
||||
vendor/bin/sail artisan migrate
|
||||
vendor/bin/sail artisan elastic:migrate
|
||||
vendor/bin/sail artisan migrate
|
||||
vendor/bin/sail artisan elastic:migrate
|
||||
|
||||
1. (On first run) Seed the database.
|
||||
|
||||
vendor/bin/sail artisan db:seed
|
||||
vendor/bin/sail artisan db:seed
|
||||
|
||||
The default username and password is `kcal` / `kcal`.
|
||||
|
||||
|
|
@ -502,6 +471,25 @@ Navigate to [http://127.0.0.1:8080](http://127.0.0.1:8080) to log in!
|
|||
Create a `docker-compose.override.yml` file to override any of the default settings
|
||||
provided for this environment.
|
||||
|
||||
### Custom console commands
|
||||
|
||||
#### `dev:cache-clear`
|
||||
|
||||
Executes the various cache clearing artisan commands:
|
||||
|
||||
- `cache:clear`
|
||||
- `config:clear`
|
||||
- `route:clear`
|
||||
- `view:clear`
|
||||
|
||||
#### `dev:reset`
|
||||
|
||||
Resets and seeds the database by executing the following artisan commands:
|
||||
|
||||
- `db:wipe`
|
||||
- `migrate`
|
||||
- `db:seed`
|
||||
|
||||
### Testing
|
||||
|
||||
Ensure that Sail is running (primarily to provide ElasticSearch):
|
||||
|
|
@ -510,25 +498,5 @@ Ensure that Sail is running (primarily to provide ElasticSearch):
|
|||
|
||||
Execute tests.
|
||||
|
||||
vendor/bin/sail artisan test --parallel
|
||||
|
||||
#### Caveats
|
||||
|
||||
In order to support parallel testing, tests are run using sqlite (even though Sail
|
||||
provides MySQL). To test with MySQL make a copy of `phpunit.xml.dist` as `phpunit.xml`
|
||||
and change:
|
||||
|
||||
```
|
||||
<server name="DB_CONNECTION" value="sqlite"/>
|
||||
<server name="DB_DATABASE" value=":memory:"/>
|
||||
```
|
||||
|
||||
to
|
||||
|
||||
```
|
||||
<server name="DB_CONNECTION" value="mysql"/>
|
||||
<server name="DB_HOST" value="db"/>
|
||||
```
|
||||
|
||||
Now running `vendor/bin/sail artisan test` will run tests with MySQL **but** tests
|
||||
cannot be run in parallel.
|
||||
vendor/bin/sail artisan dev:cache-clear
|
||||
vendor/bin/sail artisan test --parallel --recreate-databases
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
$tags = $request->get('tags', []);
|
||||
if (!empty($tags)) {
|
||||
$food->syncTags(explode(',', $tags));
|
||||
}
|
||||
elseif ($food->tags->isNotEmpty()) {
|
||||
$food->detachTags($food->tags);
|
||||
}
|
||||
|
||||
// Refresh and index updated tags.
|
||||
$food->fresh()->searchable();
|
||||
$food->updateTagsFromRequest($request);
|
||||
|
||||
session()->flash('message', "Food {$food->name} updated!");
|
||||
return redirect()->route('foods.show', $food);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ namespace App\Http\Controllers;
|
|||
use App\Models\Food;
|
||||
use App\Models\Recipe;
|
||||
use App\Search\Ingredient;
|
||||
use ElasticScoutDriverPlus\Builders\MultiMatchQueryBuilder;
|
||||
use ElasticScoutDriverPlus\Builders\TermsQueryBuilder;
|
||||
use Elastic\ScoutDriverPlus\Builders\MultiMatchQueryBuilder;
|
||||
use Elastic\ScoutDriverPlus\Builders\TermsQueryBuilder;
|
||||
use Elastic\ScoutDriverPlus\Support\Query;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -42,11 +43,10 @@ class IngredientPickerController extends Controller
|
|||
* Search using an ElasticSearch service.
|
||||
*/
|
||||
private function searchWithElasticSearch(string $term): Collection {
|
||||
return Food::boolSearch()
|
||||
->join(Recipe::class)
|
||||
$query = Query::bool()
|
||||
|
||||
// Attempt to match exact phrase first.
|
||||
->should('match_phrase', ['name' => $term])
|
||||
->should(Query::matchPhrase()->field('name')->query($term))
|
||||
|
||||
// Attempt multi-match search on all relevant fields with search-as-you-type on name.
|
||||
->should((new MultiMatchQueryBuilder())
|
||||
|
|
@ -57,10 +57,12 @@ class IngredientPickerController extends Controller
|
|||
->fuzziness('AUTO'))
|
||||
|
||||
// Attempt to match on any tags in the term.
|
||||
->should((new TermsQueryBuilder())
|
||||
->terms('tags', explode(' ', $term)))
|
||||
->should((new TermsQueryBuilder())->field('tags')->values(explode(' ', $term)))
|
||||
|
||||
// Get resulting models.
|
||||
->minimumShouldMatch(1);
|
||||
|
||||
return Food::searchQuery($query)
|
||||
->join(Recipe::class)
|
||||
->execute()
|
||||
->models();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use App\Support\Number;
|
|||
use App\Support\Nutrients;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -216,17 +217,7 @@ class RecipeController extends Controller
|
|||
$this->updateIngredients($recipe, $input);
|
||||
$this->updateIngredientSeparators($recipe, $input);
|
||||
$this->updateSteps($recipe, $input);
|
||||
|
||||
$tags = $request->get('tags', []);
|
||||
if (!empty($tags)) {
|
||||
$recipe->syncTags(explode(',', $tags));
|
||||
}
|
||||
elseif ($recipe->tags->isNotEmpty()) {
|
||||
$recipe->detachTags($recipe->tags);
|
||||
}
|
||||
|
||||
// Refresh and index updated tags.
|
||||
$recipe->fresh()->searchable();
|
||||
$recipe->updateTagsFromRequest($request);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
|
@ -360,6 +351,30 @@ class RecipeController extends Controller
|
|||
$recipe->ingredientSeparators()->saveMany($ingredient_separators);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm duplicating recipe.
|
||||
*/
|
||||
public function duplicateConfirm(Recipe $recipe): View {
|
||||
return view('recipes.duplicate')->with('recipe', $recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a recipe.
|
||||
*/
|
||||
public function duplicate(Request $request, Recipe $recipe): RedirectResponse
|
||||
{
|
||||
$attributes = $request->validate(['name' => ['required', 'string']]);
|
||||
|
||||
try {
|
||||
$new_recipe = $recipe->duplicate($attributes);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withInput()->withErrors($e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('recipes.show', $new_recipe)
|
||||
->with('message', "Recipe {$recipe->name} duplicated!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm removal of specified resource.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class Kernel extends HttpKernel
|
|||
protected $middleware = [
|
||||
// \App\Http\Middleware\TrustHosts::class,
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\Fruitcake\Cors\HandleCors::class,
|
||||
\Illuminate\Http\Middleware\HandleCors::class,
|
||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||
\App\Http\Middleware\TrimStrings::class,
|
||||
|
|
@ -31,9 +31,15 @@ class Kernel extends HttpKernel
|
|||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
\Spatie\Csp\AddCspHeaders::class,
|
||||
\App\Http\Middleware\DisableBrowserCache::class,
|
||||
],
|
||||
|
||||
'api' => [
|
||||
\App\Http\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
'throttle:api',
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
use Fideloper\Proxy\TrustProxies as Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Middleware\TrustProxies as Middleware;
|
||||
|
||||
class TrustProxies extends Middleware
|
||||
{
|
||||
|
|
@ -15,5 +15,10 @@ class TrustProxies extends Middleware
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $headers = Request::HEADER_X_FORWARDED_ALL;
|
||||
protected $headers =
|
||||
Request::HEADER_X_FORWARDED_FOR |
|
||||
Request::HEADER_X_FORWARDED_HOST |
|
||||
Request::HEADER_X_FORWARDED_PORT |
|
||||
Request::HEADER_X_FORWARDED_PROTO |
|
||||
Request::HEADER_X_FORWARDED_AWS_ELB;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ class StoreJournalEntryRequest extends FormRequest
|
|||
new InArray(Auth::user()->meals_enabled->pluck('value')->toArray())
|
||||
],
|
||||
'ingredients.amount' => ['required', 'array', new ArrayNotEmpty],
|
||||
'ingredients.amount.*' => ['required_with:ingredients.id.*', 'nullable', new StringIsPositiveDecimalOrFraction],
|
||||
'ingredients.amount.*' => [
|
||||
'required_with:ingredients.id.*',
|
||||
'nullable',
|
||||
new StringIsPositiveDecimalOrFraction
|
||||
],
|
||||
'ingredients.unit' => ['required', 'array'],
|
||||
'ingredients.unit.*' => ['required_with:ingredients.id.*'],
|
||||
'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable',
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ class UpdateUserRequest extends FormRequest
|
|||
$rules = [
|
||||
'username' => ['required', 'string', Rule::unique('users')->ignore($this->user)],
|
||||
'name' => ['required', 'string'],
|
||||
'password' => ['nullable', 'string', 'confirmed'],
|
||||
'password_confirmation' => ['nullable', 'string'],
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
'admin' => ['nullable', 'boolean'],
|
||||
'image' => ['nullable', 'file', 'mimes:jpg,png,gif'],
|
||||
'remove_image' => ['nullable', 'boolean'],
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
class FoodSchema extends SchemaProvider
|
||||
{
|
||||
|
|
@ -10,7 +10,7 @@ class FoodSchema extends SchemaProvider
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $resourceType = 'foods';
|
||||
protected string $resourceType = 'foods';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
class GoalSchema extends SchemaProvider
|
||||
{
|
||||
|
|
@ -10,7 +10,7 @@ class GoalSchema extends SchemaProvider
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $resourceType = 'goals';
|
||||
protected string $resourceType = 'goals';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
class IngredientAmountSchema extends SchemaProvider
|
||||
{
|
||||
|
|
@ -10,7 +10,7 @@ class IngredientAmountSchema extends SchemaProvider
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $resourceType = 'ingredient-amounts';
|
||||
protected string $resourceType = 'ingredient-amounts';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
class JournalEntrySchema extends SchemaProvider
|
||||
{
|
||||
|
|
@ -10,7 +10,7 @@ class JournalEntrySchema extends SchemaProvider
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $resourceType = 'journal-entries';
|
||||
protected string $resourceType = 'journal-entries';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
/**
|
||||
* Media schema.
|
||||
|
|
@ -18,7 +18,7 @@ class MediumSchema extends SchemaProvider
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $resourceType = 'media';
|
||||
protected string $resourceType = 'media';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
@ -42,7 +42,7 @@ class MediumSchema extends SchemaProvider
|
|||
'url' => $resource->getUrl(),
|
||||
'mimeType' => $resource->mime_type,
|
||||
'size' => $resource->size,
|
||||
'sizeFormatted' => $resource->getHumanReadableSizeAttribute(),
|
||||
'sizeFormatted' => $resource->human_readable_size,
|
||||
'manipulations' => $resource->manipulations,
|
||||
'customProperties' => $resource->custom_properties,
|
||||
'conversions' => [],
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
class RecipeSchema extends SchemaProvider
|
||||
{
|
||||
|
|
@ -10,7 +10,7 @@ class RecipeSchema extends SchemaProvider
|
|||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $resourceType = 'recipes';
|
||||
protected string $resourceType = 'recipes';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
class RecipeSeparatorSchema extends SchemaProvider
|
||||
{
|
||||
|
|
@ -10,7 +10,7 @@ class RecipeSeparatorSchema extends SchemaProvider
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $resourceType = 'recipe-separators';
|
||||
protected string $resourceType = 'recipe-separators';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
class RecipeStepSchema extends SchemaProvider
|
||||
{
|
||||
|
|
@ -10,7 +10,7 @@ class RecipeStepSchema extends SchemaProvider
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $resourceType = 'recipe-steps';
|
||||
protected string $resourceType = 'recipe-steps';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
class TagSchema extends SchemaProvider
|
||||
{
|
||||
|
|
@ -10,7 +10,7 @@ class TagSchema extends SchemaProvider
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $resourceType = 'tags';
|
||||
protected string $resourceType = 'tags';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\JsonApi\Schemas;
|
||||
|
||||
use Neomerx\JsonApi\Schema\SchemaProvider;
|
||||
use CloudCreativity\LaravelJsonApi\Schema\SchemaProvider;
|
||||
|
||||
class UserSchema extends SchemaProvider
|
||||
{
|
||||
|
|
@ -10,7 +10,7 @@ class UserSchema extends SchemaProvider
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $resourceType = 'users';
|
||||
protected string $resourceType = 'users';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@ use App\Models\Traits\Journalable;
|
|||
use App\Models\Traits\Sluggable;
|
||||
use App\Models\Traits\Taggable;
|
||||
use App\Support\Number;
|
||||
use ElasticScoutDriverPlus\QueryDsl;
|
||||
use Elastic\ScoutDriverPlus\Searchable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
* App\Models\Food
|
||||
|
|
@ -74,13 +73,13 @@ use Laravel\Scout\Searchable;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|Food withUniqueSlugConstraints(\Illuminate\Database\Eloquent\Model $model, string $attribute, array $config, string $slug)
|
||||
* @method static \Database\Factories\FoodFactory factory(...$parameters)
|
||||
* @property-read \Illuminate\Support\Collection $units_supported
|
||||
* @property-read string $ingredient_id
|
||||
*/
|
||||
final class Food extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use Ingredient;
|
||||
use Journalable;
|
||||
use QueryDsl;
|
||||
use Searchable;
|
||||
use Sluggable;
|
||||
use Taggable;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ final class Goal extends Model
|
|||
'fat' => 'float',
|
||||
'protein' => 'float',
|
||||
'sodium' => 'float',
|
||||
// @todo Determine why `user_id` is a string and fix it.
|
||||
'user_id' => 'int',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ use App\Models\Traits\Sluggable;
|
|||
use App\Models\Traits\Taggable;
|
||||
use App\Support\Number;
|
||||
use App\Support\Nutrients;
|
||||
use ElasticScoutDriverPlus\QueryDsl;
|
||||
use Elastic\ScoutDriverPlus\Searchable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Image\Manipulations;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
|
@ -83,6 +83,7 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
|||
* @property float|null $volume
|
||||
* @property-read string|null $volume_formatted
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|Recipe whereVolume($value)
|
||||
* @property-read string $ingredient_id
|
||||
*/
|
||||
final class Recipe extends Model implements HasMedia
|
||||
{
|
||||
|
|
@ -91,7 +92,6 @@ final class Recipe extends Model implements HasMedia
|
|||
use Ingredient;
|
||||
use InteractsWithMedia;
|
||||
use Journalable;
|
||||
use QueryDsl;
|
||||
use Searchable;
|
||||
use Sluggable;
|
||||
use Taggable;
|
||||
|
|
@ -263,4 +263,55 @@ final class Recipe extends Model implements HasMedia
|
|||
->optimize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates the recipe, updating provided attributes.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function duplicate(array $attributes): Recipe {
|
||||
/** @var \App\Models\Recipe $recipe */
|
||||
$recipe = $this->replicate();
|
||||
$recipe->fill($attributes);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($recipe) {
|
||||
$recipe->save();
|
||||
|
||||
$recipe->tags()->attach($this->tags);
|
||||
|
||||
$ingredient_amounts = [];
|
||||
foreach ($this->ingredientAmounts as $ia) {
|
||||
$new_ia = $ia->replicate();
|
||||
$new_ia->parent_id = $recipe->id;
|
||||
$new_ia->parent_type = Recipe::class;
|
||||
$ingredient_amounts[] = $new_ia;
|
||||
}
|
||||
$recipe->ingredientAmounts()->saveMany($ingredient_amounts);
|
||||
|
||||
$steps = [];
|
||||
foreach ($this->steps as $step) {
|
||||
$new_step = $step->replicate();
|
||||
$new_step->recipe_id = $recipe->id;
|
||||
$steps[] = $new_step;
|
||||
}
|
||||
$recipe->steps()->saveMany($steps);
|
||||
|
||||
$separators = [];
|
||||
foreach ($this->separators as $separator) {
|
||||
$new_separator = $separator->replicate();
|
||||
$new_separator->recipe_id = $recipe->id;
|
||||
$separators[] = $new_separator;
|
||||
}
|
||||
$recipe->separators()->saveMany($separators);
|
||||
|
||||
$recipe->push();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $recipe;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
use Spatie\Tags\Tag as TagBase;
|
||||
|
||||
/**
|
||||
|
|
@ -31,8 +32,26 @@ use Spatie\Tags\Tag as TagBase;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|Tag whereUpdatedAt($value)
|
||||
* @method static Builder|Tag withType(?string $type = null)
|
||||
* @mixin \Eloquent
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Food[] $foods
|
||||
* @property-read int|null $foods_count
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Recipe[] $recipes
|
||||
* @property-read int|null $recipes_count
|
||||
*/
|
||||
final class Tag extends TagBase
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Get all foods related to this tag.
|
||||
*/
|
||||
public function foods(): MorphToMany {
|
||||
return $this->morphedByMany(Food::class, 'taggable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recipes related to this tag.
|
||||
*/
|
||||
public function recipes(): MorphToMany {
|
||||
return $this->morphedByMany(Recipe::class, 'taggable');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,21 +8,28 @@ use Illuminate\Database\Eloquent\Collection as DatabaseCollection;
|
|||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Tags\Tag;
|
||||
|
||||
trait Ingredient
|
||||
{
|
||||
/**
|
||||
* Add special `type` attribute to appends.
|
||||
* Add special attributes to appends.
|
||||
*/
|
||||
public function initializeIngredient(): void {
|
||||
$this->appends[] = 'ingredient_id';
|
||||
$this->appends[] = 'type';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the class short name and ID combo to ensure uniqueness between models.
|
||||
*/
|
||||
public function getIngredientIdAttribute(): string {
|
||||
return Str::lower((new \ReflectionClass($this))->getShortName()) . "-$this->id";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name.
|
||||
*
|
||||
* This is necessary e.g. to provide data in ingredient picker responses.
|
||||
*/
|
||||
public function getTypeAttribute(): string {
|
||||
return $this::class;
|
||||
|
|
@ -45,9 +52,9 @@ trait Ingredient
|
|||
public static function getTagTotals(string $locale = null): DatabaseCollection {
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
return Tag::query()->join('taggables', 'taggables.tag_id', '=', 'id')
|
||||
->select(['id', 'name', DB::raw('count(*) as total')])
|
||||
->select(['name', DB::raw('count(*) as total')])
|
||||
->where('taggables.taggable_type', '=', static::class)
|
||||
->groupBy('id')
|
||||
->groupBy('name')
|
||||
->orderBy("name->{$locale}")
|
||||
->get();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Models\Traits;
|
|||
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Spatie\Tags\HasTags;
|
||||
|
||||
trait Taggable
|
||||
|
|
@ -27,4 +28,29 @@ trait Taggable
|
|||
->morphToMany(self::getTagClassName(), 'taggable', 'taggables', null, 'tag_id')
|
||||
->orderBy('order_column');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates tags from a request with a "tags" parameter value for any bag.
|
||||
*/
|
||||
public function updateTagsFromRequest(Request $request): void {
|
||||
$tags_original = $this->tags;
|
||||
|
||||
$tags = $request->get('tags', []);
|
||||
if (!empty($tags)) {
|
||||
$this->syncTags(explode(',', $tags));
|
||||
}
|
||||
elseif ($this->tags->isNotEmpty()) {
|
||||
$this->detachTags($this->tags);
|
||||
}
|
||||
|
||||
// Refresh and index updated tags.
|
||||
$this->refresh()->searchable();
|
||||
|
||||
// Delete any removed tags that are no longer in use.
|
||||
$tags_original->diff($this->tags)->each(function (Tag $tag) {
|
||||
if ($tag->foods->isEmpty() && $tag->recipes->isEmpty()) {
|
||||
$tag->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
|||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
|
@ -56,6 +56,8 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
|||
* @property \Illuminate\Support\Collection|null $meals
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereMeals($value)
|
||||
* @property-read Collection $meals_enabled
|
||||
* @property string|null $api_token
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|User whereApiToken($value)
|
||||
*/
|
||||
final class User extends Authenticatable implements HasMedia
|
||||
{
|
||||
|
|
@ -71,6 +73,9 @@ final class User extends Authenticatable implements HasMedia
|
|||
static::creating(function (User $user) {
|
||||
// Set default meals configuration.
|
||||
$user->meals = User::getDefaultMeals();
|
||||
|
||||
// Set default API token.
|
||||
$user->api_token = Str::random(32);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -78,17 +83,19 @@ final class User extends Authenticatable implements HasMedia
|
|||
* @inheritdoc
|
||||
*/
|
||||
protected $fillable = [
|
||||
'username',
|
||||
'password',
|
||||
'name',
|
||||
'meals',
|
||||
'admin',
|
||||
'api_token',
|
||||
'meals',
|
||||
'name',
|
||||
'password',
|
||||
'username',
|
||||
];
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected $hidden = [
|
||||
'api_token',
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
use App\Models\Goal;
|
||||
use App\Models\User;
|
||||
use App\Policies\GoalPolicy;
|
||||
use App\Policies\UserPolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
|
||||
|
|
@ -13,6 +15,7 @@ class AuthServiceProvider extends ServiceProvider
|
|||
* @inheritdoc
|
||||
*/
|
||||
protected $policies = [
|
||||
Goal::class => GoalPolicy::class,
|
||||
User::class => UserPolicy::class,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"license": "MPL-2.0",
|
||||
"require": {
|
||||
"php": "^8.0",
|
||||
"php": "^8.2",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"algolia/algoliasearch-client-php": "^2.7",
|
||||
"algolia/scout-extended": "^1.15",
|
||||
"babenkoivan/elastic-migrations": "^1.4",
|
||||
"babenkoivan/elastic-scout-driver": "^1.3",
|
||||
"babenkoivan/elastic-scout-driver-plus": "^2.0",
|
||||
"cloudcreativity/laravel-json-api": "^3.2",
|
||||
"cviebrock/eloquent-sluggable": "^8.0",
|
||||
"algolia/algoliasearch-client-php": "^3.2",
|
||||
"algolia/scout-extended": "^3.0",
|
||||
"babenkoivan/elastic-migrations": "^3.0",
|
||||
"babenkoivan/elastic-scout-driver": "^3.0",
|
||||
"babenkoivan/elastic-scout-driver-plus": "^4.0",
|
||||
"cloudcreativity/laravel-json-api": "^6.0",
|
||||
"cviebrock/eloquent-sluggable": "^10.0",
|
||||
"doctrine/dbal": "^3.0",
|
||||
"fideloper/proxy": "^4.4",
|
||||
"fruitcake/laravel-cors": "^2.0",
|
||||
"guzzlehttp/guzzle": "^7.0.1",
|
||||
"laravel/framework": "^8.12",
|
||||
"laravel/scout": "^8.6",
|
||||
"laravel/tinker": "^2.5",
|
||||
"league/flysystem-aws-s3-v3": "~1.0",
|
||||
"laravel/framework": "^10.0",
|
||||
"laravel/scout": "^10.0",
|
||||
"laravel/tinker": "^2.7",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"phospr/fraction": "^1.2",
|
||||
"spatie/laravel-medialibrary": "^9.0.0",
|
||||
"spatie/laravel-tags": "^3.0"
|
||||
"spatie/laravel-csp": "^2.6",
|
||||
"spatie/laravel-medialibrary": "^10.0",
|
||||
"spatie/laravel-tags": "^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-ide-helper": "^2.9",
|
||||
"brianium/paratest": "^6.2",
|
||||
"cloudcreativity/json-api-testing": "^3.2",
|
||||
"facade/ignition": "^2.5",
|
||||
"cloudcreativity/json-api-testing": "^5.0",
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
"laravel/breeze": "^1.0",
|
||||
"laravel/sail": "^0.0.5",
|
||||
"laravel/sail": "^1.10",
|
||||
"mockery/mockery": "^1.4.2",
|
||||
"nunomaduro/collision": "^5.0",
|
||||
"nunomaduro/larastan": "^0.6.13",
|
||||
"nunomaduro/collision": "^6.1",
|
||||
"nunomaduro/larastan": "^2.0",
|
||||
"php-coveralls/php-coveralls": "^2.4",
|
||||
"phpunit/phpunit": "^9.3.3"
|
||||
"phpunit/phpunit": "^9.3.3",
|
||||
"spatie/laravel-ignition": "^2.0"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
|
|
|
|||
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_test*
|
||||
*.sqlite-journal
|
||||
|
|
|
|||
|
|
@ -22,12 +22,12 @@ class RecipeFactory extends Factory
|
|||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$description = htmlspecialchars($this->faker->realText(500));
|
||||
$description = $this->faker->realText(500);
|
||||
$volumes = [1/4, 1/3, 1/2, 2/3, 3/4, 1, 1 + 1/2, 1 + 3/4, 2, 2 + 1/2, 3, 3 + 1/2, 4, 5];
|
||||
return [
|
||||
'name' => Words::randomWords(Arr::random(['npan', 'npn', 'anpn'])),
|
||||
'description' => "<p>{$description}</p>",
|
||||
'description_delta' => '{"ops":[{"insert":"' . htmlentities($description) . '\n"}]}"',
|
||||
'description_delta' => '{"ops":[{"insert":"' . $description . '\n"}]}',
|
||||
'time_prep' => $this->faker->numberBetween(0, 20),
|
||||
'time_cook' => $this->faker->numberBetween(0, 90),
|
||||
'source' => $this->faker->optional()->url,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
app:
|
||||
build:
|
||||
context: ./vendor/laravel/sail/runtimes/8.0
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP}'
|
||||
image: sail-8.0/app
|
||||
ports:
|
||||
- '${APP_PORT:-8080}:80'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER}'
|
||||
LARAVEL_SAIL: 1
|
||||
volumes:
|
||||
- '.:/var/www/html'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
- elasticsearch
|
||||
db:
|
||||
image: 'mysql:8.0'
|
||||
ports:
|
||||
- '${DB_PORT:-3306}:3306'
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'
|
||||
MYSQL_DATABASE: '${DB_DATABASE:-kcal}'
|
||||
MYSQL_USER: '${DB_USERNAME:-kcal}'
|
||||
MYSQL_PASSWORD: '${DB_PASSWORD:-kcal}'
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
|
||||
volumes:
|
||||
- 'mysql-data:/var/lib/mysql'
|
||||
networks:
|
||||
- sail
|
||||
phpmyadmin:
|
||||
image: phpmyadmin
|
||||
ports:
|
||||
- 8081:80
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- db
|
||||
elasticsearch:
|
||||
image: 'elasticsearch:7.12.0'
|
||||
environment:
|
||||
- xpack.security.enabled=false
|
||||
- discovery.type=single-node
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
cap_add:
|
||||
- IPC_LOCK
|
||||
volumes:
|
||||
- 'elasticsearch-data:/usr/share/elasticsearch/data'
|
||||
ports:
|
||||
- '${ELASTIC_PORT:-9200}:9200'
|
||||
networks:
|
||||
- sail
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
ports:
|
||||
- '${REDIS_PORT:-6379}:6379'
|
||||
volumes:
|
||||
- 'redis-data:/data'
|
||||
networks:
|
||||
- sail
|
||||
app:
|
||||
build:
|
||||
context: ./vendor/laravel/sail/runtimes/8.2
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP:-1000}'
|
||||
image: sail-8.2/app
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
ports:
|
||||
- '${APP_PORT:-80}:80'
|
||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER:-1000}'
|
||||
LARAVEL_SAIL: 1
|
||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||
volumes:
|
||||
- '.:/var/www/html'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
db:
|
||||
image: 'mariadb:10'
|
||||
ports:
|
||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
||||
environment:
|
||||
MARIADB_DATABASE: '${DB_DATABASE:-kcal}'
|
||||
MARIADB_PASSWORD: '${DB_PASSWORD:-kcal}'
|
||||
MARIADB_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'
|
||||
MARIADB_USER: '${DB_USERNAME:-kcal}'
|
||||
volumes:
|
||||
- 'db-data:/var/lib/mysql'
|
||||
- './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- mysqladmin
|
||||
- ping
|
||||
- '-p${DB_PASSWORD}'
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
ports:
|
||||
- '${FORWARD_REDIS_PORT:-6379}:6379'
|
||||
volumes:
|
||||
- 'redis-data:/data'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
elasticsearch:
|
||||
image: 'elasticsearch:7.17.17'
|
||||
environment:
|
||||
- xpack.security.enabled=false
|
||||
- discovery.type=single-node
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
volumes:
|
||||
- 'elasticsearch-data:/usr/share/elasticsearch/data'
|
||||
ports:
|
||||
- '${ELASTIC_PORT:-9200}:9200'
|
||||
- '${ELASTIC_BIN_PORT:-9300}:9300'
|
||||
networks:
|
||||
- sail
|
||||
phpmyadmin:
|
||||
image: phpmyadmin
|
||||
ports:
|
||||
- '8081:80'
|
||||
environment:
|
||||
PBA_HOST: db
|
||||
PMA_PORT: '${FORWARD_DB_PORT:-3306}:3306'
|
||||
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
sail:
|
||||
driver: bridge
|
||||
sail:
|
||||
driver: bridge
|
||||
volumes:
|
||||
elasticsearch-data:
|
||||
driver: local
|
||||
mysql-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
db-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
elasticsearch-data:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use ElasticAdapter\Indices\Mapping;
|
||||
use ElasticAdapter\Indices\Settings;
|
||||
use ElasticMigrations\Facades\Index;
|
||||
use ElasticMigrations\MigrationInterface;
|
||||
use Elastic\Adapter\Indices\Mapping;
|
||||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
|
||||
final class CreateFoodsIndex implements MigrationInterface
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use ElasticAdapter\Indices\Mapping;
|
||||
use ElasticAdapter\Indices\Settings;
|
||||
use ElasticMigrations\Facades\Index;
|
||||
use ElasticMigrations\MigrationInterface;
|
||||
use Elastic\Adapter\Indices\Mapping;
|
||||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
|
||||
final class CreateRecipesIndex implements MigrationInterface
|
||||
{
|
||||
|
|
|
|||
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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.3.2",
|
||||
"@tailwindcss/typography": "^0.4.1",
|
||||
"alpinejs": "^2.8.2",
|
||||
"autoprefixer": "^10.2.6",
|
||||
"axios": "^0.21.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"alpinejs": "^3.13.10",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"axios": "^1.6.8",
|
||||
"cross-env": "^7.0",
|
||||
"laravel-mix": "^6.0.19",
|
||||
"laravel-mix": "^6.0.49",
|
||||
"lodash": "^4.17.21",
|
||||
"postcss-import": "^14.0.2",
|
||||
"quill": "^1.3.7",
|
||||
"resolve-url-loader": "^4.0.0",
|
||||
"tailwindcss": "^2.1.2",
|
||||
"vue-template-compiler": "^2.6.12"
|
||||
"postcss-import": "^16.1.0",
|
||||
"quill": "^2.0.1",
|
||||
"resolve-url-loader": "^5.0.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"vue-template-compiler": "^2.7.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shopify/draggable": "^1.0.0-beta.12",
|
||||
"alpine-magic-helpers": "^1.2.2"
|
||||
"@shopify/draggable": "^1.1.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
</coverage>
|
||||
<php>
|
||||
<server name="APP_ENV" value="testing"/>
|
||||
<server name="DB_USERNAME" value="root"/>
|
||||
<server name="BCRYPT_ROUNDS" value="4"/>
|
||||
<server name="CACHE_DRIVER" value="array"/>
|
||||
<server name="MAIL_MAILER" value="array"/>
|
||||
|
|
@ -26,9 +27,5 @@
|
|||
<server name="SESSION_DRIVER" value="elastic"/>
|
||||
<server name="SESSION_DRIVER" value="array"/>
|
||||
<server name="TELESCOPE_ENABLED" value="false"/>
|
||||
|
||||
<!-- @todo Figure out how to do MySQL parallel testing inside Sail. -->
|
||||
<server name="DB_CONNECTION" value="sqlite"/>
|
||||
<server name="DB_DATABASE" value=":memory:"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
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
|
||||
* 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('alpine-magic-helpers');
|
||||
require('alpinejs');
|
||||
|
|
|
|||
|
|
@ -1,28 +1,11 @@
|
|||
// Load Lodash.
|
||||
window._ = require('lodash');
|
||||
|
||||
/**
|
||||
* We'll load the axios HTTP library which allows us to easily issue requests
|
||||
* to our Laravel back-end. This library automatically handles sending the
|
||||
* CSRF token as a header based on the value of the "XSRF" token cookie.
|
||||
*/
|
||||
|
||||
// Load Axios.
|
||||
window.axios = require('axios');
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
/**
|
||||
* Echo exposes an expressive API for subscribing to channels and listening
|
||||
* for events that are broadcast by Laravel. Echo and event broadcasting
|
||||
* allows your team to easily build robust real-time web applications.
|
||||
*/
|
||||
|
||||
// import Echo from 'laravel-echo';
|
||||
|
||||
// window.Pusher = require('pusher-js');
|
||||
|
||||
// window.Echo = new Echo({
|
||||
// broadcaster: 'pusher',
|
||||
// key: process.env.MIX_PUSHER_APP_KEY,
|
||||
// cluster: process.env.MIX_PUSHER_APP_CLUSTER,
|
||||
// forceTLS: true
|
||||
// });
|
||||
// Load AlpineJS.
|
||||
import Alpine from 'alpinejs';
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
@props(['status'])
|
||||
|
||||
@if ($status)
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-emerald-600']) }}>
|
||||
{{ $status }}
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
<x-button-link.base :attributes="$attributes" class="text-white bg-green-800 hover:bg-green-700 active:bg-green-900 focus:border-green-900 ring-green-300">
|
||||
<x-button-link.base :attributes="$attributes" class="text-white bg-emerald-800 hover:bg-emerald-700 active:bg-emerald-900 focus:border-emerald-900 ring-emerald-300">
|
||||
{{ $slot }}
|
||||
</x-button-link.base>
|
||||
|
|
|
|||
|
|
@ -21,18 +21,18 @@ switch ($width) {
|
|||
}
|
||||
@endphp
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @click.away="open = false" @close.stop="open = false">
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
|
||||
<div @click="open = ! open">
|
||||
{{ $trigger }}
|
||||
</div>
|
||||
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
|
||||
style="display: none;"
|
||||
@click="open = false">
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@
|
|||
autocapitalize="none"
|
||||
inputmode="search"
|
||||
x-ref="ingredients_name"
|
||||
x-spread="search" />
|
||||
x-bind="search" />
|
||||
</div>
|
||||
<div x-show="searching" x-cloak>
|
||||
<div class="absolute border-2 border-gray-500 border-b-0 bg-white"
|
||||
x-spread="ingredient">
|
||||
<template x-for="result in results" :key="result.id">
|
||||
<div class="p-1 border-b-2 border-gray-500 hover:bg-yellow-300 cursor-pointer" x-bind:data-id="result.id">
|
||||
x-bind="ingredient">
|
||||
<template x-for="result in results" :key="result.ingredient_id">
|
||||
<div class="p-1 border-b-2 border-gray-500 hover:bg-amber-300 cursor-pointer" x-bind:data-id="result.id">
|
||||
<div class="pointer-events-none">
|
||||
<div>
|
||||
<span class="font-bold" x-text="result.name"></span><span class="text-gray-600" x-text="', ' + result.detail" x-show="result.detail"></span>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
<button {{ $attributes->merge(['type' => 'submit', 'class' => "inline-flex items-center border border-transparent rounded-md font-semibold text-xs text-green-500 tracking-widest hover:text-green-700 active:text-green-900 focus:outline-none focus:border-green-900 focus:ring ring-green-300 disabled:opacity-25 transition ease-in-out duration-150"]) }}>
|
||||
<button {{ $attributes->merge(['type' => 'submit', 'class' => "inline-flex items-center border border-transparent rounded-md font-semibold text-xs text-emerald-500 tracking-widest hover:text-emerald-700 active:text-emerald-900 focus:outline-none focus:border-emerald-900 focus:ring ring-emerald-300 disabled:opacity-25 transition ease-in-out duration-150"]) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<nav class="md:w-1/4">
|
||||
<x-inputs.input name="search"
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
class="bg-blue-800 hover:bg-blue-700 active:bg-blue-900 focus:border-blue-900 ring-blue-300"
|
||||
x-show="morePages"
|
||||
x-cloak
|
||||
@click.prevent="loadMore()">
|
||||
@click.prevent="loadMore">
|
||||
Load more
|
||||
</x-inputs.button>
|
||||
</section>
|
||||
|
|
@ -51,8 +51,8 @@
|
|||
@once
|
||||
@push('scripts')
|
||||
<script type="text/javascript">
|
||||
let searchView = () => {
|
||||
return {
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('searchView', () => ({
|
||||
results: [],
|
||||
number: 1,
|
||||
size: 12,
|
||||
|
|
@ -60,6 +60,9 @@
|
|||
searchTerm: null,
|
||||
searching: false,
|
||||
filterTags: [],
|
||||
init() {
|
||||
this.loadMore();
|
||||
},
|
||||
resetPagination() {
|
||||
this.number = 1;
|
||||
this.morePages = false;
|
||||
|
|
@ -109,8 +112,8 @@
|
|||
}
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<div x-data data-tags="{{ $defaultTags ?? '[]' }}">
|
||||
<div x-data="tagSelect()" x-init="init('parentEl')" @click.away="clearSearch()" @keydown.escape="clearSearch()">
|
||||
<div x-data data-tags="{!! $defaultTags ?? '[]' !!}">
|
||||
<div x-data="tagSelect()" @click.outside="clearSearch()" @keydown.escape="clearSearch()">
|
||||
<div class="relative" @keydown.enter.prevent="addTag(searchTerm)">
|
||||
<x-inputs.input type="hidden"
|
||||
name="tags"
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
@endif
|
||||
@if(!$food->ingredientAmountRelationships->isEmpty())
|
||||
<div class="flex space-x-2 items-center text-lg">
|
||||
<div class="text-yellow-500">
|
||||
<div class="text-amber-500">
|
||||
<svg class="h-8 w-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
</x-slot>
|
||||
<x-search-view :route="route('api:v1:foods.index')" :tags="$tags">
|
||||
<x-slot name="results">
|
||||
<template x-for="food in results" :key="food">
|
||||
<template x-for="food in results" :key="food.slug">
|
||||
<article class="p-1 border-2 border-black font-sans">
|
||||
<h1 class="text-2xl lowercase font-extrabold leading-none">
|
||||
<a x-bind:href="food.showUrl"
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@
|
|||
</div>
|
||||
</section>
|
||||
<section class="flex flex-row space-x-2 justify-around md:flex-col md:space-y-2 md:space-x-0">
|
||||
<x-log-journalable :journalable="$food"></x-log-journalable>
|
||||
<x-button-link.gray href="{{ route('foods.edit', $food) }}">
|
||||
Edit Food
|
||||
</x-button-link.gray>
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@
|
|||
</x-slot>
|
||||
<form method="POST" action="{{ route('journal-entries.store') }}">
|
||||
@csrf
|
||||
<div x-data x-init="initJournalEntries($el);" class="space-y-4">
|
||||
<div x-data x-ref="root" x-init="initJournalEntries($refs.root)" class="space-y-4">
|
||||
@foreach($ingredients as $ingredient)
|
||||
@include('journal-entries.partials.entry-item-input', $ingredient)
|
||||
@endforeach
|
||||
<div class="journal-entry-template hidden">
|
||||
@include('journal-entries.partials.entry-item-input', ['default_date' => $default_date])
|
||||
</div>
|
||||
<x-inputs.icon-green type="button" class="add-entry-item" x-on:click="addEntryNode($el);">
|
||||
<x-inputs.icon-green type="button" class="add-entry-item" x-on:click="addEntryNode($refs.root);">
|
||||
<svg class="h-10 w-10 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
<x-inputs.input type="checkbox" name="group_entries" class="h-5 w-5" value="1" />
|
||||
<span class="ml-2">Group entries by day and meal</span>
|
||||
</x-inputs.label>
|
||||
<x-inputs.button x-on:click="removeTemplate($el);">Add entries</x-inputs.button>
|
||||
<x-inputs.button x-on:click="removeTemplate($refs.root);">Add entries</x-inputs.button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@
|
|||
</a>
|
||||
</div>
|
||||
<div class="text-base text-gray-500">
|
||||
<form x-data method="GET" action="{{ route('journal-entries.index') }}">
|
||||
<form x-data x-ref="root" method="GET" action="{{ route('journal-entries.index') }}">
|
||||
<x-inputs.input name="date"
|
||||
type="date"
|
||||
class="border-0 shadow-none p-0 text-center"
|
||||
:value="$date->toDateString()"
|
||||
x-on:change="$el.submit();"
|
||||
x-on:change="$refs.root.submit();"
|
||||
required />
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -125,7 +125,7 @@
|
|||
:selectedValue="$currentGoal?->id ?? null">
|
||||
</x-inputs.select>
|
||||
<div class="flex items-center justify-start mt-4">
|
||||
<x-inputs.button class="bg-green-800 hover:bg-green-700">Change Goal</x-inputs.button>
|
||||
<x-inputs.button class="bg-emerald-800 hover:bg-emerald-700">Change Goal</x-inputs.button>
|
||||
<x-button-link.red class="ml-3" x-on:click="showGoalChangeForm = !showGoalChangeForm">
|
||||
Cancel
|
||||
</x-button-link.red>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
<!-- Page Content -->
|
||||
<main>
|
||||
@if(session()->has('message'))
|
||||
<div class="bg-green-200 p-2 mb-2">
|
||||
<div class="bg-emerald-200 p-2 mb-2">
|
||||
{{ session()->get('message') }}
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@
|
|||
<x-dropdown-link :href="route('users.index')">Manage Users</x-dropdown-link>
|
||||
@endcan
|
||||
<hr />
|
||||
<form method="POST" action="{{ route('logout') }}" x-data>
|
||||
<form method="POST" action="{{ route('logout') }}" x-data x-ref="root">
|
||||
@csrf
|
||||
<x-dropdown-link :href="route('logout')" @click.prevent="$el.closest('form').submit();">Logout</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('logout')" @click.prevent="$refs.root.closest('form').submit();">Logout</x-dropdown-link>
|
||||
</form>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@
|
|||
</a>
|
||||
</div>
|
||||
@endif
|
||||
<div class="mt-2 text-gray-500">
|
||||
{{ $user->name }}
|
||||
<div class="mt-2">
|
||||
<p class="mt-2">{{ $user->name }}</p>
|
||||
@if($user->id === Auth::user()->id)
|
||||
<p class="mt-2"><strong>API key</strong>: {{ $user->api_token }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<div class="flex flex-col space-y-2 mt-2 text-lg">
|
||||
@if(!$recipe->ingredientAmountRelationships->isEmpty())
|
||||
<div class="flex space-x-2 items-center text-lg">
|
||||
<div class="text-yellow-500">
|
||||
<div class="text-amber-500">
|
||||
<svg class="h-8 w-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h1>
|
||||
</x-slot>
|
||||
<form x-data method="POST" enctype="multipart/form-data" action="{{ ($recipe->exists ? route('recipes.update', $recipe) : route('recipes.store')) }}">
|
||||
<form x-data x-ref="root" method="POST" enctype="multipart/form-data" action="{{ ($recipe->exists ? route('recipes.update', $recipe) : route('recipes.store')) }}">
|
||||
@if ($recipe->exists)@method('put')@endif
|
||||
@csrf
|
||||
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
|
||||
<!-- Ingredients -->
|
||||
<h3 class="mt-6 mb-2 font-extrabold text-lg">Ingredients</h3>
|
||||
<div x-data class="ingredients space-y-4">
|
||||
<div x-data x-ref="ingredients" class="ingredients space-y-4">
|
||||
@forelse($ingredients_list->sortBy('weight') as $item)
|
||||
@if($item['type'] === 'ingredient')
|
||||
@include('recipes.partials.ingredient-input', $item)
|
||||
|
|
@ -133,20 +133,20 @@
|
|||
</div>
|
||||
</div>
|
||||
<x-inputs.button type="button"
|
||||
class="bg-green-800 hover:bg-green-700 active:bg-green-900 focus:border-green-900 ring-green-300"
|
||||
x-on:click="addNodeFromTemplate($el, 'ingredient');">
|
||||
class="bg-emerald-800 hover:bg-emerald-700 active:bg-emerald-900 focus:border-emerald-900 ring-emerald-300"
|
||||
x-on:click="addNodeFromTemplate($refs.ingredients, 'ingredient');">
|
||||
Add Ingredient
|
||||
</x-inputs.button>
|
||||
<x-inputs.button type="button"
|
||||
class="bg-blue-800 hover:bg-blue-700 active:bg-blue-900 focus:border-blue-900 ring-blue-300"
|
||||
x-on:click="addNodeFromTemplate($el, 'separator');">
|
||||
x-on:click="addNodeFromTemplate($refs.ingredients, 'separator');">
|
||||
Add Separator
|
||||
</x-inputs.button>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<h3 class="mt-6 mb-2 font-extrabold text-lg">Steps</h3>
|
||||
<div x-data class="steps">
|
||||
<div x-data x-ref="steps" class="steps">
|
||||
@forelse($steps as $step)
|
||||
@include('recipes.partials.step-input', $step)
|
||||
@empty
|
||||
|
|
@ -158,14 +158,14 @@
|
|||
</div>
|
||||
</div>
|
||||
<x-inputs.button type="button"
|
||||
class="bg-green-800 hover:bg-green-700 active:bg-green-900 focus:border-green-900 ring-green-300"
|
||||
x-on:click="addNodeFromTemplate($el, 'step');">
|
||||
class="bg-emerald-800 hover:bg-emerald-700 active:bg-emerald-900 focus:border-emerald-900 ring-emerald-300"
|
||||
x-on:click="addNodeFromTemplate($refs.steps, 'step');">
|
||||
Add Step
|
||||
</x-inputs.button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<x-inputs.button x-on:click="prepareForm($el);" class="ml-3">
|
||||
<x-inputs.button x-on:click="prepareForm($refs.root);" class="ml-3">
|
||||
{{ ($recipe->exists ? 'Save' : 'Add') }}
|
||||
</x-inputs.button>
|
||||
</div>
|
||||
|
|
@ -187,11 +187,11 @@
|
|||
<script type="text/javascript">
|
||||
|
||||
// Enforce inline (style-base) alignment.
|
||||
const AlignStyle = Quill.import('attributors/style/align');
|
||||
Quill.register(AlignStyle, true);
|
||||
const AlignStyle = Quill.default.import('attributors/style/align');
|
||||
Quill.default.register(AlignStyle, true);
|
||||
|
||||
// Activate Quill editor.
|
||||
const description = new Quill('.quill-editor', {
|
||||
const description = new Quill.default('.quill-editor', {
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ 'header': [1, 2, 3, 4, false] }],
|
||||
|
|
@ -209,7 +209,9 @@
|
|||
});
|
||||
try {
|
||||
description.setContents(JSON.parse(document.querySelector('input[name="description_delta"]').value));
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
// Activate ingredient sortable.
|
||||
const ingredientsSortable = new Draggable.Sortable(document.querySelector('.ingredients'), {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
</x-slot>
|
||||
<x-search-view :route="route('api:v1:recipes.index')" :tags="$tags">
|
||||
<x-slot name="results">
|
||||
<template x-for="recipe in results" :key="recipe">
|
||||
<template x-for="recipe in results" :key="recipe.slug">
|
||||
<article class="p-1 border-2 border-black font-sans">
|
||||
<h1 class="text-2xl font-extrabold">
|
||||
<a x-bind:href="recipe.showUrl"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
@php use App\Models\IngredientAmount; @endphp
|
||||
@php use App\Support\Number; @endphp
|
||||
@php use App\Models\Recipe; @endphp
|
||||
@php use App\Models\RecipeSeparator; @endphp
|
||||
<x-app-layout>
|
||||
<x-slot name="title">{{ $recipe->name }}</x-slot>
|
||||
@if(!empty($feature_image))
|
||||
|
|
@ -9,7 +13,7 @@
|
|||
</h1>
|
||||
</x-slot>
|
||||
<div class="flex flex-col-reverse justify-between md:flex-row md:space-x-4">
|
||||
<div class="flex-1" x-data="{showNutrientsSummary: false}">
|
||||
<div class="flex-1" x-data="{ showNutrientsSummary: false }">
|
||||
@if($recipe->time_total > 0)
|
||||
<section class="flex justify-between mb-2 p-2 bg-gray-100 rounded max-w-3xl">
|
||||
<div>
|
||||
|
|
@ -31,42 +35,62 @@
|
|||
{!! $recipe->description !!}
|
||||
</section>
|
||||
@endif
|
||||
<section x-data="{showNutrientsSummary: false}">
|
||||
<section x-data="{ showNutrientsSummary: false }">
|
||||
<h1 class="mb-2 font-bold text-2xl">
|
||||
Ingredients
|
||||
<span class="text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300 font-normal cursor-pointer"
|
||||
x-on:click="showNutrientsSummary = !showNutrientsSummary">[toggle nutrients]</span>
|
||||
<span
|
||||
class="text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300 font-normal cursor-pointer"
|
||||
x-on:click="showNutrientsSummary = !showNutrientsSummary">[toggle nutrients]</span>
|
||||
</h1>
|
||||
<div class="prose prose-lg">
|
||||
<ul class="space-y-2">
|
||||
@foreach($recipe->ingredientsList->sortBy('weight') as $item)
|
||||
@if($item::class === \App\Models\IngredientAmount::class)
|
||||
@if($item::class === IngredientAmount::class)
|
||||
<li>
|
||||
<span>
|
||||
{{ \App\Support\Number::rationalStringFromFloat($item->amount) }}
|
||||
@if($item->unitFormatted){{ $item->unitFormatted }}@endif
|
||||
@if($item->ingredient->type === \App\Models\Recipe::class)
|
||||
{{-- Prevent food with serving size > 1 from incorrectly using formatted
|
||||
serving unit with number of servings. E.g., for a recipe calling for 1
|
||||
serving of a food with 4 tbsp. to a serving size show "1 serving" instead
|
||||
of "1 tbsp." (incorrect). --}}
|
||||
@if($item->unit === 'serving' && $item->ingredient->serving_size > 1 && ($item->ingredient->serving_unit || $item->ingredient->serving_unit_name))
|
||||
{{ Number::rationalStringFromFloat($item->amount * $item->ingredient->serving_size) }} {{ $item->unitFormatted }}
|
||||
<span
|
||||
class="text-gray-500">({{ Number::rationalStringFromFloat($item->amount) }} {{ \Illuminate\Support\Str::plural('serving', $item->amount ) }})</span>
|
||||
@else
|
||||
{{ Number::rationalStringFromFloat($item->amount) }}
|
||||
@if($item->unitFormatted)
|
||||
{{ $item->unitFormatted }}
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if($item->ingredient->type === Recipe::class)
|
||||
<a class="text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
href="{{ route('recipes.show', $item->ingredient) }}">
|
||||
{{ $item->ingredient->name }}
|
||||
</a>
|
||||
@else
|
||||
{{ $item->ingredient->name }}@if($item->ingredient->detail), {{ $item->ingredient->detail }}@endif
|
||||
{{ $item->ingredient->name }}@if($item->ingredient->detail)
|
||||
, {{ $item->ingredient->detail }}
|
||||
@endif
|
||||
@endif
|
||||
@if($item->detail)<span class="text-gray-500">{{ $item->detail }}</span>@endif
|
||||
<div x-show="showNutrientsSummary" class="text-sm text-gray-500">{{ $item->nutrients_summary }}</div>
|
||||
@if($item->detail)
|
||||
<span class="text-gray-500">{{ $item->detail }}</span>
|
||||
@endif
|
||||
<div x-show="showNutrientsSummary"
|
||||
class="text-sm text-gray-500">{{ $item->nutrients_summary }}</div>
|
||||
</span>
|
||||
</li>
|
||||
@elseif($item::class === \App\Models\RecipeSeparator::class)
|
||||
</ul></div>
|
||||
@if($item->text)
|
||||
<h2 class="mt-3 font-bold">{{ $item->text }}</h2>
|
||||
@else
|
||||
<hr class="mt-3 lg:w-1/2" />
|
||||
@endif
|
||||
<div class="prose prose-lg">
|
||||
<ul class="space-y-2">
|
||||
@endif
|
||||
@elseif($item::class === RecipeSeparator::class)
|
||||
</ul>
|
||||
</div>
|
||||
@if($item->text)
|
||||
<h2 class="mt-3 font-bold">{{ $item->text }}</h2>
|
||||
@else
|
||||
<hr class="mt-3 lg:w-1/2"/>
|
||||
@endif
|
||||
<div class="prose prose-lg">
|
||||
<ul class="space-y-2">
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -87,7 +111,8 @@
|
|||
<h1 class="mb-2 font-bold text-2xl">Tags</h1>
|
||||
<div class="flex flex-wrap">
|
||||
@foreach($recipe->tags as $tag)
|
||||
<span class="m-1 bg-gray-200 rounded-full px-2 leading-loose cursor-default">{{ $tag->name }}</span>
|
||||
<span
|
||||
class="m-1 bg-gray-200 rounded-full px-2 leading-loose cursor-default">{{ $tag->name }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -153,10 +178,14 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="flex flex-row space-x-2 justify-around md:flex-col md:space-y-2 md:space-x-0">
|
||||
<section class="flex flex-col space-y-2">
|
||||
<x-log-journalable :journalable="$recipe"></x-log-journalable>
|
||||
<x-button-link.gray href="{{ route('recipes.edit', $recipe) }}">
|
||||
Edit Recipe
|
||||
</x-button-link.gray>
|
||||
<x-button-link.gray href="{{ route('recipes.duplicate.confirm', $recipe) }}">
|
||||
Duplicate Recipe
|
||||
</x-button-link.gray>
|
||||
<x-button-link.red href="{{ route('recipes.delete', $recipe) }}">
|
||||
Delete Recipe
|
||||
</x-button-link.red>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,9 @@
|
|||
|--------------------------------------------------------------------------
|
||||
| API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| See: https://laravel-json-api.readthedocs.io/en/latest/
|
||||
|
|
||||
| TODO: Get auth middleware working...
|
||||
|
|
||||
*/
|
||||
|
||||
JsonApi::register('v1')->routes(function ($api) {
|
||||
JsonApi::register('v1')->middleware('auth:api,web')->routes(function ($api) {
|
||||
$api->resource('foods')->relationships(function ($relations) {
|
||||
$relations->hasMany('tags')->readOnly();
|
||||
})->readOnly();
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ Route::middleware(['auth'])->group(function () {
|
|||
Route::get('/foods/{food}/delete', [FoodController::class, 'delete'])->name('foods.delete');
|
||||
|
||||
// Goals.
|
||||
Route::resource('goals', GoalController::class);
|
||||
Route::get('/goals/{goal}/delete', [GoalController::class, 'delete'])->name('goals.delete');
|
||||
Route::resource('goals', GoalController::class)->only(['index', 'create', 'store']);
|
||||
Route::resource('goals', GoalController::class)->except(['index', 'create', 'store'])->middleware(['can:access,goal']);
|
||||
Route::get('/goals/{goal}/delete', [GoalController::class, 'delete'])->middleware(['can:access,goal'])->name('goals.delete');
|
||||
|
||||
// Ingredient picker.
|
||||
Route::get('/ingredient-picker/search', [IngredientPickerController::class, 'search'])->name('ingredient-picker.search');
|
||||
|
|
@ -48,13 +49,14 @@ Route::middleware(['auth'])->group(function () {
|
|||
// Recipes.
|
||||
Route::resource('recipes', RecipeController::class);
|
||||
Route::get('/recipes/{recipe}/delete', [RecipeController::class, 'delete'])->name('recipes.delete');
|
||||
Route::get('/recipes/{recipe}/duplicate', [RecipeController::class, 'duplicateConfirm'])->name('recipes.duplicate.confirm');
|
||||
Route::post('/recipes/{recipe}/duplicate', [RecipeController::class, 'duplicate'])->name('recipes.duplicate');
|
||||
|
||||
|
||||
// Users.
|
||||
Route::get('/profile/{user}', [ProfileController::class, 'show'])->name('profiles.show');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'can:editProfile,user'])->group(function () {
|
||||
// Profiles (non-admin Users variant).
|
||||
Route::get('/profile/{user}/edit', [ProfileController::class, 'edit'])->name('profiles.edit');
|
||||
Route::put('/profile/{user}', [ProfileController::class, 'update'])->name('profiles.update');
|
||||
Route::get('/profile/{user}/edit', [ProfileController::class, 'edit'])->middleware(['can:editProfile,user'])->name('profiles.edit');
|
||||
Route::put('/profile/{user}', [ProfileController::class, 'update'])->middleware(['can:editProfile,user'])->name('profiles.update');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,18 +15,36 @@ use Illuminate\Support\Facades\Artisan;
|
|||
*/
|
||||
|
||||
if (!App::isProduction()) {
|
||||
/**
|
||||
* Clear all caches.
|
||||
*/
|
||||
Artisan::command('dev:cache-clear', function () {
|
||||
/** @phpstan-ignore-next-line */
|
||||
assert($this instanceof ClosureCommand);
|
||||
$commands = [
|
||||
'cache:clear',
|
||||
'config:clear',
|
||||
'route:clear',
|
||||
'view:clear',
|
||||
];
|
||||
foreach ($commands as $command) {
|
||||
Artisan::call($command);
|
||||
$this->info(trim(Artisan::output()));
|
||||
}
|
||||
$this->info('All caches cleared!');
|
||||
})->purpose('Clear all caches.');
|
||||
|
||||
/**
|
||||
* Wipe, migrate, and seed the database.
|
||||
*/
|
||||
Artisan::command('dev:reset', function () {
|
||||
/** @phpstan-ignore-next-line */
|
||||
assert($this instanceof ClosureCommand);
|
||||
Artisan::call('db:wipe');
|
||||
$this->info(Artisan::output());
|
||||
Artisan::call('migrate');
|
||||
$this->info(Artisan::output());
|
||||
Artisan::call('db:seed');
|
||||
$this->info(Artisan::output());
|
||||
$commands = ['db:wipe', 'migrate', 'db:seed'];
|
||||
foreach ($commands as $command) {
|
||||
Artisan::call($command);
|
||||
$this->info(trim(Artisan::output()));
|
||||
}
|
||||
$this->info('Database reset complete!');
|
||||
})->purpose('Wipe, migrate, and seed the database.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
const defaultTheme = require('tailwindcss/defaultTheme');
|
||||
|
||||
module.exports = {
|
||||
mode: 'jit',
|
||||
|
||||
purge: [
|
||||
content: [
|
||||
'./storage/framework/views/*.php',
|
||||
'./resources/views/**/*.blade.php',
|
||||
],
|
||||
|
|
@ -16,12 +15,6 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
|
||||
variants: {
|
||||
extend: {
|
||||
opacity: ['disabled'],
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
require('@tailwindcss/forms')
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Tests\Feature\Http\Controllers;
|
||||
|
||||
use Algolia\AlgoliaSearch\Exceptions\UnreachableException;
|
||||
use App\Http\Controllers\IngredientPickerController;
|
||||
use App\Models\Food;
|
||||
use App\Models\Recipe;
|
||||
|
|
@ -42,8 +43,8 @@ class IngredientPickerControllerTest extends LoggedInTestCase
|
|||
*/
|
||||
public function testCanSearchWithAlgolia(): void
|
||||
{
|
||||
$this->expectException(ConnectException::class);
|
||||
$this->expectExceptionMessageMatches("/Could not resolve host: \-dsn\.algolia\.net/");
|
||||
$this->expectException(UnreachableException::class);
|
||||
$this->expectExceptionMessage("Impossible to connect, please check your Algolia Application Id.");
|
||||
|
||||
Config::set('scout.driver', 'algolia');
|
||||
$response = $this->get($this->buildUrl(['term' => 'butter']));
|
||||
|
|
|
|||
|
|
@ -102,6 +102,25 @@ class RecipeControllerTest extends HttpControllerTestCase
|
|||
$response->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
public function testCanDuplicateInstances(): void {
|
||||
$instance = $this->createInstance();
|
||||
$confirm_url = action([$this->class(), 'duplicateConfirm'], [$this->routeKey() => $instance]);
|
||||
$response = $this->get($confirm_url);
|
||||
$response->assertOk();
|
||||
$response->assertViewHas($this->routeKey());
|
||||
|
||||
$duplicate_url = action([$this->class(), 'duplicate'], [$this->routeKey() => $instance]);
|
||||
$response = $this->followingRedirects()->post($duplicate_url, ['name' => 'Duplicated Recipe']);
|
||||
$response->assertOk();
|
||||
|
||||
$recipe = Recipe::latest()->first();
|
||||
$this->assertEquals('Duplicated Recipe', $recipe->name);
|
||||
$this->assertEquals($instance->tags->toArray(), $instance->tags->toArray());
|
||||
$this->assertEquals($instance->ingredientAmounts->toArray(), $instance->ingredientAmounts->toArray());
|
||||
$this->assertEquals($instance->steps->toArray(), $instance->steps->toArray());
|
||||
$this->assertEquals($instance->separators->toArray(), $instance->separators->toArray());
|
||||
}
|
||||
|
||||
public function testSessionKeepsOldInputOnAdd(): void {
|
||||
$instance = $this->createInstance();
|
||||
$data = $this->createInvalidFormData($instance);
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ class NutrientsTest extends TestCase
|
|||
['fat', 200, 'gram', 10],
|
||||
['protein', 100, 'gram', 5],
|
||||
['protein', 200, 'gram', 10],
|
||||
['sodium', 2, 'oz', Nutrients::$gramsPerOunce],
|
||||
['sodium', 4, 'oz', Nutrients::$gramsPerOunce * 2],
|
||||
// ['sodium', 2, 'oz', Nutrients::$gramsPerOunce],
|
||||
// ['sodium', 4, 'oz', Nutrients::$gramsPerOunce * 2],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -135,25 +135,25 @@ class NutrientsTest extends TestCase
|
|||
];
|
||||
|
||||
return [
|
||||
[$foods['tsp'], $foods['tsp']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
|
||||
// [$foods['tsp'], $foods['tsp']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
|
||||
[$foods['tsp'], 1, 'serving', 1],
|
||||
[$foods['tsp'], $foods['tsp']->serving_weight * 1.5, 'gram', 1.5],
|
||||
[$foods['tsp'], 2, 'tsp', 2],
|
||||
[$foods['tsp'], 1, 'tbsp', 3],
|
||||
[$foods['tsp'], 1, 'cup', 48],
|
||||
[$foods['tbsp'], $foods['tbsp']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
|
||||
// [$foods['tbsp'], $foods['tbsp']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
|
||||
[$foods['tbsp'], 1, 'serving', 1],
|
||||
[$foods['tbsp'], $foods['tbsp']->serving_weight * 2, 'gram', 2],
|
||||
[$foods['tbsp'], 2, 'tsp', 2/3],
|
||||
[$foods['tbsp'], 1, 'tbsp', 1],
|
||||
[$foods['tbsp'], 2, 'cup', 32],
|
||||
[$foods['cup'], $foods['cup']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
|
||||
// [$foods['cup'], $foods['cup']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
|
||||
[$foods['cup'], 1, 'serving', 1],
|
||||
[$foods['cup'], $foods['cup']->serving_weight * 2.25, 'gram', 2.25],
|
||||
[$foods['cup'], 3, 'tsp', 1/16],
|
||||
[$foods['cup'], 2, 'tbsp', 1/8],
|
||||
[$foods['cup'], 5, 'cup', 5],
|
||||
[$foods['none'], $foods['none']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
|
||||
// [$foods['none'], $foods['none']->serving_weight, 'oz', Nutrients::$gramsPerOunce],
|
||||
[$foods['none'], 1, 'serving', 1],
|
||||
[$foods['none'], $foods['none']->serving_weight * 3.0125, 'gram', 3.0125],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ class NumberTest extends TestCase
|
|||
*/
|
||||
public function decimalStringFloatsProvider(): array {
|
||||
return [
|
||||
['0.0', 0.0], ['0.125', 1/8], ['0.25', 1/4], ['0.5', 1/2],
|
||||
['0.75', 3/4], ['1.0', 1.0], ['1.25', 1.25], ['1.5', 1.5],
|
||||
['0.0', 0.0], ['0.125', 0.125], ['0.25', 0.25], ['0.5', 0.5],
|
||||
['0.75', 0.75], ['1.0', 1.0], ['1.25', 1.25], ['1.5', 1.5],
|
||||
['2.5', 2.5], ['2.75', 2.75],
|
||||
];
|
||||
}
|
||||
|
|
@ -62,9 +62,9 @@ class NumberTest extends TestCase
|
|||
*/
|
||||
public function fractionStringFloatsProvider(): array {
|
||||
return [
|
||||
['0', 0.0], ['1/8', 1/8], ['1/4', 1/4], ['1/3', 1/3], ['1/2', 1/2],
|
||||
['2/3', 2/3], ['3/4', 3/4], ['1', 1.0], ['1 1/4', 1.25], ['1 1/3', 1 + 1/3],
|
||||
['1 1/2', 1.5], ['1 2/3', 1 + 2/3], ['2 1/2', 2.5], ['2 3/4', 2.75],
|
||||
['0', 0.0], ['1/8', 0.125], ['1/4', 0.25], ['1/2', 0.5],
|
||||
['3/4', 0.75], ['1', 1.0], ['1 1/4', 1.25],
|
||||
['1 1/2', 1.5], ['2 1/2', 2.5], ['2 3/4', 2.75],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue