Compare commits

...

101 commits
0.0.1 ... main

Author SHA1 Message Date
547b1842bf
bump deps
All checks were successful
/ ls (pull_request) Successful in 1m35s
/ ls (push) Successful in 1m39s
/ ls (release) Successful in 1m8s
2025-07-07 16:24:28 +02:00
64f5341371
add price feature
All checks were successful
/ ls (pull_request) Successful in 1m37s
/ ls (push) Successful in 1m37s
2025-06-29 23:18:23 +02:00
15f8db46a0
lol 2025-06-29 19:47:46 +02:00
2bdd5f9ac2
wip 2025-06-29 19:39:40 +02:00
8ebf229d62
bumpi
All checks were successful
/ ls (pull_request) Successful in 1m37s
/ ls (push) Successful in 1m37s
2025-06-29 19:36:00 +02:00
1f9562d36b
bumpi 2025-06-29 19:26:29 +02:00
bbc56a9af7
visually indicate closed orders in list
All checks were successful
/ ls (pull_request) Successful in 1m35s
/ ls (push) Successful in 1m32s
/ ls (release) Successful in 1m5s
2025-06-29 17:13:32 +02:00
2cbb64dede
fixie privacy
All checks were successful
/ ls (pull_request) Successful in 1m33s
/ ls (push) Successful in 1m31s
2025-06-28 21:26:37 +02:00
4d2ae3a6ab #98 make background more bonkers
All checks were successful
/ ls (pull_request) Successful in 1m33s
/ ls (release) Successful in 1m1s
/ ls (push) Successful in 1m31s
2025-06-21 13:35:30 +00:00
98feafa3fc
ammend
All checks were successful
/ ls (pull_request) Successful in 1m35s
/ ls (push) Successful in 1m31s
2025-06-21 15:20:08 +02:00
c8e6af6896
#97: add emojis to foodvendor 2025-06-21 15:15:47 +02:00
e2167fa19f
haaaah
Some checks failed
/ ls (pull_request) Failing after 22s
/ ls (release) Successful in 57s
/ ls (push) Successful in 1m29s
2025-06-18 20:54:19 +02:00
c29e09ccc0
hmmmmm
Some checks failed
/ ls (pull_request) Failing after 23s
/ ls (push) Successful in 1m29s
/ ls (release) Successful in 59s
2025-06-18 20:47:13 +02:00
a5e54e9f5b
lol;
Some checks failed
/ ls (pull_request) Failing after 23s
/ ls (release) Successful in 55s
/ ls (push) Successful in 1m31s
2025-06-18 20:26:48 +02:00
9b1e3d98f0
uiuiiu
All checks were successful
/ ls (pull_request) Successful in 1m33s
/ ls (push) Successful in 1m32s
/ ls (release) Successful in 1m0s
2025-06-18 20:12:54 +02:00
5cb66c5012
booty 2025-06-18 20:12:17 +02:00
c99032044d
booty 2025-06-18 19:28:14 +02:00
6bb49e8f79
fixie
All checks were successful
/ ls (pull_request) Successful in 1m33s
/ ls (push) Successful in 1m28s
/ ls (release) Successful in 1m0s
2025-06-18 16:56:49 +02:00
300c8cafc9 added a /api/food_orders/latest/ endpoint to recieve the latest food order
Some checks failed
/ ls (pull_request) Successful in 2m6s
/ ls (release) Successful in 1m18s
/ ls (push) Failing after 1m25s
2025-06-17 21:26:05 +02:00
ee32852789
api api
All checks were successful
/ ls (release) Successful in 55s
/ ls (pull_request) Successful in 1m28s
/ ls (push) Successful in 1m26s
2025-06-15 13:57:17 +02:00
9d2f0204e3
locki
All checks were successful
/ ls (pull_request) Successful in 1m28s
/ ls (release) Successful in 55s
/ ls (push) Successful in 1m28s
2025-06-15 13:03:18 +02:00
a948e992d8
remove trash 2025-06-15 12:58:37 +02:00
f731b46f86
fixi 2025-06-15 12:57:18 +02:00
937973e8e9
upidati 2025-06-15 12:55:41 +02:00
314063f15c
bump ci image
All checks were successful
/ ls (pull_request) Successful in 2m4s
/ ls (push) Successful in 1m33s
2025-05-24 21:50:48 +02:00
96b246462a
add open orders api route
All checks were successful
/ ls (pull_request) Successful in 1m31s
/ ls (push) Successful in 1m25s
/ ls (release) Successful in 1m0s
2025-04-23 18:57:55 +02:00
5de80b0da0
update
All checks were successful
/ ls (pull_request) Successful in 1m31s
/ ls (push) Successful in 1m30s
2025-04-23 17:25:45 +02:00
0e26487156
bump deps
All checks were successful
/ ls (pull_request) Successful in 1m31s
/ ls (push) Successful in 1m30s
/ ls (release) Successful in 58s
2025-04-04 22:06:46 +02:00
4fbfdcd73d
update foodordertest 2025-04-02 19:35:53 +02:00
696760659f
add index to loop
Some checks failed
/ ls (pull_request) Failing after 1m26s
/ ls (push) Failing after 1m22s
/ ls (release) Successful in 1m4s
2025-02-07 19:34:01 +01:00
5a0df96142
add link to api
Some checks failed
/ ls (pull_request) Failing after 24s
/ ls (release) Successful in 55s
/ ls (push) Successful in 1m27s
2025-02-07 12:27:16 +01:00
b00b923d43
add simple apitest
All checks were successful
/ ls (pull_request) Successful in 1m30s
/ ls (push) Successful in 1m28s
/ ls (release) Successful in 1m1s
2025-02-06 20:35:49 +01:00
d92a63fd7d
add rest api
All checks were successful
/ ls (pull_request) Successful in 1m30s
/ ls (push) Successful in 1m27s
/ ls (release) Successful in 59s
2025-02-02 21:15:44 +01:00
2b5d943116
enable parallel testing
All checks were successful
/ ls (pull_request) Successful in 1m27s
/ ls (push) Successful in 1m24s
2025-02-02 21:01:45 +01:00
9119029419
update deployment folder for php 8.4
Some checks failed
/ ls (pull_request) Failing after 22s
/ ls (push) Successful in 2m5s
2025-02-01 22:44:28 +01:00
9c98735db7
migrate test cases to use pestphp syntax
All checks were successful
/ ls (pull_request) Successful in 1m27s
/ ls (push) Successful in 1m24s
/ ls (release) Successful in 49s
2025-02-01 22:37:07 +01:00
af9354ff22
migrate to pest php for testing
All checks were successful
/ ls (pull_request) Successful in 1m33s
2025-02-01 00:11:25 +01:00
2273c91f2b
danke
All checks were successful
/ ls (pull_request) Successful in 1m29s
/ ls (release) Successful in 48s
/ ls (push) Successful in 1m28s
2025-01-29 19:50:33 +01:00
82575d2da0
add changes
All checks were successful
/ ls (pull_request) Successful in 1m29s
/ ls (push) Successful in 1m27s
/ ls (release) Successful in 55s
2025-01-26 11:57:13 +01:00
Continuous Integration
7fb0614db4 Continuous Integration Fixes 2025-01-26 10:50:50 +00:00
0aa25d107b
improve coverage, remove infection
All checks were successful
/ ls (pull_request) Successful in 1m30s
2025-01-26 11:49:21 +01:00
eaa723a58b
update symfony
All checks were successful
/ ls (pull_request) Successful in 2m34s
/ ls (release) Successful in 45s
/ ls (push) Successful in 2m31s
2025-01-25 18:13:47 +01:00
232878ebfb
make aliases work
All checks were successful
/ ls (pull_request) Successful in 2m37s
/ ls (push) Successful in 2m41s
2025-01-25 18:07:49 +01:00
e3157c1c50
Merge branch 'menuitem_aliases'
Some checks failed
/ ls (push) Has been cancelled
/ ls (release) Successful in 52s
2025-01-25 03:22:02 +01:00
b0f945f275
fix
Some checks failed
/ ls (pull_request) Has been cancelled
2025-01-25 03:20:23 +01:00
7b95ed44ee
sort and filter menuitems in aliasof select 2025-01-25 03:20:21 +01:00
3a144c5db3
fix
All checks were successful
/ ls (pull_request) Successful in 2m49s
2025-01-25 03:17:07 +01:00
889ed63bc5
sort and filter menuitems in aliasof select
Some checks failed
/ ls (pull_request) Failing after 2m56s
2025-01-25 03:16:11 +01:00
0ce09437a3
sort and filter menuitems in aliasof select
Some checks failed
/ ls (pull_request) Failing after 25s
/ ls (release) Successful in 46s
/ ls (push) Successful in 2m27s
2025-01-25 02:27:11 +01:00
9781bd561f
add menuitem aliases
All checks were successful
/ ls (pull_request) Successful in 2m42s
/ ls (release) Successful in 50s
/ ls (push) Successful in 2m27s
2025-01-25 02:15:49 +01:00
Continuous Integration
70b39515ec Continuous Integration Fixes
All checks were successful
/ ls (push) Successful in 2m16s
/ ls (release) Successful in 51s
2024-12-19 00:16:09 +00:00
86986310ad
add phone number to orderview
All checks were successful
/ ls (pull_request) Successful in 2m24s
2024-12-19 01:13:44 +01:00
b65a4814f4
#64: wip add phonenumberfield to foodvendor 2024-12-19 01:11:09 +01:00
Continuous Integration
2d3100b5c9 Continuous Integration Fixes
All checks were successful
/ ls (push) Successful in 2m20s
2024-12-18 23:38:27 +00:00
cb1ab0ed18
improve unit tests
All checks were successful
/ ls (pull_request) Successful in 2m32s
2024-12-19 00:35:23 +01:00
a974688872
update workflow image
All checks were successful
/ ls (pull_request) Successful in 2m21s
/ ls (push) Successful in 2m21s
2024-12-18 14:30:24 +01:00
8c783d780c
update dependencies
Some checks failed
/ ls (pull_request) Failing after 2m43s
2024-12-18 14:21:34 +01:00
Continuous Integration
ee5d515ac1 Continuous Integration Fixes
All checks were successful
/ ls (push) Successful in 2m17s
/ ls (release) Successful in 54s
2024-09-23 17:11:33 +00:00
80758abd60
fix ci
All checks were successful
/ ls (pull_request) Successful in 2m23s
2024-09-23 19:09:18 +02:00
9e35499269
do not show deleted menuitems
Some checks failed
/ ls (pull_request) Failing after 2m23s
2024-09-23 19:02:17 +02:00
441568bdb7
add pr feedback
All checks were successful
/ ls (pull_request) Successful in 2m18s
/ ls (push) Successful in 2m9s
2024-09-17 20:48:01 +02:00
7674b6a6bd
add mutation testing
All checks were successful
/ ls (pull_request) Successful in 2m13s
/ ls (push) Successful in 2m11s
2024-09-17 17:55:33 +02:00
12ff38ecd6
bump deps
All checks were successful
/ ls (pull_request) Successful in 57s
/ ls (push) Successful in 52s
/ ls (release) Successful in 37s
2024-08-31 21:40:05 +02:00
b1e82037c7
update dependencies
All checks were successful
/ ls (pull_request) Successful in 41s
/ ls (push) Successful in 38s
/ ls (release) Successful in 25s
2024-08-18 09:19:12 +02:00
3f3ede8548
fix issue link
All checks were successful
/ ls (pull_request) Successful in 37s
/ ls (push) Successful in 38s
/ ls (release) Successful in 25s
2024-08-16 21:57:49 +02:00
56206acd8a
#37 add external link to foodvendor
All checks were successful
/ ls (pull_request) Successful in 38s
/ ls (push) Successful in 37s
/ ls (release) Successful in 25s
2024-08-16 21:52:53 +02:00
34171d6c2b
100% test coverage
All checks were successful
/ ls (pull_request) Successful in 37s
/ ls (push) Successful in 37s
/ ls (release) Successful in 24s
2024-08-16 13:44:16 +02:00
2a28465626
lint
All checks were successful
/ ls (pull_request) Successful in 37s
/ ls (push) Successful in 35s
2024-08-16 11:15:23 +02:00
6f23c3c1b7
improve test coverage
All checks were successful
/ ls (pull_request) Successful in 39s
2024-08-16 11:14:41 +02:00
a4f62868fd
#42 linting
All checks were successful
/ ls (pull_request) Successful in 35s
/ ls (push) Successful in 34s
/ ls (release) Successful in 25s
2024-08-15 18:33:33 +02:00
311802be6b
#42: fix bug
Some checks failed
/ ls (pull_request) Successful in 39s
/ ls (push) Failing after 36s
2024-08-15 18:31:10 +02:00
674adcba60
#42: allow updates to menuitems
All checks were successful
/ ls (pull_request) Successful in 37s
/ ls (push) Successful in 39s
/ ls (release) Successful in 25s
2024-08-15 18:22:46 +02:00
0068654885
#49: change order item form labels
All checks were successful
/ ls (pull_request) Successful in 36s
/ ls (push) Successful in 36s
/ ls (release) Successful in 25s
2024-08-15 16:33:13 +02:00
c324973d28
#44: show item name on orderitem editform
All checks were successful
/ ls (pull_request) Successful in 35s
/ ls (push) Successful in 36s
/ ls (release) Successful in 28s
2024-08-14 20:39:14 +02:00
Continuous Integration
25b73e7da5 Continuous Integration Fixes
All checks were successful
/ ls (push) Successful in 34s
2024-08-14 17:41:59 +00:00
83989916a3
#45 sort orderitems aplhabetically in order view show
All checks were successful
/ ls (pull_request) Successful in 35s
2024-08-14 19:41:04 +02:00
add3631376
\#40 add list of menuitems in foodvendor show
All checks were successful
/ ls (pull_request) Successful in 33s
/ ls (push) Successful in 31s
/ ls (release) Successful in 34s
2024-07-17 21:19:01 +02:00
a0d277464d
#38: pidserspass
All checks were successful
/ ls (pull_request) Successful in 34s
/ ls (push) Successful in 33s
/ ls (release) Successful in 26s
2024-07-10 23:39:28 +02:00
9afa7fe431
#29: add more tests
All checks were successful
/ ls (pull_request) Successful in 37s
/ ls (push) Successful in 32s
2024-07-10 22:18:56 +02:00
c4cd275c83
#29: add test for homecontroller 2024-07-10 19:05:28 +02:00
5d41b6fef5
#33: limit orders on first page and paginate
All checks were successful
/ ls (pull_request) Successful in 31s
/ ls (push) Successful in 32s
/ ls (release) Successful in 25s
2024-07-08 21:23:35 +02:00
7e53705b4b
#32: show dates in europe berlin timezone
All checks were successful
/ ls (pull_request) Successful in 32s
/ ls (push) Successful in 31s
/ ls (release) Successful in 26s
2024-07-08 17:58:32 +02:00
219561c5ae
#30: remove cdn css link
All checks were successful
/ ls (pull_request) Successful in 31s
/ ls (push) Successful in 30s
/ ls (release) Successful in 25s
2024-06-30 15:26:54 +02:00
c5e0d47e03
update backup part
All checks were successful
/ ls (pull_request) Successful in 32s
/ ls (push) Successful in 32s
/ ls (release) Successful in 23s
2024-06-30 10:53:10 +02:00
df072b0c19
#25: update path
Some checks failed
/ ls (pull_request) Failing after 5s
/ ls (push) Successful in 32s
/ ls (release) Failing after 16s
2024-06-30 10:22:14 +02:00
55d18fffd1
#25
Some checks failed
/ ls (pull_request) Successful in 33s
/ ls (push) Successful in 33s
/ ls (release) Failing after 16s
2024-06-30 10:18:24 +02:00
70041d7357
!20 add createdby field to order form
All checks were successful
/ ls (pull_request) Successful in 32s
/ ls (push) Successful in 30s
2024-06-30 09:56:35 +02:00
511f12f10f
!20 add username to order and item 2024-06-27 23:36:18 +02:00
9d81464fd8
#20 add username form 2024-06-27 17:43:14 +02:00
b479fbf5ed
#21 add planned closing time to food order
All checks were successful
/ ls (pull_request) Successful in 31s
/ ls (push) Successful in 31s
2024-06-27 17:14:59 +02:00
634f13d968
reset database for each test, test menuitem creation
Some checks failed
/ ls (pull_request) Successful in 35s
/ ls (push) Failing after 34s
2024-06-27 16:57:15 +02:00
01354fa70c
remove vendor id from list
All checks were successful
/ ls (pull_request) Successful in 30s
/ ls (push) Successful in 29s
/ ls (release) Successful in 33s
2024-06-26 20:49:00 +02:00
Continuous Integration
83e1ae3204 Continuous Integration Fixes
All checks were successful
/ ls (push) Successful in 32s
2024-06-26 18:32:46 +00:00
5ff1832dee
make orderitem menuitem non nullable
All checks were successful
/ ls (pull_request) Successful in 31s
2024-06-26 20:31:27 +02:00
45029e0a4c
add option to select previous menuitems
All checks were successful
/ ls (pull_request) Successful in 33s
/ ls (push) Successful in 29s
2024-06-26 20:12:27 +02:00
57b4e33028
fix paths in prepare deploy and add local deploy script
All checks were successful
/ ls (pull_request) Successful in 31s
/ ls (push) Successful in 31s
2024-06-24 21:19:11 +02:00
b6e0ef44f4
write release workflow file 2024-06-23 11:58:18 +02:00
14bb4a1542
update deployment paths
All checks were successful
/ ls (pull_request) Successful in 29s
/ ls (push) Successful in 28s
2024-06-23 09:21:20 +02:00
38a2de5858
add unit files 2024-06-23 08:49:44 +02:00
7f2eb7ec65
update fpm config 2024-06-23 08:27:17 +02:00
0661f5dd3f
more-deployment 2024-06-22 22:20:51 +02:00
130 changed files with 10770 additions and 2128 deletions

4
.env
View file

@ -28,3 +28,7 @@ DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

4
.env.dev Normal file
View file

@ -0,0 +1,4 @@
###> symfony/framework-bundle ###
APP_SECRET=11c8937d48993fb3aee1a476413161f5
###< symfony/framework-bundle ###

View file

@ -4,4 +4,3 @@ APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
DATABASE_URL="sqlite:///%kernel.project_dir%/var/test-data.db"

View file

@ -3,13 +3,13 @@ jobs:
ls:
runs-on: docker
container:
image: git.php.fail/lubiana/container/php:ci
image: git.php.fail/lubiana/container/php:8.4.8-ci
steps:
- name: Manually checkout
env:
REPO: '${{ github.repository }}'
TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GIT_SERVER: 'hannover.ccc.de/gitlab'
GIT_SERVER: 'git.hannover.ccc.de'
run: |
git clone --branch $GITHUB_HEAD_REF https://${TOKEN}@${GIT_SERVER}/${REPO}.git .
git fetch

View file

@ -6,14 +6,14 @@ jobs:
ls:
runs-on: docker
container:
image: git.php.fail/lubiana/container/php:ci
image: git.php.fail/lubiana/container/php:8.4.8-ci
steps:
- name: Manually checkout
env:
REPO: '${{ github.repository }}'
TOKEN: '${{ secrets.GITHUB_TOKEN }}'
BRANCH: '${{ env.GITHUB_REF_NAME }}'
GIT_SERVER: 'hannover.ccc.de/gitlab'
GIT_SERVER: 'git.hannover.ccc.de'
run: |
git clone --branch $GITHUB_REF_NAME https://${TOKEN}@${GIT_SERVER}/${REPO}.git .
git fetch

View file

@ -4,7 +4,7 @@ jobs:
ls:
runs-on: docker
container:
image: git.php.fail/lubiana/container/php:ci
image: git.php.fail/lubiana/container/php:8.4.8-ci
steps:
- name: Manually checkout
env:
@ -16,5 +16,34 @@ jobs:
git clone --branch $GITHUB_REF_NAME https://${TOKEN}@${GIT_SERVER}/${REPO}.git .
git fetch
git checkout ${{ github.head_ref }}
- name: list
run: ls
- name: prepare deploy
run: sh ./deploy/prepare-deploy.sh
- name: deploy
env:
HOST: 'web.server.c3h'
USERNAME: 'c3h-futtern'
TARGETDIR: '/home/c3h-futtern/futtern'
HOMEDIR: '/home/c3h-futtern'
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh/
# Print the SSH key, replacing newline characters with actual new lines
echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
# Set appropriate permissions for the SSH key
chmod 600 ~/.ssh/id_rsa
# Add the remote host's key to the known_hosts file to avoid authenticity confirmation
ssh-keyscan -H $HOST >> ~/.ssh/known_hosts
# stop services
ssh ${USERNAME}@${HOST} systemctl --user stop pod-futtern
# backup database
ssh ${USERNAME}@${HOST} "cp ${HOMEDIR}/futtern/app/var/data.db ${HOMEDIR}/backup/data.db-$(date +\"%Y%m%d%H%M%S\")"
# only keep last 10 backupts
ssh ${USERNAME}@${HOST} "find ${HOMEDIR}/backup/ -type f | sort | head -n -10 | xargs rm -f"
# SCP files to the remote host
rsync -avz --delete deploy/ ${USERNAME}@${HOST}:${TARGETDIR} --exclude=var
# run update script
ssh ${USERNAME}@${HOST} /home/c3h-futtern/futtern/update.sh

29
.gitignore vendored
View file

@ -1,4 +1,3 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
@ -8,17 +7,27 @@
/var/
/vendor/
###< symfony/framework-bundle ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
.idea/
/deploy/var/
/deploy/app/
###> phpunit/phpunit ###
/phpunit.xml
.phpunit.result.cache
.phpunit.cache
###< phpunit/phpunit ###
.idea/
/deploy/futtern-app/
/deploy/var/
.DS_Store
###> squizlabs/php_codesniffer ###
/.phpcs-cache
/phpcs.xml
###< squizlabs/php_codesniffer ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
###> symfony/asset-mapper ###
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###

24
assets/app.js Normal file
View file

@ -0,0 +1,24 @@
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles/app.css';
import './styles/modes.css';
import './styles/emoji-footprint.css';
// Import modules
import './javascript/theme.js';
import './javascript/emoji-footprint.js';
import './javascript/modes.js';
import './javascript/htmx.js';
import emojiButtonListener from './javascript/emoji-button.js';
import 'bootstrap';
import { initRadioState } from './javascript/radioState.js';
document.addEventListener('DOMContentLoaded', () => {
initRadioState();
emojiButtonListener();
});

View file

@ -0,0 +1,14 @@
const emojiButtonListener = function () {
const buttons = document.querySelectorAll('.emoji-buttons .btn.btn-primary');
buttons.forEach(button => {
button.addEventListener('click', function() {
const emojiField = document.querySelector('#food_vendor_emojis');
if (emojiField) {
emojiField.value += this.textContent;
}
});
});
}
export default emojiButtonListener;

View file

@ -0,0 +1,19 @@
// Sparkle effect on mouse move
document.addEventListener('mousemove', function (e) {
const emojis = ['✨', '💖', '🌟', '💅', '🦄', '🎉', '🌈'];
const sparkle = document.createElement('div');
sparkle.className = 'emoji-footprint';
sparkle.textContent = emojis[Math.floor(Math.random() * emojis.length)];
sparkle.style.left = e.pageX + 'px';
sparkle.style.top = e.pageY + 'px';
document.body.appendChild(sparkle);
setTimeout(() => {
sparkle.remove();
}, 1000);
});
export function initEmojiFootprint() {
// The sparkle effect is already initialized when this module is imported
// This function can be used if we need to control when the effect starts
}

View file

@ -0,0 +1,3 @@
import htmx from 'htmx.org';
window.htmx = htmx;

136
assets/javascript/modes.js Normal file
View file

@ -0,0 +1,136 @@
// Bonkers mode functionality
function setEmojiLevelClass(mode) {
document.body.classList.remove('emoji-normal', 'emoji-enhanced', 'emoji-bonkers');
if (mode === 'bonkers') {
document.body.classList.add('emoji-bonkers');
} else if (mode === 'enhanced') {
document.body.classList.add('emoji-enhanced');
} else {
document.body.classList.add('emoji-normal');
}
}
function initBonkersMode() {
// Check if we're in bonkers mode
const currentMode = document.documentElement.getAttribute('data-website-mode');
setEmojiLevelClass(currentMode);
if (currentMode === 'bonkers') {
// Apply bonkers mode immediately
document.body.classList.add('bonkers-mode');
// Start the fabulous effects
createExtraSparkles();
createSlayEffects();
console.log('🌈✨ Bonkers mode activated! ✨🌈');
} else {
// Remove bonkers mode if it was active
document.body.classList.remove('bonkers-mode');
}
}
// Function to create extra sparkles during bonkers mode
function createExtraSparkles() {
const currentMode = document.documentElement.getAttribute('data-website-mode');
if (currentMode !== 'bonkers') return;
const extraEmojis = [
'💃', '🕺',
'🍑', '💦', '😏', '😈', '👅', '💋', '🥵', '😳', '🤤', '😍', '🥴',
'💕', '💖', '💗', '💘', '💝', '💞', '💟', '💌', '💏', '💑',
'🍆', '🥒', '🍌', '💦', '👀', '😉', '😌', '😍', '🥰', '😘',
'😚', '😋', '😏', '😫', '😩', '🥺', '🥵', '🥴',
'💖', '💗', '💕', '💞', '💓', '💗', '💖', '💘', '💝',
'💋', '💏', '💑'
];
const sparkle = document.createElement('div');
sparkle.className = 'emoji-footprint';
sparkle.textContent = extraEmojis[Math.floor(Math.random() * extraEmojis.length)];
sparkle.style.left = Math.random() * window.innerWidth + 'px';
sparkle.style.top = Math.random() * window.innerHeight + 'px';
document.body.appendChild(sparkle);
setTimeout(() => {
if (sparkle.parentNode) {
sparkle.remove();
}
}, 3000);
// Continue creating extra sparkles while in bonkers mode
const newMode = document.documentElement.getAttribute('data-website-mode');
if (newMode === 'bonkers') {
setTimeout(() => createExtraSparkles(), 150);
}
}
// Function to create slay effects
function createSlayEffects() {
const currentMode = document.documentElement.getAttribute('data-website-mode');
if (currentMode !== 'bonkers') return;
// Create floating "SLAY" text effects
const slayWords = [
'SLAY', 'QUEEN', 'FABULOUS', 'ICONIC', 'LEGENDARY', 'STUNNING', 'GORGEOUS', 'FLAWLESS',
'DAZZLING', 'RADIANT', 'BREATHTAKING', 'EXQUISITE', 'DIVINE'
];
const slayElement = document.createElement('div');
slayElement.className = 'slay-text';
slayElement.textContent = slayWords[Math.floor(Math.random() * slayWords.length)];
slayElement.style.left = Math.random() * window.innerWidth + 'px';
slayElement.style.top = Math.random() * window.innerHeight + 'px';
document.body.appendChild(slayElement);
setTimeout(() => {
if (slayElement.parentNode) {
slayElement.remove();
}
}, 3000);
// Continue creating slay effects while in bonkers mode
const newMode = document.documentElement.getAttribute('data-website-mode');
if (newMode === 'bonkers') {
setTimeout(() => createSlayEffects(), 800);
}
}
// Watch for mode changes
function watchModeChanges() {
// Create a MutationObserver to watch for changes to the data-website-mode attribute
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-website-mode') {
const newMode = document.documentElement.getAttribute('data-website-mode');
if (newMode === 'bonkers') {
document.body.classList.add('bonkers-mode');
setEmojiLevelClass(newMode);
// Start the fabulous effects
createExtraSparkles();
createSlayEffects();
console.log('🌈✨ Switched to bonkers mode! ✨🌈');
} else {
document.body.classList.remove('bonkers-mode');
setEmojiLevelClass(newMode);
console.log(`😴 Switched to ${newMode} mode`);
}
}
});
});
// Start observing
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-website-mode']
});
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
initBonkersMode();
watchModeChanges();
});
export { initBonkersMode, watchModeChanges };

View file

@ -0,0 +1,55 @@
// Function to initialize number input buttons
function initNumberInputs(container = document) {
container.querySelectorAll('.number-input-wrapper').forEach(function(wrapper) {
const input = wrapper.querySelector('input[type="number"]');
const decreaseBtn = wrapper.querySelector('[data-action="decrease"]');
const increaseBtn = wrapper.querySelector('[data-action="increase"]');
if (!input || !decreaseBtn || !increaseBtn) return;
// Skip if already initialized
if (decreaseBtn.hasAttribute('data-initialized')) return;
const step = parseFloat(input.getAttribute('step')) || 1;
const min = 0;
const max = input.getAttribute('max') ? parseFloat(input.getAttribute('max')) : null;
decreaseBtn.addEventListener('click', function() {
const currentValue = parseFloat(input.value) || 0;
const newValue = currentValue - step;
if (min === null || newValue >= min) {
input.value = newValue;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
increaseBtn.addEventListener('click', function() {
const currentValue = parseFloat(input.value) || 0;
const newValue = currentValue + step;
if (max === null || newValue <= max) {
input.value = newValue;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
// Validate input on change
input.addEventListener('input', function() {
const value = parseFloat(this.value);
if (min !== null && value < min) {
this.value = min;
}
if (max !== null && value > max) {
this.value = max;
}
});
// Mark as initialized
decreaseBtn.setAttribute('data-initialized', 'true');
increaseBtn.setAttribute('data-initialized', 'true');
});
}
export { initNumberInputs };

View file

@ -0,0 +1,35 @@
// Radio button state management with localStorage
function initRadioState() {
// Store and retrieve radio button state
const radioButtons = document.querySelectorAll('input[name="mode"]');
// Load saved state on page load
const savedMode = localStorage.getItem('selectedMode');
if (savedMode) {
const radioToCheck = document.getElementById(savedMode);
if (radioToCheck) {
radioToCheck.checked = true;
// Set the data attribute to match the saved mode
document.documentElement.setAttribute('data-website-mode', savedMode);
}
} else {
// If no saved state, set to the currently checked radio button
const checkedRadio = document.querySelector('input[name="mode"]:checked');
if (checkedRadio) {
document.documentElement.setAttribute('data-website-mode', checkedRadio.id);
}
}
// Save state when radio button changes
radioButtons.forEach(radio => {
radio.addEventListener('change', function() {
if (this.checked) {
localStorage.setItem('selectedMode', this.id);
// Update the data attribute when mode changes
document.documentElement.setAttribute('data-website-mode', this.id);
}
});
});
}
export { initRadioState };

View file

@ -0,0 +1,18 @@
// Theme detection and switching
const getPreferredTheme = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
document.documentElement.setAttribute('data-bs-theme', theme)
}
// Set initial theme
setTheme(getPreferredTheme())
// Listen for changes in user's preferred color scheme
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
setTheme(getPreferredTheme())
})
export { getPreferredTheme, setTheme };

179
assets/styles/app.css Normal file
View file

@ -0,0 +1,179 @@
/*
* =================================================================================================
* 💖 BUBBLEGUM PUNK THEME (LIGHT) 💖
*
* This isn't just a theme. It's a statement.
* Unapologetically loud, pink, and quirky.
* =================================================================================================
*/
:root,
[data-bs-theme=light] {
/* --- CORE VIBE --- */
--bs-pink: #FF007A; /* 💖 Hyper Pink (Our Queen) */
--bs-green: #CFFF50; /* 🧪 Toxic Slime */
--bs-purple: #A328D6; /* 👾 Graffiti Purple */
--bs-yellow: #F9F871; /* ⚡ Neon Lemon */
--bs-cyan: #00F5D4; /* 💎 Glitchy Teal */
--bs-blue: #00A9E0; /* 💦 Splash Zone */
/* Let's redefine ALL the core colors to match the new energy */
--bs-primary: var(--bs-pink);
--bs-secondary: var(--bs-green);
--bs-success: var(--bs-cyan);
--bs-info: var(--bs-blue);
--bs-warning: var(--bs-yellow);
--bs-danger: #FF3D3D; /* 🚨 Code Red Rave */
/* --- BACKGROUNDS & TEXT --- */
/* No more boring white! */
--bs-body-bg: #FFF5FD; /* A soft, dreamy pink canvas */
--bs-body-color: #4A003D; /* Dark Plum (instead of black) for text */
--bs-heading-color: var(--bs-purple); /* Make headings POP */
--bs-secondary-color: rgba(74, 0, 61, 0.75); /* Plum, but softer */
--bs-tertiary-color: rgba(74, 0, 61, 0.5);
/* Make cards and containers pure white to contrast the pink background */
--bs-tertiary-bg: #FFFFFF;
--bs-secondary-bg: #FEF9FE;
/* --- LINKS & CODE --- */
--bs-link-color: var(--bs-pink);
--bs-link-hover-color: var(--bs-purple);
--bs-code-color: var(--bs-purple);
/* --- BORDERS & SHADOWS: LET'S GET QUIRKY --- */
--bs-border-width: 2px; /* Chunky borders! */
--bs-border-color: #FFD6F5; /* Pink-tinted border color */
--bs-border-color-translucent: rgba(74, 0, 61, 0.2);
--bs-border-radius: 1rem; /* Super bubbly and round */
--bs-border-radius-sm: 0.5rem;
--bs-border-radius-lg: 1.5rem;
--bs-border-radius-pill: 50rem;
/* Say goodbye to black shadows, hello to colored glows! */
--bs-box-shadow: 0 4px 12px rgba(255, 0, 122, 0.2);
--bs-box-shadow-sm: 0 2px 4px rgba(255, 0, 122, 0.15);
--bs-box-shadow-lg: 0 8px 30px rgba(255, 0, 122, 0.25);
--bs-box-shadow-inset: inset 0 1px 4px rgba(74, 0, 61, 0.2);
/* --- THE GRADIENT: THE SOUL OF THE THEME --- */
--bs-gradient: linear-gradient(75deg, var(--bs-primary), var(--bs-secondary));
/* --- Don't forget the RGB values for Bootstrap components! --- */
--bs-primary-rgb: 255, 0, 122;
--bs-secondary-rgb: 207, 255, 80;
--bs-body-color-rgb: 74, 0, 61;
--bs-body-bg-rgb: 255, 245, 253;
}
/*
* =================================================================================================
* 🌙🦇 CYBER GOTH THEME (DARK) 🦇🌙
*
* The lights are out, the neon is ON.
* A dark, moody theme with vibrant, glowing accents.
* =================================================================================================
*/
[data-bs-theme=dark] {
color-scheme: dark;
/* --- BACKGROUNDS & TEXT --- */
--bs-body-bg: #1D001A; /* Deep, dark space purple */
--bs-body-color: #FFE9FA; /* Light pink text for high contrast */
--bs-heading-color: var(--bs-cyan); /* Glowing cyan headings */
--bs-tertiary-bg: #2E0028; /* A slightly lighter container background */
--bs-secondary-bg: #3A0033;
--bs-secondary-color: rgba(255, 233, 250, 0.75);
--bs-tertiary-color: rgba(255, 233, 250, 0.5);
/* --- LINKS & CODE --- */
/* Using the Toxic Slime for links gives it that cyber look */
--bs-link-color: var(--bs-green);
--bs-link-hover-color: var(--bs-cyan);
--bs-code-color: var(--bs-pink);
/* --- BORDERS & SHADOWS: NEON GLOWS --- */
--bs-border-color: #5C004F;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
/* Redefine shadows to be neon glows */
--bs-box-shadow: 0 0 15px rgba(var(--bs-primary-rgb), 0.4);
--bs-box-shadow-lg: 0 0 30px rgba(var(--bs-primary-rgb), 0.5);
/* --- EMPHASIS & SUBTLE BACKGROUNDS --- */
/* These are for alerts, badges, etc. They'll be dark with glowing text. */
--bs-primary-text-emphasis: #FF8AD1;
--bs-secondary-text-emphasis: #E2FF8A;
--bs-success-text-emphasis: #8AFFEB;
--bs-info-text-emphasis: #7ADCF5;
--bs-warning-text-emphasis: #FAF8A8;
--bs-danger-text-emphasis: #FF8A8A;
--bs-primary-bg-subtle: #3D002B;
--bs-secondary-bg-subtle: #415215;
--bs-success-bg-subtle: #00332B;
--bs-info-bg-subtle: #00313D;
--bs-warning-bg-subtle: #3E3D1C;
--bs-danger-bg-subtle: #520E0E;
}
/* === EMOJI LEVELS === */
.emoji-normal .emoji-normal { display: inline; }
.emoji-normal .emoji-enhanced,
.emoji-normal .emoji-bonkers { display: none; }
.emoji-enhanced .emoji-enhanced { display: inline; }
.emoji-enhanced .emoji-normal,
.emoji-enhanced .emoji-bonkers { display: none; }
.emoji-bonkers .emoji-bonkers { display: inline; }
.emoji-bonkers .emoji-normal,
.emoji-bonkers .emoji-enhanced { display: none; }
/*
* =================================================================================================
* 🌈 RAINBOW PRIDE ELEMENTS 🌈
*
* Fabulous rainbow-themed elements to celebrate diversity and pride!
* =================================================================================================
*/
.bg-rainbow {
background: linear-gradient(
to right,
#FF5757, /* Red */
#FFBD59, /* Orange */
#F9F871, /* Yellow */
#CFFF50, /* Green */
#00F5D4, /* Teal */
#00A9E0, /* Blue */
#A328D6 /* Purple */
);
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
font-weight: bold;
border: none;
}
.fun-fact {
font-size: 1.1rem;
line-height: 1.6;
font-style: italic;
}
/* Add a subtle rainbow border to the fun facts card */
.card:has(.fun-fact) {
border-width: 2px;
border-style: solid;
border-image: linear-gradient(
to right,
#FF5757, /* Red */
#FFBD59, /* Orange */
#F9F871, /* Yellow */
#CFFF50, /* Green */
#00F5D4, /* Teal */
#00A9E0, /* Blue */
#A328D6 /* Purple */
) 1;
box-shadow: 0 4px 15px rgba(163, 40, 214, 0.2);
}

View file

@ -0,0 +1,30 @@
/* Emoji Footprint Animation */
.emoji-footprint {
position: absolute;
font-size: 1.6rem;
pointer-events: none;
animation: emojiFade 1s ease-out forwards;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
z-index: 9999;
text-shadow:
0 0 4px #ff00bf,
0 0 8px #ff80df,
0 0 12px #ffccff;
}
@keyframes emojiFade {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0.7;
}
100% {
transform: translate(-50%, -50%) scale(2);
opacity: 0;
}
}

568
assets/styles/modes.css Normal file
View file

@ -0,0 +1,568 @@
/* 🌈✨ BONKERS MODE ANIMATIONS ✨🌈 */
@keyframes rainbowGradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes discoFlash {
0%, 100% {
background-color: var(--bs-pink);
box-shadow: 0 0 20px var(--bs-pink), 0 0 40px var(--bs-pink);
}
16.66% {
background-color: var(--bs-purple);
box-shadow: 0 0 20px var(--bs-purple), 0 0 40px var(--bs-purple);
}
33.33% {
background-color: var(--bs-cyan);
box-shadow: 0 0 20px var(--bs-cyan), 0 0 40px var(--bs-cyan);
}
50% {
background-color: var(--bs-yellow);
box-shadow: 0 0 20px var(--bs-yellow), 0 0 40px var(--bs-yellow);
}
66.66% {
background-color: var(--bs-green);
box-shadow: 0 0 20px var(--bs-green), 0 0 40px var(--bs-green);
}
83.33% {
background-color: var(--bs-orange);
box-shadow: 0 0 20px var(--bs-orange), 0 0 40px var(--bs-orange);
}
}
@keyframes wiggle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-2deg); }
75% { transform: rotate(2deg); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rainbowText {
0% { color: var(--bs-red); }
14.28% { color: var(--bs-orange); }
28.57% { color: var(--bs-yellow); }
42.85% { color: var(--bs-green); }
57.14% { color: var(--bs-cyan); }
71.42% { color: var(--bs-purple); }
85.71% { color: var(--bs-pink); }
100% { color: var(--bs-red); }
}
@keyframes shine {
0% { left: -100%; }
50% { left: 100%; }
100% { left: 100%; }
}
@keyframes slayFloat {
0% {
transform: translateY(0) scale(0.5);
opacity: 0;
}
20% {
transform: translateY(-20px) scale(1);
opacity: 1;
}
80% {
transform: translateY(-60px) scale(1.2);
opacity: 0.8;
}
100% {
transform: translateY(-100px) scale(1.5);
opacity: 0;
}
}
/* 🎭 BONKERS MODE CLASSES 🎭 */
.bonkers-mode {
background: linear-gradient(270deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan), var(--bs-yellow), var(--bs-green), var(--bs-orange), var(--bs-red), var(--bs-pink));
background-size: 1600% 1600%;
animation: rainbowGradient 10s ease infinite;
transition: all 0.3s ease-in-out;
}
.bonkers-mode .btn {
animation: discoFlash 0.3s infinite, wiggle 0.2s infinite;
background: linear-gradient(45deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan), var(--bs-yellow), var(--bs-green), var(--bs-orange), var(--bs-red));
background-size: 400% 400%;
animation: discoFlash 0.3s infinite, wiggle 0.2s infinite, rainbowGradient 1s ease infinite;
border: 4px solid var(--bs-white);
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
position: relative;
overflow: hidden;
transition: all 0.2s ease;
}
.bonkers-mode .btn:hover {
animation: discoFlash 0.2s infinite, wiggle 0.1s infinite, rainbowGradient 0.5s ease infinite;
box-shadow: 0 0 30px var(--bs-pink), 0 0 60px var(--bs-purple);
}
.bonkers-mode .btn::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255,255,255,0.5), transparent);
transform: rotate(45deg);
animation: spin 0.5s linear infinite;
}
.bonkers-mode .navbar {
background: linear-gradient(90deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan), var(--bs-yellow), var(--bs-green), var(--bs-orange), var(--bs-red));
background-size: 200% 200%;
animation: rainbowGradient 2s ease infinite;
box-shadow: 0 0 50px rgba(255, 105, 180, 0.9);
height: auto !important;
min-height: 56px;
}
.bonkers-mode .navbar-brand {
animation: rainbowText 0.8s infinite, wiggle 0.4s infinite;
font-size: 1.8em;
text-shadow: 3px 3px 6px rgba(0,0,0,0.5);
position: relative;
overflow: hidden;
}
.bonkers-mode .navbar-brand::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255,255,255,0.3), transparent);
transform: rotate(45deg);
animation: spin 2s linear infinite;
}
.bonkers-mode .navbar-nav .nav-link {
animation: rainbowText 1.2s infinite, wiggle 0.3s infinite;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
border: 2px solid transparent;
border-radius: 8px;
padding: 8px 16px;
margin: 0 4px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.bonkers-mode .navbar-nav .nav-link::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: shine 1.5s ease-in-out infinite;
}
.bonkers-mode .navbar-nav .nav-link:hover {
background: linear-gradient(45deg, var(--bs-pink), var(--bs-purple));
border-color: var(--bs-white);
box-shadow: 0 0 20px var(--bs-pink);
animation: discoFlash 0.5s infinite, wiggle 0.2s infinite;
}
.bonkers-mode .navbar-nav .nav-link.active {
background: linear-gradient(45deg, var(--bs-yellow), var(--bs-orange));
border-color: var(--bs-white);
box-shadow: 0 0 25px var(--bs-yellow);
animation: discoFlash 0.8s infinite, wiggle 0.3s infinite;
}
.bonkers-mode .navbar-text {
animation: rainbowText 1.5s infinite, wiggle 0.5s infinite;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
border: 2px solid var(--bs-white);
border-radius: 8px;
padding: 6px 12px;
background: linear-gradient(45deg, var(--bs-cyan), var(--bs-blue));
box-shadow: 0 0 15px var(--bs-cyan);
}
.bonkers-mode .navbar-toggler {
border: 3px solid var(--bs-white);
background: linear-gradient(45deg, var(--bs-pink), var(--bs-purple));
animation: discoFlash 0.6s infinite, wiggle 0.4s infinite;
box-shadow: 0 0 20px var(--bs-pink);
}
.bonkers-mode .navbar-toggler:focus {
box-shadow: 0 0 30px var(--bs-pink), 0 0 0 0.2rem rgba(255, 105, 180, 0.5);
}
.bonkers-mode .navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba(255, 255, 255, 1)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
animation: spin 1s linear infinite;
}
.bonkers-mode .dropdown-menu {
background: linear-gradient(135deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan));
border: 3px solid var(--bs-white);
box-shadow: 0 0 30px rgba(255,105,180,0.8);
animation: rainbowGradient 2s ease infinite;
}
.bonkers-mode .dropdown-item {
animation: rainbowText 1.8s infinite, wiggle 0.6s infinite;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
border-bottom: 1px solid rgba(255,255,255,0.3);
transition: all 0.3s ease;
}
.bonkers-mode .dropdown-item:hover {
background: linear-gradient(45deg, var(--bs-yellow), var(--bs-orange));
color: var(--bs-white);
box-shadow: 0 0 15px var(--bs-yellow);
animation: discoFlash 0.5s infinite, wiggle 0.3s infinite;
}
.bonkers-mode .navbar-collapse {
background: linear-gradient(135deg, rgba(255,105,180,0.1), rgba(138,43,226,0.1));
border-radius: 8px;
margin-top: 8px;
padding: 8px;
border: 2px solid var(--bs-pink);
}
.bonkers-mode h1, .bonkers-mode h2, .bonkers-mode h3 {
animation: rainbowText 1.5s infinite;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.bonkers-mode .table {
background: linear-gradient(135deg, rgba(255,105,180,0.2), rgba(138,43,226,0.2), rgba(0,255,255,0.2));
animation: rainbowGradient 3s ease infinite;
border: 3px solid var(--bs-pink);
box-shadow: 0 0 30px rgba(255,105,180,0.5);
}
.bonkers-mode .table th {
background: linear-gradient(45deg, var(--bs-pink), var(--bs-purple));
color: var(--bs-white);
animation: discoFlash 0.8s infinite;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
font-size: 1.1em;
}
.bonkers-mode .form-control {
border: 3px solid var(--bs-pink);
box-shadow: 0 0 15px var(--bs-pink);
animation: pulse 0.6s infinite;
}
.bonkers-mode .alert {
animation: discoFlash 0.6s infinite, wiggle 0.3s infinite;
border: 4px solid var(--bs-white);
font-weight: bold;
font-size: 1.1em;
}
.bonkers-mode .card {
background: linear-gradient(45deg, rgba(255,105,180,0.2), rgba(138,43,226,0.2));
border: 3px solid var(--bs-purple);
box-shadow: 0 0 35px rgba(138,43,226,0.6);
animation: pulse 1s infinite;
}
.bonkers-mode .modal-content {
background: linear-gradient(135deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan));
border: 4px solid var(--bs-white);
box-shadow: 0 0 50px rgba(255,105,180,0.8);
animation: rainbowGradient 2s ease infinite;
}
.bonkers-mode .modal-header {
background: linear-gradient(90deg, var(--bs-yellow), var(--bs-orange));
animation: discoFlash 0.8s infinite;
font-size: 1.2em;
}
.bonkers-mode .number-input-wrapper {
animation: wiggle 0.4s infinite;
}
.bonkers-mode .number-input-wrapper .btn {
animation: discoFlash 0.3s infinite, wiggle 0.2s infinite;
}
/* Enhanced mode styles (for future use) */
[data-website-mode="enhanced"] .btn {
background: linear-gradient(45deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan), var(--bs-yellow), var(--bs-green), var(--bs-orange), var(--bs-red));
background-size: 400% 400%;
animation: rainbowGradient 1s ease infinite;
border: 4px solid var(--bs-white);
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
position: relative;
overflow: hidden;
transition: all 0.2s ease;
}
[data-website-mode="enhanced"] .btn:hover {
animation: rainbowGradient 0.5s ease infinite;
box-shadow: 0 0 30px var(--bs-pink), 0 0 60px var(--bs-purple);
}
[data-website-mode="enhanced"] .btn::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255,255,255,0.5), transparent);
transform: rotate(45deg);
animation: spin 0.5s linear infinite;
}
[data-website-mode="enhanced"] .navbar {
background: linear-gradient(90deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan), var(--bs-yellow), var(--bs-green), var(--bs-orange), var(--bs-red));
background-size: 200% 200%;
animation: rainbowGradient 2s ease infinite;
box-shadow: 0 0 50px rgba(255, 105, 180, 0.9);
height: auto !important;
min-height: 56px;
}
[data-website-mode="enhanced"] .navbar-brand {
animation: rainbowText 0.8s infinite;
font-size: 1.8em;
text-shadow: 3px 3px 6px rgba(0,0,0,0.5);
position: relative;
overflow: hidden;
}
[data-website-mode="enhanced"] .navbar-brand::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255,255,255,0.3), transparent);
transform: rotate(45deg);
animation: spin 2s linear infinite;
}
[data-website-mode="enhanced"] .navbar-nav .nav-link {
animation: rainbowText 1.2s infinite;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
border: 2px solid transparent;
border-radius: 8px;
padding: 8px 16px;
margin: 0 4px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
[data-website-mode="enhanced"] .navbar-nav .nav-link::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: shine 1.5s ease-in-out infinite;
}
[data-website-mode="enhanced"] .navbar-nav .nav-link:hover {
background: linear-gradient(45deg, var(--bs-pink), var(--bs-purple));
border-color: var(--bs-white);
box-shadow: 0 0 20px var(--bs-pink);
}
[data-website-mode="enhanced"] .navbar-nav .nav-link.active {
background: linear-gradient(45deg, var(--bs-yellow), var(--bs-orange));
border-color: var(--bs-white);
box-shadow: 0 0 25px var(--bs-yellow);
}
[data-website-mode="enhanced"] .navbar-text {
animation: rainbowText 1.5s infinite;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
border: 2px solid var(--bs-white);
border-radius: 8px;
padding: 6px 12px;
background: linear-gradient(45deg, var(--bs-cyan), var(--bs-blue));
box-shadow: 0 0 15px var(--bs-cyan);
}
[data-website-mode="enhanced"] .navbar-toggler {
border: 3px solid var(--bs-white);
background: linear-gradient(45deg, var(--bs-pink), var(--bs-purple));
animation: rainbowGradient 0.6s ease infinite;
box-shadow: 0 0 20px var(--bs-pink);
}
[data-website-mode="enhanced"] .navbar-toggler:focus {
box-shadow: 0 0 30px var(--bs-pink), 0 0 0 0.2rem rgba(255, 105, 180, 0.5);
}
[data-website-mode="enhanced"] .navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba(255, 255, 255, 1)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
animation: spin 1s linear infinite;
}
[data-website-mode="enhanced"] .dropdown-menu {
background: linear-gradient(135deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan));
border: 3px solid var(--bs-white);
box-shadow: 0 0 30px rgba(255,105,180,0.8);
animation: rainbowGradient 2s ease infinite;
}
[data-website-mode="enhanced"] .dropdown-item {
animation: rainbowText 1.8s infinite;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
border-bottom: 1px solid rgba(255,255,255,0.3);
transition: all 0.3s ease;
}
[data-website-mode="enhanced"] .dropdown-item:hover {
background: linear-gradient(45deg, var(--bs-yellow), var(--bs-orange));
color: var(--bs-white);
box-shadow: 0 0 15px var(--bs-yellow);
}
[data-website-mode="enhanced"] .navbar-collapse {
background: linear-gradient(135deg, rgba(255,105,180,0.1), rgba(138,43,226,0.1));
border-radius: 8px;
margin-top: 8px;
padding: 8px;
border: 2px solid var(--bs-pink);
}
[data-website-mode="enhanced"] h1, [data-website-mode="enhanced"] h2, [data-website-mode="enhanced"] h3 {
animation: rainbowText 1.5s infinite;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
[data-website-mode="enhanced"] .table {
background: linear-gradient(135deg, rgba(255,105,180,0.2), rgba(138,43,226,0.2), rgba(0,255,255,0.2));
animation: rainbowGradient 3s ease infinite;
border: 3px solid var(--bs-pink);
box-shadow: 0 0 30px rgba(255,105,180,0.5);
}
[data-website-mode="enhanced"] .table th {
background: linear-gradient(45deg, var(--bs-pink), var(--bs-purple));
color: var(--bs-white);
animation: rainbowGradient 0.8s ease infinite;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
font-size: 1.1em;
}
[data-website-mode="enhanced"] .form-control {
border: 3px solid var(--bs-pink);
box-shadow: 0 0 15px var(--bs-pink);
}
[data-website-mode="enhanced"] .alert {
animation: rainbowGradient 0.6s ease infinite;
border: 4px solid var(--bs-white);
font-weight: bold;
font-size: 1.1em;
}
[data-website-mode="enhanced"] .card {
background: linear-gradient(45deg, rgba(255,105,180,0.2), rgba(138,43,226,0.2));
border: 3px solid var(--bs-purple);
box-shadow: 0 0 35px rgba(138,43,226,0.6);
}
[data-website-mode="enhanced"] .modal-content {
background: linear-gradient(135deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan));
border: 4px solid var(--bs-white);
box-shadow: 0 0 50px rgba(255,105,180,0.8);
animation: rainbowGradient 2s ease infinite;
}
[data-website-mode="enhanced"] .modal-header {
background: linear-gradient(90deg, var(--bs-yellow), var(--bs-orange));
animation: rainbowGradient 0.8s ease infinite;
font-size: 1.2em;
}
[data-website-mode="enhanced"] .number-input-wrapper {
}
[data-website-mode="enhanced"] .number-input-wrapper .btn {
animation: rainbowGradient 0.3s ease infinite;
}
/* Emoji Footprint Animation */
.emoji-footprint {
position: absolute;
font-size: 1.6rem;
pointer-events: none;
animation: emojiFade 1s ease-out forwards;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
z-index: 9999;
text-shadow:
0 0 4px #ff00bf,
0 0 8px #ff80df,
0 0 12px #ffccff;
}
@keyframes emojiFade {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0.7;
}
100% {
transform: translate(-50%, -50%) scale(2);
opacity: 0;
}
}
/* 💅 SLAY TEXT EFFECTS 💅 */
.slay-text {
position: fixed;
font-size: 2rem;
font-weight: bold;
pointer-events: none;
z-index: 10000;
animation: slayFloat 3s ease-out forwards;
text-shadow:
0 0 10px #ff00bf,
0 0 20px #ff80df,
0 0 30px #ffccff,
2px 2px 4px rgba(0,0,0,0.5);
background: linear-gradient(45deg, var(--bs-pink), var(--bs-purple), var(--bs-cyan), var(--bs-yellow));
background-size: 400% 400%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: slayFloat 3s ease-out forwards, rainbowGradient 1s ease infinite;
}

View file

@ -1,23 +0,0 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 80000) {
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
} else {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
}
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}

View file

@ -4,44 +4,65 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.3",
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.12",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.2",
"symfony/console": "7.1.*",
"symfony/dotenv": "7.1.*",
"symfony/flex": "^2",
"symfony/form": "7.1.*",
"symfony/framework-bundle": "7.1.*",
"symfony/runtime": "7.1.*",
"symfony/security-csrf": "7.1.*",
"symfony/twig-bundle": "7.1.*",
"symfony/uid": "7.1.*",
"symfony/validator": "7.1.*",
"symfony/yaml": "7.1.*"
"api-platform/doctrine-orm": ">=4.1.18",
"api-platform/symfony": ">=4.1.18",
"doctrine/dbal": "^4.2.4",
"doctrine/doctrine-bundle": "^2.15.0",
"doctrine/doctrine-migrations-bundle": "^3.4.2",
"doctrine/orm": "^3.5.0",
"nelmio/cors-bundle": "^2.5",
"phpdocumentor/reflection-docblock": "^5.6.2",
"psr/clock": "^1.0",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2.8.1",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/monolog-bundle": "^3.10",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/security-csrf": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/uid": "7.3.*",
"symfony/validator": "7.3.*",
"symfony/yaml": "7.3.*",
"twig/extra-bundle": "^2.12|^3.21",
"twig/intl-extra": "^3.21",
"twig/twig": "^2.12|^3.21.1"
},
"require-dev": {
"lubiana/code-quality": "^1.7",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "7.1.*",
"symfony/css-selector": "7.1.*",
"symfony/maker-bundle": "^1.60",
"symfony/phpunit-bridge": "^7.1",
"symplify/config-transformer": "^12.3"
"doctrine/doctrine-fixtures-bundle": "^4.1",
"liip/test-fixtures-bundle": "^3.5",
"lubiana/code-quality": "1.7.3",
"pestphp/pest": "^3.8.2",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/maker-bundle": "^1.64",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*",
"symplify/config-transformer": "^12.4.0"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"pestphp/pest-plugin": true,
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true,
"dealerdirect/phpcodesniffer-composer-installer": true
"symfony/runtime": true
},
"sort-packages": true,
"platform": {
"php": "8.3"
"php": "8.4"
}
},
"autoload": {
@ -62,25 +83,28 @@
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*",
"symfony/polyfill-php84": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts",
"config-transformer switch-format config"
"config-transformer config"
],
"lint": [
"rector",
"ecs --fix || ecs --fix"
],
"test": "bin/phpunit"
"test": "pest --parallel"
},
"conflict": {
"symfony/symfony": "*"
@ -88,7 +112,7 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.1.*"
"require": "7.3.*"
}
}
}

6560
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,18 @@
<?php declare(strict_types=1);
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Liip\TestFixturesBundle\LiipTestFixturesBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MakerBundle\MakerBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
return [
FrameworkBundle::class => [
@ -22,4 +30,31 @@ return [
TwigBundle::class => [
'all' => true,
],
DoctrineFixturesBundle::class => [
'dev' => true,
'test' => true,
],
WebProfilerBundle::class => [
'dev' => true,
'test' => true,
],
SecurityBundle::class => [
'all' => true,
],
NelmioCorsBundle::class => [
'all' => true,
],
LiipTestFixturesBundle::class => [
'dev' => true,
'test' => true,
],
TwigExtraBundle::class => [
'all' => true,
],
MonologBundle::class => [
'all' => true,
],
ApiPlatformBundle::class => [
'all' => true,
],
];

View file

@ -0,0 +1,20 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('api_platform', [
'title' => 'Hello API Platform',
'version' => '1.0.0',
'defaults' => [
'stateless' => true,
'cache_headers' => [
'vary' => [
'Content-Type',
'Authorization',
'Origin',
],
],
],
]);
};

View file

@ -0,0 +1,19 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Config\FrameworkConfig;
return static function (
ContainerConfigurator $containerConfigurator,
FrameworkConfig $frameworkConfig,
): void {
$frameworkConfig->assetMapper()
->path('assets/', true)
->missingImportMode('strict')
->importmapPolyfill(false)
;
if ($containerConfigurator->env() === 'prod') {
$frameworkConfig->assetMapper()
->missingImportMode('warn');
}
};

20
config/packages/csrf.php Normal file
View file

@ -0,0 +1,20 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'form' => [
'csrf_protection' => [
'token_id' => 'submit',
],
],
'csrf_protection' => [
'stateless_token_ids' => [
'submit',
'authenticate',
'logout',
],
],
]);
};

View file

@ -0,0 +1,95 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('monolog', [
'channels' => [
'deprecation',
],
]);
if ($containerConfigurator->env() === 'dev') {
$containerConfigurator->extension('monolog', [
'handlers' => [
'main' => [
'type' => 'stream',
'path' => '%kernel.logs_dir%/%kernel.environment%.log',
'level' => 'debug',
'channels' => [
'!event',
],
],
'console' => [
'type' => 'console',
'process_psr_3_messages' => false,
'channels' => [
'!event',
'!doctrine',
'!console',
],
],
],
]);
}
if ($containerConfigurator->env() === 'test') {
$containerConfigurator->extension('monolog', [
'handlers' => [
'main' => [
'type' => 'fingers_crossed',
'action_level' => 'error',
'handler' => 'nested',
'excluded_http_codes' => [
404,
405,
],
'channels' => [
'!event',
],
],
'nested' => [
'type' => 'stream',
'path' => '%kernel.logs_dir%/%kernel.environment%.log',
'level' => 'debug',
],
],
]);
}
if ($containerConfigurator->env() === 'prod') {
$containerConfigurator->extension('monolog', [
'handlers' => [
'main' => [
'type' => 'fingers_crossed',
'action_level' => 'error',
'handler' => 'nested',
'excluded_http_codes' => [
404,
405,
],
'buffer_size' => 50,
],
'nested' => [
'type' => 'stream',
'path' => 'php://stderr',
'level' => 'debug',
'formatter' => 'monolog.formatter.json',
],
'console' => [
'type' => 'console',
'process_psr_3_messages' => false,
'channels' => [
'!event',
'!doctrine',
],
],
'deprecation' => [
'type' => 'stream',
'channels' => [
'deprecation',
],
'path' => 'php://stderr',
'formatter' => 'monolog.formatter.json',
],
],
]);
}
};

View file

@ -0,0 +1,33 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('nelmio_cors', [
'defaults' => [
'origin_regex' => true,
'allow_origin' => [
'%env(CORS_ALLOW_ORIGIN)%',
],
'allow_methods' => [
'GET',
'OPTIONS',
'POST',
'PUT',
'PATCH',
'DELETE',
],
'allow_headers' => [
'Content-Type',
'Authorization',
],
'expose_headers' => [
'Link',
],
'max_age' => 3600,
],
'paths' => [
'^/' => null,
],
]);
};

View file

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'property_info' => [
'with_constructor_extractor' => true,
],
]);
};

View file

@ -0,0 +1,40 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('security', [
'password_hashers' => [
PasswordAuthenticatedUserInterface::class => 'auto',
],
'providers' => [
'users_in_memory' => [
'memory' => null,
],
],
'firewalls' => [
'dev' => [
'pattern' => '^/(_(profiler|wdt)|css|images|js)/',
'security' => false,
],
'main' => [
'lazy' => true,
'provider' => 'users_in_memory',
],
],
'access_control' => null,
]);
if ($containerConfigurator->env() === 'test') {
$containerConfigurator->extension('security', [
'password_hashers' => [
PasswordAuthenticatedUserInterface::class => [
'algorithm' => 'auto',
'cost' => 4,
'time_cost' => 3,
'memory_cost' => 10,
],
],
]);
}
};

View file

@ -1,14 +1,16 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Config\TwigConfig;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('twig', [
'file_name_pattern' => '*.twig',
]);
return static function (
ContainerConfigurator $containerConfigurator,
TwigConfig $twig,
): void {
if ($containerConfigurator->env() === 'test') {
$containerConfigurator->extension('twig', [
'strict_variables' => true,
]);
$twig->strictVariables(true);
}
$twig->formThemes(['bootstrap_5_layout.html.twig']);
$twig->fileNamePattern('*.twig');
$twig->global('favicon', '@App\Service\Favicon');
};

View file

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
if ($containerConfigurator->env() === 'dev') {
$containerConfigurator->extension('web_profiler', [
'toolbar' => true,
'intercept_redirects' => false,
]);
$containerConfigurator->extension('framework', [
'profiler' => [
'only_exceptions' => false,
'collect_serializer_data' => true,
],
]);
}
if ($containerConfigurator->env() === 'test') {
$containerConfigurator->extension('web_profiler', [
'toolbar' => false,
'intercept_redirects' => false,
]);
$containerConfigurator->extension('framework', [
'profiler' => [
'collect' => false,
],
]);
}
};

View file

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
return static function (RoutingConfigurator $routingConfigurator): void {
$routingConfigurator->import('.', 'api_platform')
->prefix('/api');
};

View file

@ -0,0 +1,7 @@
<?php declare(strict_types=1);
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
return static function (RoutingConfigurator $routingConfigurator): void {
$routingConfigurator->import('security.route_loader.logout', 'service');
};

View file

@ -0,0 +1,12 @@
<?php declare(strict_types=1);
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
return static function (RoutingConfigurator $routingConfigurator): void {
if ($routingConfigurator->env() === 'dev') {
$routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/wdt.xml')
->prefix('/_wdt');
$routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/profiler.xml')
->prefix('/_profiler');
}
};

View file

@ -1,2 +0,0 @@
FROM git.php.fail/lubiana/container/php:8.3 as phpbuild

View file

@ -1,10 +0,0 @@
[www]
user = nobody
group = nobody
listen = 9001
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

View file

@ -0,0 +1,13 @@
[www]
user = root
group = root
listen = 9001
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
env[APP_ENV]=$APP_ENV
env[APP_SECRET]=$APP_SECRET
catch_workers_output = yes

View file

@ -1,31 +0,0 @@
#!/usr/bin/env sh
if [ ! -d "var" ]; then
mkdir var
fi
podman pod stop futtern
podman pod rm futtern
podman pod create \
--label "io.containers.autoupdate=registry" \
--name futtern \
-p 8087:8087
podman run -d \
--pod futtern \
--name futtern-php \
--volume $(pwd)/etc/php83/php-fpm.d/www.conf:/etc/php83/php-fpm.d/www.conf \
--volume $(pwd)/futtern-app:/var/www/html \
--volume $(pwd)/var:/var/www/html/var \
--env 'APP_ENV=prod' \
git.php.fail/lubiana/container/php:8.3-fpm
podman run -d \
--pod futtern \
--name futtern-caddy \
--volume $(pwd)/etc/caddy/Caddyfile:/etc/caddy/Caddyfile \
--volume $(pwd)/futtern-app:/var/www/html \
--volume caddy_data:/data \
docker.io/caddy/caddy:alpine
echo 'yes' | podman exec -it futtern-php /var/www/html/bin/console doctrine:migrations:migrate

10
deploy/local-deploy.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env sh
export HOMEDIR='/home/c3h-futtern/'
. ./deploy/prepare-deploy.sh
ssh leitstelle-futtern 'systemctl --user stop pod-futtern'
ssh leitstelle-futtern "cp ${HOMEDIR}/futtern/app/var/data.db ${HOMEDIR}/backup/data.db-$(date +\"%Y%m%d%H%M%S\")"
ssh leitstelle-futtern "find ${HOMEDIR}/backup/ -type f | sort | head -n -10 | xargs rm -f"
rsync -avz --delete deploy/ leitstelle-futtern:futtern --exclude=var
ssh leitstelle-futtern '/home/c3h-futtern/futtern/update.sh'

16
deploy/prepare-deploy.sh Normal file → Executable file
View file

@ -1,6 +1,6 @@
#!/usr/bin/env sh
TARGETDIR='futtern-app'
TARGETDIR='deploy/app'
if [ -d $TARGETDIR ]; then
rm -rf $TARGETDIR
@ -8,23 +8,15 @@ fi
mkdir $TARGETDIR
cd $TARGETDIR || return
pathsToCopy="public bin config migrations src templates composer.json composer.lock symfony.lock .env etc"
pathsToCopy="assets public bin config migrations src templates composer.json composer.lock symfony.lock .env importmap.php"
for path in $pathsToCopy
do
cp -r ../../"$path" ./
done
rm ./bin/phpunit
APP_ENV=prod composer install --no-dev -a
mkdir -p ~/.ssh/
# Print the SSH key, replacing newline characters with actual new lines
echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
# Set appropriate permissions for the SSH key
chmod 600 ~/.ssh/id_rsa
# Add the remote host's key to the known_hosts file to avoid authenticity confirmation
ssh-keyscan -H $HOST >> ~/.ssh/known_hosts
# SCP files to the remote host
rsync -avz --delete public/ ${USERNAME}@${HOST}:${TARGETDIR}
rm -rf ./var/cache
cd -

View file

@ -0,0 +1,43 @@
# container-futtern-caddy.service
# autogenerated by Podman 4.3.1
# Sun Jun 23 05:33:51 UTC 2024
[Unit]
Description=Podman container-futtern-caddy.service
Documentation=man:podman-generate-systemd(1)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=%t/containers
BindsTo=pod-futtern.service
After=pod-futtern.service
[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
Restart=on-failure
TimeoutStopSec=70
ExecStartPre=/bin/rm \
-f %t/%n.ctr-id
ExecStart=/usr/bin/podman run \
--cidfile=%t/%n.ctr-id \
--cgroups=no-conmon \
--rm \
--pod-id-file %t/pod-futtern.pod-id \
--sdnotify=conmon \
--replace \
-d \
--name futtern-caddy \
--volume %h/futtern/etc/caddy/Caddyfile:/etc/caddy/Caddyfile \
--volume %h/futtern/app:/var/www/html \
--volume caddy_data:/data docker.io/caddy/caddy:alpine
ExecStop=/usr/bin/podman stop \
--ignore -t 10 \
--cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm \
-f \
--ignore -t 10 \
--cidfile=%t/%n.ctr-id
Type=notify
NotifyAccess=all
[Install]
WantedBy=default.target

View file

@ -0,0 +1,46 @@
# container-futtern-php.service
# autogenerated by Podman 4.3.1
# Sun Jun 23 05:33:51 UTC 2024
[Unit]
Description=Podman container-futtern-php.service
Documentation=man:podman-generate-systemd(1)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=%t/containers
BindsTo=pod-futtern.service
After=pod-futtern.service
[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
Restart=on-failure
TimeoutStopSec=70
ExecStartPre=/bin/rm \
-f %t/%n.ctr-id
ExecStart=/usr/bin/podman run \
--cidfile=%t/%n.ctr-id \
--cgroups=no-conmon \
--rm \
--pod-id-file %t/pod-futtern.pod-id \
--sdnotify=conmon \
--replace \
-d \
--name futtern-php \
--volume %h/futtern/etc/php84/php-fpm.d/www.conf:/etc/php84/php-fpm.d/www.conf \
--volume %h/futtern/app:/var/www/html \
--volume %h/futtern/app/var:/var/www/html/var \
--env APP_ENV=prod \
--env APP_SECRET=UwUtHiSisNotSecurePlZcHanGeMe \
git.php.fail/lubiana/container/php:8.4-fpm
ExecStop=/usr/bin/podman stop \
--ignore -t 10 \
--cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm \
-f \
--ignore -t 10 \
--cidfile=%t/%n.ctr-id
Type=notify
NotifyAccess=all
[Install]
WantedBy=default.target

View file

@ -0,0 +1,42 @@
# pod-futtern.service
# autogenerated by Podman 4.3.1
# Sun Jun 23 05:33:51 UTC 2024
[Unit]
Description=Podman pod-futtern.service
Documentation=man:podman-generate-systemd(1)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/run/user/%U/containers
Wants=container-futtern-caddy.service container-futtern-php.service
Before=container-futtern-caddy.service container-futtern-php.service
[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
Restart=on-failure
TimeoutStopSec=70
ExecStartPre=/bin/rm \
-f %t/pod-futtern.pid %t/pod-futtern.pod-id
ExecStartPre=/usr/bin/podman pod create \
--infra-conmon-pidfile %t/pod-futtern.pid \
--pod-id-file %t/pod-futtern.pod-id \
--exit-policy=stop \
--label io.containers.autoupdate=registry \
--name futtern \
-p 8087:8087 \
--replace
ExecStart=/usr/bin/podman pod start \
--pod-id-file %t/pod-futtern.pod-id
ExecStop=/usr/bin/podman pod stop \
--ignore \
--pod-id-file %t/pod-futtern.pod-id \
-t 10
ExecStopPost=/usr/bin/podman pod rm \
--ignore \
-f \
--pod-id-file %t/pod-futtern.pod-id
PIDFile=%t/pod-futtern.pid
Type=forking
[Install]
WantedBy=default.target

11
deploy/update.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env sh
systemctl --user stop pod-futtern
systemctl --user start pod-futtern
sleep 2
podman exec -it futtern-php /var/www/html/bin/console cache:clear
podman exec -it futtern-php /var/www/html/bin/console cache:warmup
podman exec -it futtern-php /var/www/html/bin/console asset-map:compile
echo 'yes' | podman exec -it futtern-php /var/www/html/bin/console doctrine:migrations:migrate

View file

@ -13,5 +13,7 @@ return ECSConfig::configure()
__DIR__ . '/tests',
])
->withRootFiles()
->withRules([FinalClassFixer::class])
->withRules([
FinalClassFixer::class,
])
->withSets([LubiSetList::ECS]);

32
importmap.php Normal file
View file

@ -0,0 +1,32 @@
<?php declare(strict_types=1);
/**
* Returns the importmap for this application.
*
* - "path" is a path inside the asset mapper system. Use the
* "debug:asset-map" command to see the full list of paths.
*
* - "entrypoint" (JavaScript only) set to true for any module that will
* be used as an "entrypoint" (and passed to the importmap() Twig function).
*
* The "importmap:require" command can be used to add new entries to this file.
*/
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'bootstrap' => [
'version' => '5.3.7',
],
'@popperjs/core' => [
'version' => '2.11.8',
],
'bootstrap/dist/css/bootstrap.min.css' => [
'version' => '5.3.7',
'type' => 'css',
],
'htmx.org' => [
'version' => '2.0.5',
],
];

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240626175246 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE menu_item (id BLOB NOT NULL, name VARCHAR(255) NOT NULL, food_vendor_id BLOB NOT NULL, PRIMARY KEY(id), CONSTRAINT FK_D754D5506EF983E8 FOREIGN KEY (food_vendor_id) REFERENCES food_vendor (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_D754D5506EF983E8 ON menu_item (food_vendor_id)');
$this->addSql('CREATE TEMPORARY TABLE __temp__food_order AS SELECT id, food_vendor_id, closed_at FROM food_order');
$this->addSql('DROP TABLE food_order');
$this->addSql('CREATE TABLE food_order (id BLOB NOT NULL, food_vendor_id BLOB NOT NULL, closed_at DATETIME DEFAULT NULL, PRIMARY KEY(id), CONSTRAINT FK_44856726EF983E8 FOREIGN KEY (food_vendor_id) REFERENCES food_vendor (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO food_order (id, food_vendor_id, closed_at) SELECT id, food_vendor_id, closed_at FROM __temp__food_order');
$this->addSql('DROP TABLE __temp__food_order');
$this->addSql('CREATE INDEX IDX_44856726EF983E8 ON food_order (food_vendor_id)');
$this->addSql('CREATE TEMPORARY TABLE __temp__food_vendor AS SELECT id, name FROM food_vendor');
$this->addSql('DROP TABLE food_vendor');
$this->addSql('CREATE TABLE food_vendor (id BLOB NOT NULL, name VARCHAR(50) NOT NULL, PRIMARY KEY(id))');
$this->addSql('INSERT INTO food_vendor (id, name) SELECT id, name FROM __temp__food_vendor');
$this->addSql('DROP TABLE __temp__food_vendor');
$this->addSql('CREATE TEMPORARY TABLE __temp__order_item AS SELECT id, food_order_id, name, extras FROM order_item');
$this->addSql('DROP TABLE order_item');
$this->addSql('CREATE TABLE order_item (id BLOB NOT NULL, food_order_id BLOB DEFAULT NULL, name VARCHAR(255) NOT NULL, extras VARCHAR(255) DEFAULT NULL, menu_item_id BLOB DEFAULT NULL, PRIMARY KEY(id), CONSTRAINT FK_52EA1F09A5D24A7A FOREIGN KEY (food_order_id) REFERENCES food_order (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_52EA1F099AB44FE0 FOREIGN KEY (menu_item_id) REFERENCES menu_item (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO order_item (id, food_order_id, name, extras) SELECT id, food_order_id, name, extras FROM __temp__order_item');
$this->addSql('DROP TABLE __temp__order_item');
$this->addSql('CREATE INDEX IDX_52EA1F09A5D24A7A ON order_item (food_order_id)');
$this->addSql('CREATE INDEX IDX_52EA1F099AB44FE0 ON order_item (menu_item_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE menu_item');
$this->addSql('CREATE TEMPORARY TABLE __temp__food_order AS SELECT id, closed_at, food_vendor_id FROM food_order');
$this->addSql('DROP TABLE food_order');
$this->addSql('CREATE TABLE food_order (id BLOB NOT NULL --(DC2Type:ulid)
, closed_at DATETIME DEFAULT NULL --(DC2Type:datetime_immutable)
, food_vendor_id BLOB NOT NULL --(DC2Type:ulid)
, PRIMARY KEY(id), CONSTRAINT FK_44856726EF983E8 FOREIGN KEY (food_vendor_id) REFERENCES food_vendor (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO food_order (id, closed_at, food_vendor_id) SELECT id, closed_at, food_vendor_id FROM __temp__food_order');
$this->addSql('DROP TABLE __temp__food_order');
$this->addSql('CREATE INDEX IDX_44856726EF983E8 ON food_order (food_vendor_id)');
$this->addSql('CREATE TEMPORARY TABLE __temp__food_vendor AS SELECT id, name FROM food_vendor');
$this->addSql('DROP TABLE food_vendor');
$this->addSql('CREATE TABLE food_vendor (id BLOB NOT NULL --(DC2Type:ulid)
, name VARCHAR(50) NOT NULL, PRIMARY KEY(id))');
$this->addSql('INSERT INTO food_vendor (id, name) SELECT id, name FROM __temp__food_vendor');
$this->addSql('DROP TABLE __temp__food_vendor');
$this->addSql('CREATE TEMPORARY TABLE __temp__order_item AS SELECT id, name, extras, food_order_id FROM order_item');
$this->addSql('DROP TABLE order_item');
$this->addSql('CREATE TABLE order_item (id BLOB NOT NULL --(DC2Type:ulid)
, name VARCHAR(255) NOT NULL, extras VARCHAR(255) DEFAULT NULL, food_order_id BLOB NOT NULL --(DC2Type:ulid)
, PRIMARY KEY(id), CONSTRAINT FK_52EA1F09A5D24A7A FOREIGN KEY (food_order_id) REFERENCES food_order (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO order_item (id, name, extras, food_order_id) SELECT id, name, extras, food_order_id FROM __temp__order_item');
$this->addSql('DROP TABLE __temp__order_item');
$this->addSql('CREATE INDEX IDX_52EA1F09A5D24A7A ON order_item (food_order_id)');
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240626182031 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__order_item AS SELECT id, food_order_id, name, extras, menu_item_id FROM order_item');
$this->addSql('DROP TABLE order_item');
$this->addSql('CREATE TABLE order_item (id BLOB NOT NULL, food_order_id BLOB DEFAULT NULL, name VARCHAR(255) NOT NULL, extras VARCHAR(255) DEFAULT NULL, menu_item_id BLOB NOT NULL, PRIMARY KEY(id), CONSTRAINT FK_52EA1F09A5D24A7A FOREIGN KEY (food_order_id) REFERENCES food_order (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_52EA1F099AB44FE0 FOREIGN KEY (menu_item_id) REFERENCES menu_item (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO order_item (id, food_order_id, name, extras, menu_item_id) SELECT id, food_order_id, name, extras, menu_item_id FROM __temp__order_item');
$this->addSql('DROP TABLE __temp__order_item');
$this->addSql('CREATE INDEX IDX_52EA1F099AB44FE0 ON order_item (menu_item_id)');
$this->addSql('CREATE INDEX IDX_52EA1F09A5D24A7A ON order_item (food_order_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__order_item AS SELECT id, name, extras, food_order_id, menu_item_id FROM order_item');
$this->addSql('DROP TABLE order_item');
$this->addSql('CREATE TABLE order_item (id BLOB NOT NULL, name VARCHAR(255) NOT NULL, extras VARCHAR(255) DEFAULT NULL, food_order_id BLOB DEFAULT NULL, menu_item_id BLOB DEFAULT NULL, PRIMARY KEY(id), CONSTRAINT FK_52EA1F09A5D24A7A FOREIGN KEY (food_order_id) REFERENCES food_order (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_52EA1F099AB44FE0 FOREIGN KEY (menu_item_id) REFERENCES menu_item (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO order_item (id, name, extras, food_order_id, menu_item_id) SELECT id, name, extras, food_order_id, menu_item_id FROM __temp__order_item');
$this->addSql('DROP TABLE __temp__order_item');
$this->addSql('CREATE INDEX IDX_52EA1F09A5D24A7A ON order_item (food_order_id)');
$this->addSql('CREATE INDEX IDX_52EA1F099AB44FE0 ON order_item (menu_item_id)');
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240627212849 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE food_order ADD COLUMN created_by VARCHAR(255) DEFAULT \'nobody\' NOT NULL');
$this->addSql('ALTER TABLE order_item ADD COLUMN created_by VARCHAR(255) DEFAULT \'nobody\' NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__food_order AS SELECT id, closed_at, food_vendor_id FROM food_order');
$this->addSql('DROP TABLE food_order');
$this->addSql('CREATE TABLE food_order (id BLOB NOT NULL, closed_at DATETIME DEFAULT NULL, food_vendor_id BLOB NOT NULL, PRIMARY KEY(id), CONSTRAINT FK_44856726EF983E8 FOREIGN KEY (food_vendor_id) REFERENCES food_vendor (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO food_order (id, closed_at, food_vendor_id) SELECT id, closed_at, food_vendor_id FROM __temp__food_order');
$this->addSql('DROP TABLE __temp__food_order');
$this->addSql('CREATE INDEX IDX_44856726EF983E8 ON food_order (food_vendor_id)');
$this->addSql('CREATE TEMPORARY TABLE __temp__order_item AS SELECT id, name, extras, food_order_id, menu_item_id FROM order_item');
$this->addSql('DROP TABLE order_item');
$this->addSql('CREATE TABLE order_item (id BLOB NOT NULL, name VARCHAR(255) NOT NULL, extras VARCHAR(255) DEFAULT NULL, food_order_id BLOB DEFAULT NULL, menu_item_id BLOB NOT NULL, PRIMARY KEY(id), CONSTRAINT FK_52EA1F09A5D24A7A FOREIGN KEY (food_order_id) REFERENCES food_order (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_52EA1F099AB44FE0 FOREIGN KEY (menu_item_id) REFERENCES menu_item (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO order_item (id, name, extras, food_order_id, menu_item_id) SELECT id, name, extras, food_order_id, menu_item_id FROM __temp__order_item');
$this->addSql('DROP TABLE __temp__order_item');
$this->addSql('CREATE INDEX IDX_52EA1F09A5D24A7A ON order_item (food_order_id)');
$this->addSql('CREATE INDEX IDX_52EA1F099AB44FE0 ON order_item (menu_item_id)');
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240815151510 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE menu_item ADD COLUMN deleted_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__menu_item AS SELECT id, name, food_vendor_id FROM menu_item');
$this->addSql('DROP TABLE menu_item');
$this->addSql('CREATE TABLE menu_item (id BLOB NOT NULL, name VARCHAR(255) NOT NULL, food_vendor_id BLOB NOT NULL, PRIMARY KEY(id), CONSTRAINT FK_D754D5506EF983E8 FOREIGN KEY (food_vendor_id) REFERENCES food_vendor (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO menu_item (id, name, food_vendor_id) SELECT id, name, food_vendor_id FROM __temp__menu_item');
$this->addSql('DROP TABLE __temp__menu_item');
$this->addSql('CREATE INDEX IDX_D754D5506EF983E8 ON menu_item (food_vendor_id)');
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240816193410 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE food_vendor ADD COLUMN menu_link VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__food_vendor AS SELECT name, id FROM food_vendor');
$this->addSql('DROP TABLE food_vendor');
$this->addSql('CREATE TABLE food_vendor (name VARCHAR(50) NOT NULL, id BLOB NOT NULL, PRIMARY KEY(id))');
$this->addSql('INSERT INTO food_vendor (name, id) SELECT name, id FROM __temp__food_vendor');
$this->addSql('DROP TABLE __temp__food_vendor');
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241218235101 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE food_vendor ADD COLUMN phone VARCHAR(50) DEFAULT \'\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__food_vendor AS SELECT name, menu_link, id FROM food_vendor');
$this->addSql('DROP TABLE food_vendor');
$this->addSql('CREATE TABLE food_vendor (name VARCHAR(50) NOT NULL, menu_link VARCHAR(255) DEFAULT NULL, id BLOB NOT NULL, PRIMARY KEY(id))');
$this->addSql('INSERT INTO food_vendor (name, menu_link, id) SELECT name, menu_link, id FROM __temp__food_vendor');
$this->addSql('DROP TABLE __temp__food_vendor');
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250124234947 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__menu_item AS SELECT id, name, food_vendor_id, deleted_at FROM menu_item');
$this->addSql('DROP TABLE menu_item');
$this->addSql('CREATE TABLE menu_item (id BLOB NOT NULL, name VARCHAR(255) NOT NULL, food_vendor_id BLOB NOT NULL, deleted_at DATETIME DEFAULT NULL, alias_of_id BLOB DEFAULT NULL, PRIMARY KEY(id), CONSTRAINT FK_D754D5506EF983E8 FOREIGN KEY (food_vendor_id) REFERENCES food_vendor (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_D754D55061F0AFC5 FOREIGN KEY (alias_of_id) REFERENCES menu_item (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO menu_item (id, name, food_vendor_id, deleted_at) SELECT id, name, food_vendor_id, deleted_at FROM __temp__menu_item');
$this->addSql('DROP TABLE __temp__menu_item');
$this->addSql('CREATE INDEX IDX_D754D5506EF983E8 ON menu_item (food_vendor_id)');
$this->addSql('CREATE INDEX IDX_D754D55061F0AFC5 ON menu_item (alias_of_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__menu_item AS SELECT name, deleted_at, id, food_vendor_id FROM menu_item');
$this->addSql('DROP TABLE menu_item');
$this->addSql('CREATE TABLE menu_item (name VARCHAR(255) NOT NULL, deleted_at DATETIME DEFAULT NULL, id BLOB NOT NULL, food_vendor_id BLOB NOT NULL, PRIMARY KEY(id), CONSTRAINT FK_D754D5506EF983E8 FOREIGN KEY (food_vendor_id) REFERENCES food_vendor (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO menu_item (name, deleted_at, id, food_vendor_id) SELECT name, deleted_at, id, food_vendor_id FROM __temp__menu_item');
$this->addSql('DROP TABLE __temp__menu_item');
$this->addSql('CREATE INDEX IDX_D754D5506EF983E8 ON menu_item (food_vendor_id)');
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250621131822 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE food_vendor ADD COLUMN emojis VARCHAR(30) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TEMPORARY TABLE __temp__food_vendor AS SELECT name, phone, menu_link, id FROM food_vendor
SQL);
$this->addSql(<<<'SQL'
DROP TABLE food_vendor
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE food_vendor (name VARCHAR(50) NOT NULL, phone VARCHAR(50) DEFAULT '', menu_link VARCHAR(255) DEFAULT NULL, id BLOB NOT NULL, PRIMARY KEY(id))
SQL);
$this->addSql(<<<'SQL'
INSERT INTO food_vendor (name, phone, menu_link, id) SELECT name, phone, menu_link, id FROM __temp__food_vendor
SQL);
$this->addSql(<<<'SQL'
DROP TABLE __temp__food_vendor
SQL);
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250629160123 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE order_item ADD COLUMN is_paid BOOLEAN DEFAULT 0 NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE order_item ADD COLUMN price_cents INTEGER DEFAULT 0 NOT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TEMPORARY TABLE __temp__order_item AS SELECT name, extras, created_by, id, food_order_id, menu_item_id FROM order_item
SQL);
$this->addSql(<<<'SQL'
DROP TABLE order_item
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE order_item (name VARCHAR(255) NOT NULL, extras VARCHAR(255) DEFAULT NULL, created_by VARCHAR(255) DEFAULT 'nobody' NOT NULL, id BLOB NOT NULL, food_order_id BLOB DEFAULT NULL, menu_item_id BLOB NOT NULL, PRIMARY KEY(id), CONSTRAINT FK_52EA1F09A5D24A7A FOREIGN KEY (food_order_id) REFERENCES food_order (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_52EA1F099AB44FE0 FOREIGN KEY (menu_item_id) REFERENCES menu_item (id) NOT DEFERRABLE INITIALLY IMMEDIATE)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO order_item (name, extras, created_by, id, food_order_id, menu_item_id) SELECT name, extras, created_by, id, food_order_id, menu_item_id FROM __temp__order_item
SQL);
$this->addSql(<<<'SQL'
DROP TABLE __temp__order_item
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_52EA1F09A5D24A7A ON order_item (food_order_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_52EA1F099AB44FE0 ON order_item (menu_item_id)
SQL);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250629160639 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE menu_item ADD COLUMN price_cents INTEGER DEFAULT 0 NOT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TEMPORARY TABLE __temp__menu_item AS SELECT name, deleted_at, id, food_vendor_id, alias_of_id FROM menu_item
SQL);
$this->addSql(<<<'SQL'
DROP TABLE menu_item
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE menu_item (name VARCHAR(255) NOT NULL, deleted_at DATETIME DEFAULT NULL, id BLOB NOT NULL, food_vendor_id BLOB NOT NULL, alias_of_id BLOB DEFAULT NULL, PRIMARY KEY(id), CONSTRAINT FK_D754D5506EF983E8 FOREIGN KEY (food_vendor_id) REFERENCES food_vendor (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_D754D55061F0AFC5 FOREIGN KEY (alias_of_id) REFERENCES menu_item (id) NOT DEFERRABLE INITIALLY IMMEDIATE)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO menu_item (name, deleted_at, id, food_vendor_id, alias_of_id) SELECT name, deleted_at, id, food_vendor_id, alias_of_id FROM __temp__menu_item
SQL);
$this->addSql(<<<'SQL'
DROP TABLE __temp__menu_item
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_D754D5506EF983E8 ON menu_item (food_vendor_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_D754D55061F0AFC5 ON menu_item (alias_of_id)
SQL);
}
}

View file

@ -1,20 +0,0 @@
<?php declare(strict_types=1);
use PhpStyler\Config;
use PhpStyler\Files;
use PhpStyler\Styler;
return new Config(
styler: new Styler(lineLen: 79),
files: new Files(
__DIR__ . '/bin',
__DIR__ . '/public',
__DIR__ . '/src',
__DIR__ . '/config',
__DIR__ . '/tests',
__DIR__ . '/php-styler.php',
__DIR__ . '/ecs.php',
__DIR__ . '/rector.php',
),
cache: __DIR__ . '/.php-styler.cache',
);

38
phpunit.xml Normal file
View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
shortenArraysForExportThreshold="10"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
displayDetailsOnPhpunitDeprecations="true"
failOnPhpunitDeprecation="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests/Feature</directory>
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
<exclude>
<file>src/Kernel.php</file>
<file>src/Service/Favicon.php</file>
</exclude>
</source>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="KERNEL_CLASS" value="App\Kernel" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
</phpunit>

View file

@ -14,8 +14,7 @@
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.6" />
<server name="KERNEL_CLASS" value="App\Kernel" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
</php>
<testsuites>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,13 @@
<svg
enable-background="new 0 0 512 512"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<g
transform="rotate(30, 256, 256)"
>
<path d="m51.6 72.608 199.566-34.825 213.114 34.825c19.867 0 32.553 21.19 23.171 38.701l-206.224 384.92c-9.908 18.492-36.421 18.499-46.337.011l-206.455-384.92c-9.393-17.512 3.293-38.712 23.165-38.712z" fill="#fecb21"/>
<path d="m270.254 307.902c0 28.665-23.238 51.902-51.902 51.902s-51.902-23.237-51.902-51.902 23.237-51.902 51.902-51.902 51.902 23.237 51.902 51.902zm65.862 81.954c-25.323-13.433-56.74-3.794-70.173 21.528-13.433 25.323-3.794 56.74 21.528 70.173m48.645-342.08c-28.665 0-51.902 23.237-51.902 51.902s23.237 51.902 51.902 51.902 51.902-23.237 51.902-51.902-23.237-51.902-51.902-51.902zm-182.102-33.763c-28.665 0-51.902 23.237-51.902 51.902s23.237 51.902 51.902 51.902 51.902-23.237 51.902-51.902-23.237-51.902-51.902-51.902z" fill="#f43b22"/>
<path d="m41.204 130.519c-13.328.001-26.256-7.039-33.184-19.518-10.167-18.308-3.566-41.391 14.743-51.557 34.605-19.215 72.08-34.048 111.383-44.088 39.334-10.047 80.138-15.214 121.279-15.356 40.982-.14 81.845 4.711 121.369 14.423 40.306 9.904 78.8 24.757 114.412 44.147 18.393 10.014 25.184 33.041 15.17 51.433-10.014 18.391-33.043 25.185-51.433 15.169-29.887-16.273-62.269-28.757-96.246-37.105-33.046-8.12-67.199-12.236-101.53-12.236-.496 0-.986.001-1.481.002-34.903.121-69.481 4.494-102.772 12.998-33.009 8.432-64.412 20.851-93.338 36.912-5.829 3.239-12.145 4.776-18.372 4.776z" fill="#c4790c"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

View file

@ -1,35 +0,0 @@
<?php declare(strict_types=1);
namespace App\Command;
use App\Service\FakeData;
use Override;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:fake-data',
description: 'add some fake data to database',
)]
final class FakeDataCommand extends Command
{
public function __construct(
private readonly FakeData $fakeData,
) {
parent::__construct();
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->fakeData->resetDb();
$this->fakeData->generate();
$io->success('Added some fake data to database');
return Command::SUCCESS;
}
}

View file

@ -4,6 +4,7 @@ namespace App\Controller;
use App\Entity\FoodOrder;
use App\Form\FoodOrderType;
use App\Form\OrderFinalize;
use App\Repository\FoodOrderRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -14,11 +15,59 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route('/food/order')]
final class FoodOrderController extends AbstractController
{
#[Route('/', name: 'app_food_order_index', methods: ['GET'])]
#[Route(
path: '/list/archive/{page}',
name: 'app_food_order_archive',
requirements: [
'page' => '\d+',
],
methods: ['GET']
)]
public function archive(FoodOrderRepository $foodOrderRepository, int $page = 1): Response
{
$nextPage = $page + 1;
$prevPage = $page - 1;
$itemsPerPage = 10;
$count = $foodOrderRepository->count();
if ($count < $page * $itemsPerPage) {
$nextPage = $page;
}
return $this->render('food_order/index.html.twig', [
'food_orders' => $foodOrderRepository->findLatestEntries(
page: $page,
pagesize: $itemsPerPage,
days: 0
),
'current_page' => $page,
'next_page' => $nextPage,
'prev_page' => $prevPage,
]);
}
#[Route('/{id}/close', name: 'app_food_order_close', methods: ['GET'])]
public function close(FoodOrder $foodOrder, FoodOrderRepository $repository): Response
{
$foodOrder->close();
$repository->save();
return $this->redirectToRoute('app_food_order_show', [
'id' => $foodOrder->getId(),
], Response::HTTP_SEE_OTHER);
}
#[Route(
path: '/list',
name: 'app_food_order_index',
methods: ['GET']
)]
public function index(FoodOrderRepository $foodOrderRepository): Response
{
return $this->render('food_order/index.html.twig', [
'food_orders' => $foodOrderRepository->findLatestEntries(),
'food_orders' => $foodOrderRepository->findLatestEntries(days: 3),
'current_page' => 0,
'next_page' => 0,
'prev_page' => 0,
]);
}
@ -26,6 +75,8 @@ final class FoodOrderController extends AbstractController
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$foodOrder = new FoodOrder;
$username = $request->cookies->get('username', 'nobody');
$foodOrder->setCreatedBy($username);
$form = $this->createForm(FoodOrderType::class, $foodOrder, [
'action' => $this->generateUrl('app_food_order_new'),
]);
@ -44,29 +95,31 @@ final class FoodOrderController extends AbstractController
]);
}
#[Route('/{id}', name: 'app_food_order_show', methods: ['GET'])]
public function show(FoodOrder $foodOrder): Response
{
return $this->render('food_order/show.html.twig', [
'food_order' => $foodOrder,
]);
}
#[Route('/{id}/close', name: 'app_food_order_close', methods: ['GET'])]
public function close(FoodOrder $foodOrder, FoodOrderRepository $repository): Response
{
$repository->save($foodOrder->close());
return $this->redirectToRoute('app_food_order_show', [
'id' => $foodOrder->getId(),
], Response::HTTP_SEE_OTHER);
}
#[Route('/{id}/open', name: 'app_food_order_open', methods: ['GET'])]
public function open(FoodOrder $foodOrder, FoodOrderRepository $repository): Response
{
$repository->save($foodOrder->open());
$foodOrder->open();
$repository->save();
return $this->redirectToRoute('app_food_order_show', [
'id' => $foodOrder->getId(),
], Response::HTTP_SEE_OTHER);
}
#[Route('/{id}', name: 'app_food_order_show', methods: ['GET', 'POST'])]
public function show(Request $request, FoodOrder $foodOrder, EntityManagerInterface $entityManager): Response
{
$form = null;
if ($foodOrder->isClosed()) {
$form = $this->createForm(OrderFinalize::class, $foodOrder);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($foodOrder);
$entityManager->flush();
}
}
return $this->render('food_order/show.html.twig', [
'food_order' => $foodOrder,
'form' => $form,
]);
}
}

View file

@ -14,6 +14,23 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route('/food/vendor')]
final class FoodVendorController extends AbstractController
{
#[Route('/{id}/edit', name: 'app_food_vendor_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, FoodVendor $foodVendor, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(FoodVendorType::class, $foodVendor);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
return $this->redirectToRoute('app_food_vendor_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('food_vendor/edit.html.twig', [
'form' => $form,
]);
}
#[Route('/', name: 'app_food_vendor_index', methods: ['GET'])]
public function index(FoodVendorRepository $foodVendorRepository): Response
{
@ -23,8 +40,10 @@ final class FoodVendorController extends AbstractController
}
#[Route('/new', name: 'app_food_vendor_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
public function new(
Request $request,
EntityManagerInterface $entityManager
): Response {
$foodVendor = new FoodVendor;
$form = $this->createForm(FoodVendorType::class, $foodVendor);
$form->handleRequest($request);
@ -49,22 +68,4 @@ final class FoodVendorController extends AbstractController
'food_vendor' => $foodVendor,
]);
}
#[Route('/{id}/edit', name: 'app_food_vendor_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, FoodVendor $foodVendor, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(FoodVendorType::class, $foodVendor);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
return $this->redirectToRoute('app_food_vendor_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('food_vendor/edit.html.twig', [
'food_vendor' => $foodVendor,
'form' => $form,
]);
}
}

View file

@ -2,16 +2,46 @@
namespace App\Controller;
use App\Form\UserNameFormType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class HomeController
final class HomeController extends AbstractController
{
public const string DEFAULT_USERNAME = 'nobody';
#[Route('/', name: 'home')]
public function home(UrlGeneratorInterface $router): Response
{
return new RedirectResponse($router->generate('app_food_order_index'));
}
#[Route('/username', name: 'username')]
public function usernameForm(Request $request, UrlGeneratorInterface $router): Response
{
$form = $this->createForm(UsernameFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$username = $form->getData()['username'] ?? self::DEFAULT_USERNAME;
$response = new RedirectResponse($router->generate('app_food_order_index'));
if ($username === self::DEFAULT_USERNAME || $username === '') {
$response->headers->clearCookie('username');
return $response;
}
$response->headers->setCookie(new Cookie('username', $username));
return $response;
}
$username = $request->cookies->get('username', self::DEFAULT_USERNAME);
$form->setData([
'username' => $username,
]);
return $this->render('username.html.twig', [
'form' => $form,
]);
}
}

View file

@ -0,0 +1,68 @@
<?php declare(strict_types=1);
namespace App\Controller;
use App\Entity\MenuItem;
use App\Form\MenuItemType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/menu/item')]
final class MenuItemController extends AbstractController
{
#[Route('/{id}', name: 'app_menu_item_delete', methods: ['POST'])]
public function delete(Request $request, MenuItem $menuItem, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete' . $menuItem->getId(), $request->getPayload()->getString('_token'))) {
$menuItem->delete();
$entityManager->flush();
}
return $this->redirectToRoute('app_food_vendor_show', [
'id' => $menuItem->getFoodVendor()
->getId(),
], Response::HTTP_SEE_OTHER);
}
#[Route('/{id}/edit', name: 'app_menu_item_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, MenuItem $menuItem, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(MenuItemType::class, $menuItem);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
foreach ($menuItem->getFoodVendor()->getMenuItems() as $vendorItem) {
if ($menuItem->getAliases()->contains($vendorItem)) {
$vendorItem->setAliasOf($menuItem);
} elseif ($vendorItem->getAliasOf() === $menuItem) {
$vendorItem->setAliasOf(null);
}
$entityManager->persist($vendorItem);
}
$entityManager->persist($menuItem);
$entityManager->flush();
return $this->redirectToRoute('app_menu_item_show', [
'id' => $menuItem->getId(),
], Response::HTTP_SEE_OTHER);
}
return $this->render('menu_item/edit.html.twig', [
'menu_item' => $menuItem,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_menu_item_show', methods: ['GET'])]
public function show(MenuItem $menuItem): Response
{
return $this->render('menu_item/show.html.twig', [
'menu_item' => $menuItem,
]);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Controller;
use App\Entity\FoodOrder;
use App\Entity\OrderItem;
use App\Form\OrderItemType;
use App\Repository\MenuItemRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@ -14,35 +15,6 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route('/order/item')]
final class OrderItemController extends AbstractController
{
#[Route('/new/{foodOrder}', name: 'app_order_item_new', methods: ['GET', 'POST'])]
public function new(Request $request, FoodOrder $foodOrder, EntityManagerInterface $entityManager): Response
{
if ($foodOrder->isClosed()) {
return $this->redirectToRoute('app_food_order_show', [
'id' => $foodOrder->getId(),
], Response::HTTP_SEE_OTHER);
}
$orderItem = new OrderItem;
$form = $this->createForm(OrderItemType::class, $orderItem);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$orderItem->setFoodOrder($foodOrder);
$entityManager->persist($orderItem);
$entityManager->flush();
return $this->redirectToRoute('app_food_order_show', [
'id' => $foodOrder->getId(),
], Response::HTTP_SEE_OTHER);
}
return $this->render('order_item/new.html.twig', [
'order_item' => $orderItem,
'food_order' => $foodOrder,
'form' => $form,
]);
}
#[Route('/{id}/copy', name: 'app_order_item_copy', methods: ['GET'])]
public function copy(OrderItem $orderItem, EntityManagerInterface $entityManager): Response
{
@ -56,6 +28,7 @@ final class OrderItemController extends AbstractController
$newOrderItem->setFoodOrder($orderItem->getFoodOrder());
$newOrderItem->setName($orderItem->getName());
$newOrderItem->setExtras($orderItem->getExtras());
$newOrderItem->setMenuItem($orderItem->getMenuItem());
$entityManager->persist($newOrderItem);
$entityManager->flush();
@ -65,33 +38,6 @@ final class OrderItemController extends AbstractController
], Response::HTTP_SEE_OTHER);
}
#[Route('/{id}/edit', name: 'app_order_item_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, OrderItem $orderItem, EntityManagerInterface $entityManager): Response
{
$foodOrder = $orderItem->getFoodOrder();
if ($foodOrder->isClosed()) {
return $this->redirectToRoute('app_food_order_show', [
'id' => $foodOrder->getId(),
], Response::HTTP_SEE_OTHER);
}
$form = $this->createForm(OrderItemType::class, $orderItem);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
return $this->redirectToRoute('app_food_order_show', [
'id' => $orderItem->getFoodOrder()
->getId(),
], Response::HTTP_SEE_OTHER);
}
return $this->render('order_item/edit.html.twig', [
'order_item' => $orderItem,
'form' => $form,
]);
}
#[Route('/delete/{id}', name: 'app_order_item_delete')]
public function delete(OrderItem $orderItem, EntityManagerInterface $entityManager): Response
{
@ -108,4 +54,77 @@ final class OrderItemController extends AbstractController
->getId(),
], Response::HTTP_SEE_OTHER);
}
#[Route('/{id}/edit', name: 'app_order_item_edit', methods: ['GET', 'POST'])]
public function edit(
Request $request,
OrderItem $orderItem,
EntityManagerInterface $entityManager,
MenuItemRepository $menuItemRepository,
): Response {
$foodOrder = $orderItem->getFoodOrder();
if ($foodOrder->isClosed()) {
return $this->redirectToRoute('app_food_order_show', [
'id' => $foodOrder->getId(),
], Response::HTTP_SEE_OTHER);
}
$orderItem->setName($orderItem->getMenuItem()->getName());
$form = $this->createForm(OrderItemType::class, $orderItem);
$form->setData($orderItem);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$orderItem->setFoodOrder($foodOrder);
$entityManager->persist($orderItem);
$entityManager->flush();
return $this->redirectToRoute('app_food_order_show', [
'id' => $orderItem->getFoodOrder()
->getId(),
], Response::HTTP_SEE_OTHER);
}
return $this->render('order_item/edit.html.twig', [
'order_item' => $orderItem,
'form' => $form,
]);
}
#[Route('/new/{foodOrder}', name: 'app_order_item_new', methods: ['GET', 'POST'])]
public function new(Request $request, FoodOrder $foodOrder, EntityManagerInterface $entityManager, MenuItemRepository $menuItemRepository): Response
{
if ($foodOrder->isClosed()) {
return $this->redirectToRoute('app_food_order_show', [
'id' => $foodOrder->getId(),
], Response::HTTP_SEE_OTHER);
}
$orderItem = new OrderItem;
$username = $request->cookies->get('username', 'nobody');
$orderItem->setCreatedBy($username);
$form = $this->createForm(OrderItemType::class, $orderItem);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$orderItem->setFoodOrder($foodOrder);
$entityManager->persist($orderItem);
$entityManager->flush();
return $this->redirectToRoute('app_food_order_show', [
'id' => $foodOrder->getId(),
], Response::HTTP_SEE_OTHER);
}
$menuItems = $menuItemRepository->findBy([
'foodVendor' => $foodOrder->getFoodVendor(),
'deletedAt' => null,
]);
return $this->render('order_item/new.html.twig', [
'order_item' => $orderItem,
'food_order' => $foodOrder,
'form' => $form,
'menuItems' => $menuItems,
]);
}
}

View file

@ -0,0 +1,66 @@
<?php declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\FoodOrder;
use App\Entity\FoodVendor;
use App\Entity\MenuItem;
use App\Entity\OrderItem;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Override;
use function range;
final class AppFixtures extends Fixture
{
private ObjectManager $manager;
public function addMenuItemsToVendor(FoodVendor $vendor): void
{
$menuItems = [];
foreach (range(1, 10) as $i) {
$item = new MenuItem;
$item->setName("{$vendor->getName()} Item {$i}");
$item->setFoodVendor($vendor);
$this->manager->persist($item);
$this->manager->flush();
$menuItems[] = $item;
}
$order = new FoodOrder;
$order->setFoodVendor($vendor);
$this->manager->persist($order);
foreach ($menuItems as $item) {
$orderItem = new OrderItem;
$orderItem->setMenuItem($item);
$orderItem->setCreatedBy('John');
$order->addOrderItem($orderItem);
$this->manager->persist($orderItem);
}
}
public function createVendor(string $name): FoodVendor
{
$vendorA = new FoodVendor;
$vendorA->setName($name);
$vendorA->setMenuLink('https://vendora.com');
$vendorA->setPhone('1234567890');
$this->manager->persist($vendorA);
$this->manager->flush();
return $vendorA;
}
#[Override]
public function load(ObjectManager $manager): void
{
$this->manager = $manager;
$vendorA = $this->createVendor('Vendor A');
$this->addMenuItemsToVendor($vendorA);
$vendorB = $this->createVendor('Vendor B');
$this->addMenuItemsToVendor($vendorB);
}
}

View file

@ -2,97 +2,82 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\FoodOrderRepository;
use App\State\LatestOrderProvider;
use App\State\OpenOrdersProvider;
use DateInterval;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Uid\Ulid;
use function iterator_to_array;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: 'food_orders/open',
description: 'Get only open orders',
provider: OpenOrdersProvider::class,
),
new Get(
uriTemplate: 'food_orders/latest',
description: 'Get the latest created order',
provider: LatestOrderProvider::class,
normalizationContext: [
'groups' => ['food_order:read', 'food_order:latest'],
]
),
new GetCollection,
new Get,
new Post,
new Put,
new Delete,
]
)]
#[ORM\Entity(repositoryClass: FoodOrderRepository::class)]
class FoodOrder
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: UlidGenerator::class)]
private Ulid|null $id = null;
#[Groups(['food_order:read'])]
#[ORM\Column(nullable: true)]
private DateTimeImmutable|null $closedAt = null;
#[ORM\ManyToOne(inversedBy: 'foodOrders')]
#[Groups(['food_order:read'])]
#[ORM\Column(length: 255, options: [
'default' => 'nobody',
])]
private string|null $createdBy = 'nobody';
#[Groups(['food_order:read', 'food_order:latest'])]
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'foodOrders')]
private FoodVendor|null $foodVendor = null;
/**
* @var Collection<int, OrderItem>
*/
#[Groups(['food_order:read', 'food_order:latest'])]
#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'foodOrder', orphanRemoval: true)]
private Collection $orderItems;
public function __construct()
{
public function __construct(
#[Groups(['food_order:read'])]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\Id]
private Ulid|null $id = new Ulid
) {
$this->id ??= new Ulid;
$this->orderItems = new ArrayCollection;
}
public function getId(): Ulid|null
{
return $this->id;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->id->getDateTime();
}
public function getClosedAt(): DateTimeImmutable|null
{
return $this->closedAt;
}
public function setClosedAt(DateTimeImmutable|null $closedAt): static
{
$this->closedAt = $closedAt;
return $this;
}
public function isClosed(): bool
{
return $this->closedAt instanceof DateTimeImmutable;
}
public function close(): static
{
return $this->setClosedAt(new DateTimeImmutable);
}
public function open(): static
{
return $this->setClosedAt(null);
}
public function getFoodVendor(): FoodVendor|null
{
return $this->foodVendor;
}
public function setFoodVendor(FoodVendor|null $foodVendor): static
{
$this->foodVendor = $foodVendor;
return $this;
}
/**
* @return Collection<int, OrderItem>
*/
public function getOrderItems(): Collection
{
return $this->orderItems;
$this->open();
}
public function addOrderItem(OrderItem $orderItem): static
@ -105,13 +90,104 @@ class FoodOrder
return $this;
}
public function close(): static
{
return $this->setClosedAt(new DateTimeImmutable);
}
public function getClosedAt(): DateTimeImmutable|null
{
return $this->closedAt;
}
#[Groups(['food_order:read'])]
public function getCreatedAt(): DateTimeImmutable
{
return $this->id->getDateTime();
}
public function getCreatedBy(): string|null
{
return $this->createdBy;
}
public function getFoodVendor(): FoodVendor|null
{
return $this->foodVendor;
}
public function getId(): Ulid|null
{
return $this->id;
}
/**
* @return Collection<int, OrderItem>
*/
public function getOrderItems(): Collection
{
return $this->orderItems;
}
/**
* @return Collection<int, OrderItem>
*/
public function getOrderItemsSortedByName(): Collection
{
$iterator = $this->getOrderItems()
->getIterator();
$iterator->uasort(
static fn(OrderItem $a, OrderItem $b): int => $a->getName() <=> $b->getName()
);
return new ArrayCollection(
iterator_to_array(
$iterator
)
);
}
public function isClosed(): bool
{
if (! $this->closedAt instanceof DateTimeImmutable) {
return false;
}
return $this->closedAt < new DateTimeImmutable;
}
public function open(): static
{
$this->closedAt = (new DateTimeImmutable)->add(new DateInterval('PT1H'));
return $this;
}
public function removeOrderItem(OrderItem $orderItem): static
{
// set the owning side to null (unless already changed)
if ($this->orderItems->removeElement($orderItem) && $orderItem->getFoodOrder() === $this) {
if ($this->orderItems->removeElement($orderItem)) {
$orderItem->setFoodOrder(null);
}
return $this;
}
public function setClosedAt(DateTimeImmutable|null $closedAt = null): static
{
$this->closedAt = $closedAt;
return $this;
}
public function setCreatedBy(string $createdBy): static
{
$this->createdBy = $createdBy;
return $this;
}
public function setFoodVendor(FoodVendor|null $foodVendor): static
{
$this->foodVendor = $foodVendor;
return $this;
}
}

View file

@ -2,25 +2,31 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\FoodVendorRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Validator\Constraints\Length;
use function mb_strlen;
#[ApiResource]
#[ORM\Entity(repositoryClass: FoodVendorRepository::class)]
class FoodVendor
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: UlidGenerator::class)]
private Ulid|null $id = null;
#[ORM\Column(length: 50)]
private string|null $name = null;
/**
* String of emojis (max 30 characters)
*/
#[Groups(['food_order:latest', 'food_vendor:read'])]
#[Length(max: 10)]
#[ORM\Column(length: 30, nullable: true)]
private string|null $emojis = null;
/**
* @var Collection<int, FoodOrder>
@ -28,34 +34,37 @@ class FoodVendor
#[ORM\OneToMany(targetEntity: FoodOrder::class, mappedBy: 'foodVendor', orphanRemoval: true)]
private Collection $foodOrders;
public function __construct()
{
$this->foodOrders = new ArrayCollection;
}
public function getId(): Ulid|null
{
return $this->id;
}
public function getName(): string|null
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
/**
* @return Collection<int, FoodOrder>
* @var Collection<int, MenuItem>
*/
public function getFoodOrders(): Collection
{
return $this->foodOrders;
#[ORM\OneToMany(targetEntity: MenuItem::class, mappedBy: 'foodVendor', orphanRemoval: true)]
private Collection $menuItems;
#[Groups(['food_order:latest'])]
#[ORM\Column(length: 255, nullable: true)]
private string|null $menuLink = null;
#[Groups(['food_order:latest', 'food_vendor:read'])]
#[ORM\Column(length: 50)]
private string|null $name = null;
#[Groups(['food_order:latest', 'food_vendor:read'])]
#[ORM\Column(length: 50, nullable: true, options: [
'default' => '',
])]
private string|null $phone = null;
public function __construct(
#[Groups(['food_order:latest'])]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: UlidGenerator::class)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Id]
private Ulid|null $id = new Ulid
) {
$this->id ??= new Ulid;
$this->foodOrders = new ArrayCollection;
$this->menuItems = new ArrayCollection;
}
public function addFoodOrder(FoodOrder $foodOrder): static
@ -68,13 +77,109 @@ class FoodVendor
return $this;
}
public function addMenuItem(MenuItem $menuItem): static
{
if (! $this->menuItems->contains($menuItem)) {
$this->menuItems->add($menuItem);
$menuItem->setFoodVendor($this);
}
return $this;
}
public function getEmojis(): string|null
{
return $this->emojis;
}
/**
* @return Collection<int, FoodOrder>
*/
public function getFoodOrders(): Collection
{
return $this->foodOrders;
}
public function getId(): Ulid|null
{
return $this->id;
}
/**
* @return Collection<int, MenuItem>
*/
public function getMenuItems(bool $withDeleted = false): Collection
{
if ($withDeleted) {
return $this->menuItems;
}
return $this->menuItems->filter(
static fn(MenuItem $item): bool => $item->isDeleted() === false
);
}
public function getMenuLink(): string|null
{
return $this->menuLink;
}
public function getName(): string|null
{
return $this->name;
}
public function getPhone(): string|null
{
return $this->phone;
}
public function removeFoodOrder(FoodOrder $foodOrder): static
{
// set the owning side to null (unless already changed)
if ($this->foodOrders->removeElement($foodOrder) && $foodOrder->getFoodVendor() === $this) {
if ($this->foodOrders->removeElement($foodOrder)) {
$foodOrder->setFoodVendor(null);
}
return $this;
}
public function removeMenuItem(MenuItem $menuItem): static
{
// set the owning side to null (unless already changed)
if ($this->menuItems->removeElement($menuItem)) {
$menuItem->setFoodVendor(null);
}
return $this;
}
public function setEmojis(string|null $emojis): static
{
if ($emojis !== null && mb_strlen($emojis) > 30) {
throw new InvalidArgumentException('A maximum of 30 characters is allowed for emojis');
}
$this->emojis = $emojis;
return $this;
}
public function setMenuLink(string|null $menuLink): static
{
$this->menuLink = $menuLink;
return $this;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function setPhone(string|null $phone): static
{
$this->phone = $phone;
return $this;
}
}

158
src/Entity/MenuItem.php Normal file
View file

@ -0,0 +1,158 @@
<?php declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\MenuItemRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Validator\Constraints\Positive;
#[ApiResource]
#[ORM\Entity(repositoryClass: MenuItemRepository::class)]
class MenuItem
{
/**
* @var Collection<int, self>
*/
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'aliasOf')]
private Collection $aliases;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'aliases')]
private self|null $aliasOf = null;
#[ORM\Column(nullable: true)]
private DateTimeImmutable|null $deletedAt = null;
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'menuItems')]
private FoodVendor|null $foodVendor = null;
#[ORM\Column(length: 255)]
private string|null $name = null;
#[ORM\Column(type: 'integer', options: [
'default' => 0,
])]
#[Positive]
private int $priceCents = 0;
public function __construct(
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: UlidGenerator::class)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Id]
private Ulid|null $id = new Ulid
) {
$this->id ??= new Ulid;
$this->aliases = new ArrayCollection;
}
public function addAlias(self $alias): static
{
if (! $this->aliases->contains($alias)) {
$this->aliases->add($alias);
$alias->setAliasOf($this);
}
return $this;
}
public function delete(): static
{
$this->setDeletedAt(new DateTimeImmutable);
return $this;
}
/**
* @return Collection<int, self>
*/
public function getAliases(): Collection
{
return $this->aliases;
}
public function getAliasOf(): self|null
{
return $this->aliasOf;
}
public function getDeletedAt(): DateTimeImmutable|null
{
return $this->deletedAt;
}
public function getFoodVendor(): FoodVendor|null
{
return $this->foodVendor;
}
public function getId(): Ulid|null
{
return $this->id;
}
public function getName(): string|null
{
return $this->name;
}
public function getPriceCents(): int
{
return $this->priceCents;
}
public function isDeleted(): bool
{
return $this->getDeletedAt() instanceof DateTimeImmutable;
}
public function removeAlias(self $alias): static
{
// set the owning side to null (unless already changed)
if ($this->aliases->removeElement($alias) && $alias->getAliasOf() === $this) {
$alias->setAliasOf(null);
}
return $this;
}
public function setAliasOf(self|null $aliasOf): static
{
$this->aliasOf = $aliasOf;
return $this;
}
public function setDeletedAt(DateTimeImmutable|null $deletedAt = new DateTimeImmutable): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function setFoodVendor(FoodVendor|null $foodVendor): static
{
$this->foodVendor = $foodVendor;
return $this;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function setPriceCents(int $priceCents): self
{
$this->priceCents = $priceCents;
return $this;
}
}

View file

@ -2,51 +2,110 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\OrderItemRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Uid\Ulid;
#[ApiResource]
#[ORM\Entity(repositoryClass: OrderItemRepository::class)]
class OrderItem
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: UlidGenerator::class)]
private Ulid|null $id = null;
#[ORM\Column(length: 255)]
private string|null $name = null;
#[Groups(['food_order:latest'])]
#[ORM\Column(length: 255, options: [
'default' => 'nobody',
])]
private string|null $createdBy = 'nobody';
#[Groups(['food_order:latest'])]
#[ORM\Column(length: 255, nullable: true)]
private string|null $extras = null;
#[ORM\JoinColumn(nullable: true)]
#[ORM\ManyToOne(inversedBy: 'orderItems')]
#[ORM\JoinColumn(nullable: false)]
private FoodOrder|null $foodOrder = null;
#[Groups('food_order:latest')]
#[ORM\Column(type: 'boolean', options: [
'default' => false,
])]
private bool $isPaid = false;
#[Groups(['food_order:latest'])]
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne]
private MenuItem|null $menuItem = null;
#[Groups(['food_order:latest'])]
#[ORM\Column(length: 255)]
private string|null $name = null;
#[Groups('food_order:latest')]
#[ORM\Column(type: 'integer', options: [
'default' => 0,
])]
#[Positive]
private int $priceCents = 0;
public function __construct(
#[Groups(['food_order:latest'])]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: UlidGenerator::class)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Id]
private Ulid|null $id = new Ulid
) {
$this->id ??= new Ulid;
}
public function getCreatedBy(): string|null
{
return $this->createdBy;
}
public function getExtras(): string|null
{
return $this->extras;
}
public function getFoodOrder(): FoodOrder|null
{
return $this->foodOrder;
}
public function getId(): Ulid|null
{
return $this->id;
}
public function getMenuItem(): MenuItem|null
{
return $this->menuItem;
}
public function getName(): string|null
{
return $this->name;
}
public function setName(string $name): static
public function getPriceCents(): int
{
$this->name = $name;
return $this;
return $this->priceCents;
}
public function getExtras(): string|null
public function isPaid(): bool
{
return $this->extras;
return $this->isPaid;
}
public function setCreatedBy(string $createdBy): static
{
$this->createdBy = $createdBy;
return $this;
}
public function setExtras(string|null $extras): static
@ -56,15 +115,37 @@ class OrderItem
return $this;
}
public function getFoodOrder(): FoodOrder|null
{
return $this->foodOrder;
}
public function setFoodOrder(FoodOrder|null $foodOrder): static
{
$this->foodOrder = $foodOrder;
return $this;
}
public function setIsPaid(bool $isPaid): self
{
$this->isPaid = $isPaid;
return $this;
}
public function setMenuItem(MenuItem|null $menuItem): static
{
$this->menuItem = $menuItem;
$this->name = $menuItem->getName();
return $this;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function setPriceCents(int $priceCents): self
{
$this->priceCents = $priceCents;
return $this;
}
}

View file

@ -0,0 +1,52 @@
<?php declare(strict_types=1);
namespace App\EventListener;
use App\Entity\MenuItem;
use App\Entity\OrderItem;
use App\Repository\MenuItemRepository;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PreFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\Persistence\ObjectManager;
#[AsDoctrineListener(event: Events::preFlush)]
final readonly class OrderItemPreFlush
{
public function __construct(
private MenuItemRepository $menuItemRepository,
) {}
public function preFlush(PreFlushEventArgs $eventArgs): void
{
foreach (($eventArgs->getObjectManager()->getUnitOfWork()->getIdentityMap()[OrderItem::class] ?? []) as $orderItem) {
$this->checkOrderItem($orderItem, $eventArgs->getObjectManager());
}
}
private function checkOrderItem(OrderItem $orderItem, ObjectManager $objectManager): void
{
$menuItem = $this->menuItemRepository->findOneBy([
'name' => $orderItem->getName(),
'foodVendor' => $orderItem->getFoodOrder()
->getFoodVendor(),
]);
if ($menuItem === null) {
$menuItem = new MenuItem;
$menuItem->setName($orderItem->getName());
$menuItem->setFoodVendor($orderItem->getFoodOrder()->getFoodVendor());
$objectManager->persist($menuItem);
}
if ($menuItem->getAliasOf() !== null) {
$menuItem = $menuItem->getAliasOf();
$orderItem->setName($menuItem->getName());
}
$orderItem->setMenuItem($menuItem);
if ($orderItem->getPriceCents() === 0) {
$orderItem->setPriceCents($menuItem->getPriceCents());
} elseif ($orderItem->getPriceCents() !== $menuItem->getPriceCents()) {
$menuItem->setPriceCents($orderItem->getPriceCents());
$objectManager->persist($menuItem);
}
}
}

View file

@ -2,13 +2,11 @@
namespace App\Form;
use App\Entity\FoodOrder;
use App\Entity\FoodVendor;
use Override;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class FoodOrderType extends AbstractType
{
@ -21,17 +19,14 @@ final class FoodOrderType extends AbstractType
'class' => FoodVendor::class,
'choice_label' => 'name',
])
->add(child: 'closedAt', options: [
'label' => 'closes at',
'view_timezone' => 'Europe/Berlin',
])
->add(child: 'createdBy')
;
if ($action !== null) {
$builder->setAction($action);
}
}
#[Override]
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => FoodOrder::class,
]);
}
}

View file

@ -15,6 +15,9 @@ final class FoodVendorType extends AbstractType
{
$builder
->add('name')
->add('menuLink')
->add('phone')
->add('emojis')
;
}

54
src/Form/MenuItemType.php Normal file
View file

@ -0,0 +1,54 @@
<?php declare(strict_types=1);
namespace App\Form;
use App\Entity\MenuItem;
use App\Repository\MenuItemRepository;
use Doctrine\ORM\QueryBuilder;
use Override;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class MenuItemType extends AbstractType
{
#[Override]
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$item = $options['data'];
$builder->add('name', TextType::class, [
'constraints' => [
new NotBlank,
new Length([
'min' => 3,
]),
],
]);
$builder->add('priceCents', MoneyType::class, [
'label' => 'Price',
'divisor' => 100,
]);
$builder->add('aliases', EntityType::class, [
'class' => MenuItem::class,
'choice_label' => 'name',
'multiple' => true,
'expanded' => true,
'query_builder' => static fn(MenuItemRepository $repository): QueryBuilder
=> $repository->getSuitableAliasQueryBuilder($item),
]);
}
#[Override]
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => MenuItem::class,
]);
}
}

View file

@ -0,0 +1,32 @@
<?php declare(strict_types=1);
namespace App\Form;
use App\Entity\FoodOrder;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class OrderFinalize extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('orderItems', CollectionType::class, [
'entry_type' => OrderItemFinalize::class,
'entry_options' => [
'label' => false,
],
])
->add('save', SubmitType::class)
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => FoodOrder::class,
]);
}
}

View file

@ -0,0 +1,44 @@
<?php declare(strict_types=1);
namespace App\Form;
use App\Entity\OrderItem;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class OrderItemFinalize extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add(child: 'name', options: [
'label' => 'order item',
'disabled' => true,
])
->add(child: 'extras', options: [
'disabled' => true,
])
->add(child: 'createdBy', options: [
'disabled' => true,
])
->add(child: 'priceCents', type: MoneyType::class, options: [
'label' => 'price',
'divisor' => 100,
])
->add(child: 'isPaid', type: CheckboxType::class, options: [
'required' => false,
'label' => 'paid?',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => OrderItem::class,
]);
}
}

View file

@ -14,8 +14,13 @@ final class OrderItemType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name')
->add('extras')
->add(child: 'name', options: [
'label' => 'order item',
])
->add(child: 'extras')
->add(child: 'createdBy', options: [
'label' => 'your name',
])
;
}

View file

@ -0,0 +1,18 @@
<?php declare(strict_types=1);
namespace App\Form;
use Override;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
final class UserNameFormType extends AbstractType
{
#[Override]
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add(child: 'username')
;
}
}

View file

@ -8,4 +8,15 @@ use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
public function __construct(
protected string $environment,
protected bool $debug,
) {
parent::__construct($environment, $debug);
if ($environment === 'test') {
$this->debug = false;
}
}
}

View file

@ -3,7 +3,10 @@
namespace App\Repository;
use App\Entity\FoodOrder;
use DateInterval;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Persistence\ManagerRegistry;
/**
@ -16,26 +19,56 @@ final class FoodOrderRepository extends ServiceEntityRepository
parent::__construct($registry, FoodOrder::class);
}
public function save(FoodOrder $order): void
/**
* @return FoodOrder[]
*/
public function findLatestEntries(int $page = 1, int $pagesize = 10, int $days = 4): array
{
$this->getEntityManager()
->persist($order);
$this->getEntityManager()
->flush();
$result = $this->createQueryBuilder('alias')
->orderBy('alias.id', 'DESC')
->setFirstResult(($page - 1) * $pagesize)
->setMaxResults($pagesize)
->getQuery()
->getResult();
if ($days < 1) {
return $result;
}
$date = (new DateTimeImmutable)->sub(new DateInterval('P' . $days . 'D'));
return (new ArrayCollection($result))
->filter(static fn(FoodOrder $order): bool => $order->getCreatedAt() >= $date)
->getValues();
}
public function findLatestOrder(): FoodOrder|null
{
return $this->createQueryBuilder('alias')
->orderBy('alias.id', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/**
* @return FoodOrder[]
*/
public function findLatestEntries(int $limit = 10): array
public function findOpenOrders(): array
{
$qb = $this->createQueryBuilder('alias');
$now = new DateTimeImmutable;
$qb->orderBy('alias.id', 'DESC');
$qb->setMaxResults($limit);
return $this->createQueryBuilder('o')
->where('o.closedAt IS NULL OR o.closedAt > :now')
->setParameter('now', $now)
->orderBy('o.id', 'DESC')
->getQuery()
->getResult();
}
$query = $qb->getQuery();
return $query->getResult();
public function save(): void
{
$this->getEntityManager()
->flush();
}
}

View file

@ -0,0 +1,49 @@
<?php declare(strict_types=1);
namespace App\Repository;
use App\Entity\MenuItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Uid\Ulid;
use function array_map;
/**
* @extends ServiceEntityRepository<MenuItem>
*/
final class MenuItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MenuItem::class);
}
public function getSuitableAliasQueryBuilder(MenuItem $menuItem): QueryBuilder
{
$ids = $this->createQueryBuilder('m')
->select('DISTINCT IDENTITY(m.aliasOf)')
->where('m.deletedAt IS NULL')
->andWhere('m.aliasOf IS NOT NULL')
->getquery();
$ids = $ids->getScalarResult();
$ids = array_map(static fn(array $id): Ulid => Ulid::fromBinary($id[1]), $ids);
$qb = $this->createQueryBuilder('m');
$qb
->where('m.foodVendor = :vendorId')
->andWhere('m.deletedAt IS NULL')
->andWhere('m.id != :id');
foreach ($ids as $key => $id) {
$qb->andWhere("m.id != :idBy{$key}")
->setParameter("idBy{$key}", $id, UlidType::NAME);
}
$qb
->orderBy('m.name', 'ASC')
->setParameter('vendorId', $menuItem->getFoodVendor()->getId(), UlidType::NAME)
->setParameter('id', $menuItem->getId(), UlidType::NAME);
return $qb;
}
}

View file

@ -1,100 +0,0 @@
<?php declare(strict_types=1);
namespace App\Service;
use App\Entity\FoodOrder;
use App\Entity\FoodVendor;
use App\Entity\OrderItem;
use App\Repository\FoodOrderRepository;
use App\Repository\FoodVendorRepository;
use App\Repository\OrderItemRepository;
use Doctrine\ORM\EntityManagerInterface;
use function range;
final readonly class FakeData
{
public function __construct(
private EntityManagerInterface $entityManager,
private FoodVendorRepository $foodVendorRepository,
private FoodOrderRepository $foodOrderRepository,
private OrderItemRepository $orderItemRepository,
) {}
public function resetDb(): void
{
foreach ($this->orderItemRepository->findAll() as $item) {
$this->entityManager->remove($item);
}
foreach ($this->foodOrderRepository->findAll() as $item) {
$this->entityManager->remove($item);
}
foreach ($this->foodVendorRepository->findAll() as $item) {
$this->entityManager->remove($item);
}
}
public function generate(int $vendorAmount = 3, int $orderAmount = 4, int $itemAmount = 10): void
{
$vendors = $this->generateVendors($vendorAmount);
foreach ($vendors as $vendor) {
$orders = $this->generateOrdersForVendor($vendor, $orderAmount);
foreach ($orders as $order) {
$this->generateItemsForOrder($order, $itemAmount);
}
}
$this->entityManager->flush();
}
/**
* @return FoodVendor[]
*/
public function generateVendors(int $amount = 10): array
{
$vendors = [];
foreach (range(1, $amount) as $i) {
$vendor = new FoodVendor;
$vendor->setName('Food Vendor ' . $i);
$this->entityManager->persist($vendor);
$vendors[] = $vendor;
}
return $vendors;
}
/**
* @return FoodOrder[]
*/
public function generateOrdersForVendor(FoodVendor $vendor, int $amount = 10): array
{
$orders = [];
foreach (range(1, $amount) as $i) {
$order = new FoodOrder;
$order->setFoodVendor($vendor);
if ($i % 2 === 0) {
$order->close();
}
$this->entityManager->persist($order);
$orders[] = $order;
}
return $orders;
}
/**
* @return OrderItem[]
*/
public function generateItemsForOrder(FoodOrder $order, int $amount = 10): array
{
$items = [];
foreach (range(1, $amount) as $i) {
$item = new OrderItem;
$item->setName('Item ' . $i);
$item->setFoodOrder($order);
if ($i % 2 === 0) {
$item->setExtras('Extra ' . $i);
}
$this->entityManager->persist($item);
$items[] = $item;
}
return $items;
}
}

18
src/Service/Favicon.php Normal file
View file

@ -0,0 +1,18 @@
<?php declare(strict_types=1);
namespace App\Service;
use Override;
use Stringable;
use function random_int;
final class Favicon implements Stringable
{
#[Override]
public function __toString(): string
{
$rotate = random_int(0, 380);
return "data:image/svg+xml, %3Csvg enable-background='new 0 0 512 512' viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg' %3E%3Cg transform='rotate({$rotate}, 256, 256)' %3E%3Cpath d='m51.6 72.608 199.566-34.825 213.114 34.825c19.867 0 32.553 21.19 23.171 38.701l-206.224 384.92c-9.908 18.492-36.421 18.499-46.337.011l-206.455-384.92c-9.393-17.512 3.293-38.712 23.165-38.712z' fill='%23fecb21'/%3E%3Cpath d='m270.254 307.902c0 28.665-23.238 51.902-51.902 51.902s-51.902-23.237-51.902-51.902 23.237-51.902 51.902-51.902 51.902 23.237 51.902 51.902zm65.862 81.954c-25.323-13.433-56.74-3.794-70.173 21.528-13.433 25.323-3.794 56.74 21.528 70.173m48.645-342.08c-28.665 0-51.902 23.237-51.902 51.902s23.237 51.902 51.902 51.902 51.902-23.237 51.902-51.902-23.237-51.902-51.902-51.902zm-182.102-33.763c-28.665 0-51.902 23.237-51.902 51.902s23.237 51.902 51.902 51.902 51.902-23.237 51.902-51.902-23.237-51.902-51.902-51.902z' fill='%23f43b22'/%3E%3Cpath d='m41.204 130.519c-13.328.001-26.256-7.039-33.184-19.518-10.167-18.308-3.566-41.391 14.743-51.557 34.605-19.215 72.08-34.048 111.383-44.088 39.334-10.047 80.138-15.214 121.279-15.356 40.982-.14 81.845 4.711 121.369 14.423 40.306 9.904 78.8 24.757 114.412 44.147 18.393 10.014 25.184 33.041 15.17 51.433-10.014 18.391-33.043 25.185-51.433 15.169-29.887-16.273-62.269-28.757-96.246-37.105-33.046-8.12-67.199-12.236-101.53-12.236-.496 0-.986.001-1.481.002-34.903.121-69.481 4.494-102.772 12.998-33.009 8.432-64.412 20.851-93.338 36.912-5.829 3.239-12.145 4.776-18.372 4.776z' fill='%23c4790c'/%3E%3C/g%3E%3C/svg%3E%0A";
}
}

View file

@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\FoodOrder;
use App\Repository\FoodOrderRepository;
use Override;
final readonly class LatestOrderProvider implements ProviderInterface
{
public function __construct(
private FoodOrderRepository $repository
) {}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): FoodOrder|null
{
return $this->repository->findLatestOrder();
}
}

View file

@ -0,0 +1,25 @@
<?php declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\FoodOrder;
use App\Repository\FoodOrderRepository;
use Override;
final readonly class OpenOrdersProvider implements ProviderInterface
{
public function __construct(
private FoodOrderRepository $repository
) {}
/**
* @return FoodOrder[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
return $this->repository->findOpenOrders();
}
}

View file

@ -1,11 +1,34 @@
{
"doctrine/doctrine-bundle": {
"version": "2.12",
"api-platform/symfony": {
"version": "4.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.12",
"ref": "7266981c201efbbe02ae53c87f8bb378e3f825ae"
"version": "4.0",
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
},
"files": [
"config/packages/api_platform.yaml",
"config/routes/api_platform.yaml",
"src/ApiResource/.gitignore"
]
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.14",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
},
"files": [
"config/packages/doctrine.yaml",
@ -13,8 +36,20 @@
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-fixtures-bundle": {
"version": "4.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
},
"files": [
"src/DataFixtures/AppFixtures.php"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.3",
"version": "3.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -26,40 +61,77 @@
"migrations/.gitignore"
]
},
"liip/test-fixtures-bundle": {
"version": "3.4.0"
},
"nelmio/cors-bundle": {
"version": "2.5",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"config/packages/nelmio_cors.yaml"
]
},
"phpstan/phpstan": {
"version": "1.11",
"version": "1.12",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
}
},
"files": [
"phpstan.dist.neon"
]
},
"phpunit/phpunit": {
"version": "9.6",
"version": "11.5",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "9.6",
"ref": "7364a21d87e658eb363c5020c072ecfdc12e2326"
"version": "11.1",
"ref": "c6658a60fc9d594805370eacdf542c3d6b5c0869"
},
"files": [
".env.test",
"phpunit.xml.dist",
"tests/bootstrap.php"
"tests/bootstrap.php",
"bin/phpunit"
]
},
"squizlabs/php_codesniffer": {
"version": "3.10",
"version": "3.13",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "3.6",
"ref": "1019e5c08d4821cb9b77f4891f8e9c31ff20ac6f"
}
},
"files": [
"phpcs.xml.dist"
]
},
"symfony/asset-mapper": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a"
},
"files": [
"assets/app.js",
"assets/styles/app.css",
"config/packages/asset_mapper.yaml",
"importmap.php"
]
},
"symfony/console": {
"version": "7.1",
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -71,24 +143,37 @@
]
},
"symfony/flex": {
"version": "2.4",
"version": "2.7",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env"
".env",
".env.dev"
]
},
"symfony/form": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": {
"version": "7.1",
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "6356c19b9ae08e7763e4ba2d9ae63043efc75db5"
"version": "7.3",
"ref": "5a1497d539f691b96afd45ae397ce5fe30beb4b9"
},
"files": [
"config/packages/cache.yaml",
@ -98,11 +183,12 @@
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php"
"src/Kernel.php",
".editorconfig"
]
},
"symfony/maker-bundle": {
"version": "1.60",
"version": "1.63",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -110,23 +196,32 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/phpunit-bridge": {
"version": "7.1",
"symfony/monolog-bundle": {
"version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "a411a0480041243d97382cac7984f7dce7813c08"
"version": "3.7",
"ref": "aff23899c4440dd995907613c1dd709b6f59503f"
},
"files": [
".env.test",
"bin/phpunit",
"phpunit.xml.dist",
"tests/bootstrap.php"
"config/packages/monolog.yaml"
]
},
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.1",
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -138,8 +233,21 @@
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "2ae08430db28c8eb4476605894296c82a642028f"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.1",
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -152,7 +260,7 @@
]
},
"symfony/uid": {
"version": "7.1",
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -161,7 +269,7 @@
}
},
"symfony/validator": {
"version": "7.1",
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -171,5 +279,21 @@
"files": [
"config/packages/validator.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "5b2b543e13942495c0003f67780cb4448af9e606"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
},
"twig/extra-bundle": {
"version": "v3.21.0"
}
}

View file

@ -1,4 +1,4 @@
{{ form_start(form) }}
{{ form_widget(form) }}
<button class="btn">{{ button_label|default('Save') }}</button>
{{ form_start(form, {'attr': {'class': 'mb-3'}}) }}
{{ form_widget(form, {'attr': {'class': 'form-control'}}) }}
<button class="btn btn-primary mt-2">{{ button_label|default('Save') }}</button>
{{ form_end(form) }}

View file

@ -2,25 +2,64 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="color-scheme" content="dark light">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
<link rel="stylesheet" href="/static/css/simple.min.css">
<script src="/static/js/htmx.min.js"></script>
<link rel="icon" type="image/svg+xml"
href="{{ favicon }}" />
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body>
<header>
<nav>
<a href="{{ path('app_food_order_index') }}">Orders</a>
<a href="{{ path('app_food_vendor_index') }}">Vendors</a>
<a
href="https://hannover.ccc.de/gitlab/lubiana/futtern/issues/new"
target="_blank"
>Create Issue</a>
</nav>
</header>
<main>
{% block body %}{% endblock %}
</main>
<header class="mb-4">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<span class="navbar-brand">Futtern</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{{ path('app_food_order_index') }}">Orders</a></li>
<li class="nav-item"><a class="nav-link" href="{{ path('app_food_vendor_index') }}">Vendors</a></li>
<li class="nav-item"><a class="nav-link" href="/api">API</a></li>
<li class="nav-item"><a class="nav-link" href="https://git.hannover.ccc.de/lubiana/futtern/issues/new" target="_blank">Create Issue</a></li>
</ul>
<div class="btn-group ms-auto" role="group" aria-label="Mode selection">
<input type="radio" class="btn-check" name="mode" id="normal" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="normal">
Normal
<span class="emoji-normal">😌</span>
<span class="emoji-enhanced">😌🍑</span>
<span class="emoji-bonkers">😌🍑🍆💦👅💋😈🏳️‍🌈✨</span>
</label>
<input type="radio" class="btn-check" name="mode" id="enhanced" autocomplete="off">
<label class="btn btn-outline-primary" for="enhanced">
Enhanced
<span class="emoji-normal">✨</span>
<span class="emoji-enhanced">✨🍑🍆</span>
<span class="emoji-bonkers">✨🍑🍆💦👅💋😈🏳️‍🌈</span>
</label>
<input type="radio" class="btn-check" name="mode" id="bonkers" autocomplete="off">
<label class="btn btn-outline-primary" for="bonkers">
Bonkers
<span class="emoji-normal">💦</span>
<span class="emoji-enhanced">💦🍑🍆</span>
<span class="emoji-bonkers">💦🍑🍆👅💋😈🏳️‍🌈✨🥵😳🤤😍🥴💕💗💘💝💞💟💌💏💑</span>
</label>
</div>
<span class="navbar-text">
Hello {{ app.request.cookies.get('username', 'nobody') }} - <a class="text-light" href="{{ path('username') }}">change name</a>
</span>
</div>
</div>
</nav>
</header>
<main class="container pb-5">
{% block body %}{% endblock %}
</main>
</body>
</html>

View file

@ -3,10 +3,12 @@
{% block title %}Edit FoodOrder{% endblock %}
{% block body %}
<h1>Edit FoodOrder</h1>
<h1 class="mb-4">Edit FoodOrder</h1>
{{ include('_form.html.twig', {'button_label': 'Update'}) }}
<div class="mb-4">
{{ include('_form.html.twig', {'button_label': 'Update'}) }}
</div>
<a href="{{ path('app_food_order_index') }}">back to list</a>
<a class="btn btn-secondary" href="{{ path('app_food_order_index') }}">back to list</a>
{% endblock %}

View file

@ -3,11 +3,20 @@
{% block title %}FoodOrder index{% endblock %}
{% block body %}
<h1>FoodOrder index</h1>
<table class="table">
<thead>
<h1 class="mb-4">FoodOrder index</h1>
<div class="mb-3">
<button
class="btn btn-primary"
hx-get="{{ path('app_food_order_new') }}"
hx-trigger="click"
hx-target="closest div"
>Create new</button>
</div>
<hr>
<table class="table table-striped table-hover">
<thead class="table-light">
<tr>
<th>CreatedBy</th>
<th>Vendor</th>
<th>CreatedAt</th>
<th>ClosedAt</th>
@ -17,18 +26,23 @@
<tbody>
{% for food_order in food_orders %}
{{ include('food_order/table_row.html.twig') }}
{% else %}
<tr>
<td colspan="4">no records found</td>
</tr>
{% endfor %}
{% if food_orders|length < 10 %}
<tr>
<td colspan="5" class="text-center text-muted">
check the <a href="{{ path('app_food_order_archive') }}">archive</a>
for older orders
</td>
</tr>
{% endif %}
</tbody>
</table>
<div>
<button
hx-get="{{ path('app_food_order_new') }}"
hx-trigger="click"
hx-target="closest div"
>Create new</button>
<div class="d-flex gap-2">
{% if prev_page > 0 %}
<a class="btn btn-outline-secondary btn-sm" href="{{ path('app_food_order_archive', {'page': prev_page}) }}">previous page</a>
{% endif %}
{% if next_page > current_page %}
<a class="btn btn-outline-secondary btn-sm" href="{{ path('app_food_order_archive', {'page': next_page}) }}">next page</a>
{% endif %}
</div>
{% endblock %}

View file

@ -1,2 +1,4 @@
{{ include('_form.html.twig') }}
<div class="mb-4">
{{ include('_form.html.twig') }}
</div>

View file

@ -3,57 +3,104 @@
{% block title %}FoodOrder{% endblock %}
{% block body %}
<h1>FoodOrder</h1>
<h1 class="mb-4">FoodOrder</h1>
<table class="table">
<table class="table table-bordered table-striped w-auto">
<tbody>
<tr>
<th>Vendor</th>
<td>{{ food_order.foodVendor.name }}</td>
</tr>
<tr>
<th>Vendorphone</th>
<td>{{ food_order.foodVendor.phone }}</td>
</tr>
<tr>
<th>Created By</th>
<td>{{ food_order.createdBy }}</td>
</tr>
<tr>
<th>CreatedAt</th>
<td>{{ food_order.createdAt ? food_order.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ food_order.createdAt ? food_order.createdAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}</td>
</tr>
<tr>
<th>ClosedAt</th>
<td>{{ food_order.closedAt ? food_order.closedAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ food_order.closedAt ? food_order.closedAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}</td>
</tr>
</tbody>
</table>
<a class="button" href="{{ path('app_food_order_index') }}">back to list</a>
{% if(food_order.isClosed) %}
<a class="button" href="{{ path('app_food_order_open', {'id': food_order.id}) }}">reopen</a>
{% else %}
<a class="button" href="{{ path('app_food_order_close', {'id': food_order.id}) }}">close</a>
{% endif %}
<div class="mb-3 d-flex gap-2">
<a class="btn btn-secondary" href="{{ path('app_food_order_index') }}">back to list</a>
{% if(food_order.isClosed) %}
<a class="btn btn-warning" href="{{ path('app_food_order_open', {'id': food_order.id}) }}">reopen</a>
{% else %}
<a class="btn btn-success" href="{{ path('app_food_order_close', {'id': food_order.id}) }}">close</a>
{% endif %}
</div>
<h2>Items</h2>
<table class="table">
<thead>
<h2 class="mt-5">Items</h2>
{% if (food_order.isClosed and form) %}
{{ form_start(form) }}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>By</th>
<th>item</th>
<th>extras</th>
<th>price</th>
<th>is paid</th>
</tr>
</thead>
<tbody>
{% for itemForm in form.orderItems %}
<tr>
<td>{{ field_value(itemForm.createdBy) }}</td>
<td>{{ field_value(itemForm.name) }}</td>
<td>{{ field_value(itemForm.extras) }}</td>
<td>{{ form_widget(itemForm.priceCents) }}</td>
<td>{{ form_widget(itemForm.isPaid) }}</td>
</tr>
{% endfor %}
<tr>
<td colspan="4"></td>
<td>{{ form_row(form.save) }} {{ form_row(form._token) }}</td>
</tr>
</tbody>
</table>
</div>
{{ form_end(form, {render_rest: false}) }}
{% else %}
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Index</th>
<th>username</th>
<th>name</th>
<th>extras</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for item in food_order.orderItems %}
{% for item in food_order.orderItemsSortedByName %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ item.createdBy }}</td>
<td>{{ item.name }}</td>
<td>{{ item.extras }}</td>
<td>
{% if(food_order.isClosed) %}
{% else %}
<a href="{{ path('app_order_item_edit', {'id': item.id}) }}">edit</a>
<a href="{{ path('app_order_item_copy', {'id': item.id}) }}">copy</a>
<a href="{{ path('app_order_item_delete', {'id': item.id}) }}">remove</a>
<a class="btn btn-sm btn-outline-primary me-1" href="{{ path('app_order_item_edit', {'id': item.id}) }}">edit</a>
<a class="btn btn-sm btn-outline-secondary me-1" href="{{ path('app_order_item_copy', {'id': item.id}) }}">copy</a>
<a class="btn btn-sm btn-outline-danger" href="{{ path('app_order_item_delete', {'id': item.id}) }}">remove</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<a class="button" href="{{ path('app_order_item_new', {'foodOrder': food_order.id}) }}">New Item</a>
{% endif %}
<a class="btn btn-primary mt-2" href="{{ path('app_order_item_new', {'foodOrder': food_order.id}) }}">New Item</a>
{% endblock %}

View file

@ -1,8 +1,18 @@
<tr>
<td>{{ food_order.foodVendor.name }}</td>
<td>{{ food_order.createdAt ? food_order.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ food_order.closedAt ? food_order.closedAt|date('Y-m-d H:i:s') : '' }}</td>
{% set opacity = food_order.isClosed ? 'opacity-25' : 'opacity-100' %}
<tr class="{{ opacity }}">
<td>
<a href="{{ path('app_food_order_show', {'id': food_order.id}) }}">show</a>
{{ food_order.createdBy }}
</td>
<td>
{{ food_order.foodVendor.name }}
</td>
<td>
{{ food_order.createdAt ? food_order.createdAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}
</td>
<td>
{{ food_order.closedAt ? food_order.closedAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}
</td>
<td>
<a class="btn btn-sm btn-outline-info" href="{{ path('app_food_order_show', {'id': food_order.id}) }}">show</a>
</td>
</tr>

View file

@ -1,4 +1,4 @@
{{ form_start(form) }}
{{ form_widget(form) }}
<button class="btn">{{ button_label|default('Save') }}</button>
{{ form_start(form, {'attr': {'class': 'mb-3'}}) }}
{{ form_widget(form, {'attr': {'class': 'form-control'}}) }}
<button class="btn btn-primary mt-2">{{ button_label|default('Save') }}</button>
{{ form_end(form) }}

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