Initial commit into new repository

This commit is contained in:
bastian 2025-02-13 14:51:21 +01:00
commit e3aa71a40a
73 changed files with 17742 additions and 0 deletions

16
.docker/conf.d/05-app.ini Normal file
View File

@ -0,0 +1,16 @@
expose_php = 0
date.timezone = Europe/Amsterdam
apc.enable_cli = 1
session.use_strict_mode = 1
zend.detect_unicode = 0
; Symfony performance optimizations (https://symfony.com/doc/current/performance.html)
realpath_cache_size = 4096K
realpath_cache_ttl = 600
; OPCache settings for better performance
opcache.enable = 1
opcache.enable_cli = 1
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 40000
opcache.memory_consumption = 256

View File

@ -0,0 +1,3 @@
opcache.validate_timestamps = 1
opcache.enable_file_override = 0
opcache.revalidate_freq = 1

View File

@ -0,0 +1,2 @@
opcache.enable_file_override = 1
opcache.validate_timestamps = 0

View File

@ -0,0 +1,23 @@
; This
server {
listen 80;
server_name localhost;
root /var/www/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php-cgk-streaming-roster:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_param HTTP_COOKIE $http_cookie;
}
error_log /var/log/nginx/cgk-streaming-roster-error.log;
access_log /var/log/nginx/cgk-streaming-roster-access.log;
}

View File

@ -0,0 +1,8 @@
#!/bin/bash
echo -e "\033[36;01m+ Executing migrations\033[0m"
/var/www/bin/console doctrine:migrations:migrate --no-interaction
if [ $? -ne 0 ]; then
echo -e "\033[0;31mAn error occurred when executing migrations.\033[0m"
fi

View File

@ -0,0 +1,3 @@
#!/bin/bash
echo -e "\033[0;32m+ Initialization done.\033[0m"

36
.docker/docker-entrypoint.sh Executable file
View File

@ -0,0 +1,36 @@
#!/bin/bash
set -e
SELF=$(basename "$0")
# first arg is `-f` or `--some-option`
if [ "${1#-}" != "$1" ]; then
set -- php-fpm "$@"
fi
if [ "$1" = 'php-fpm' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
if /usr/bin/find "/docker-entrypoint.d/" -mindepth 1 -maxdepth 1 -type f -print -quit 2>/dev/null | read v; then
echo "$SELF: /docker-entrypoint.d/ found, continuing to execute scripts"
find "/docker-entrypoint.d/" -follow -type f -print | sort -V | while read -r f; do
case "$f" in
*.sh)
if [ -x "$f" ]; then
echo -e "$SELF: Launching $f";
"$f"
else
# warn on shell scripts without exec bit
echo "$SELF: Ignoring $f, not executable";
fi
;;
*) echo "$SELF: Ignoring $f";;
esac
done
echo "$SELF: Configuration complete; ready for start up."
else
echo "$SELF: No files found in /docker-entrypoint.d/, skipping configuration."
fi
fi
exec docker-php-entrypoint "$@"

8
.docker/docker-healthcheck.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
if env -i REQUEST_METHOD=GET SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping cgi-fcgi -bind -connect /var/run/php/php-fpm.sock; then
exit 0
fi
exit 1

27
.env Normal file
View File

@ -0,0 +1,27 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=prod
APP_SECRET=ef6fa07151a06c16691a39b12431d80d
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
DATABASE_URL="pgsql://user:pass@host:5432/database_name"
###< doctrine/doctrine-bundle ###

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> symfony/webpack-encore-bundle ###
/node_modules/
/public/build/
npm-debug.log
yarn-error.log
###< symfony/webpack-encore-bundle ###
compose.override.yaml
docker/volumes

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM php:8.3-fpm
# Set working directory
WORKDIR /var/www
# Install required system dependencies
RUN apt-get update && apt-get install -y \
unzip \
git \
curl \
libicu-dev \
libpq-dev \
libonig-dev \
libzip-dev \
npm \
&& docker-php-ext-install intl pdo pdo_pgsql zip
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Copy settings for PHP-FPM
COPY --link ./.docker/php/conf.d/05-app.ini $PHP_INI_DIR/conf.d/
COPY --link ./.docker/php/conf.d/50-opcache_dev.ini $PHP_INI_DIR/conf.d/
RUN echo "export PS1='\h:\w\$ '" >> /root/.bashrc
# Expose port for PHP-FPM
EXPOSE 9000
# Start PHP-FPM
CMD ["php-fpm"]

83
Dockerfile-prod Normal file
View File

@ -0,0 +1,83 @@
# --- Stage 1: Build PHP & Symfony ---
FROM php:8.3-fpm AS php_builder
# Set working directory
WORKDIR /var/www
RUN apt-get update && apt-get install -y \
bash \
unzip \
git \
curl \
libicu-dev \
libpq-dev \
libonig-dev \
libzip-dev \
npm \
&& docker-php-ext-install intl pdo pdo_pgsql zip
# Make Composer available.
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Copy source.
COPY . .
RUN printf "APP_ENV=prod\nAPP_DEBUG=0\nDATABASE_URL=\"mysql://user:password@url:3306?serverVersion=1\"\n" > .env.local
# Install app dependencies.
RUN composer install --no-dev --no-scripts --no-progress --no-interaction --optimize-autoloader
# Run Symfony cache warmup.
RUN php bin/console cache:warmup
# --- Stage 2: Build Frontend ---
FROM node:18 AS frontend_builder
WORKDIR /app
# Copy source again, for front-end assets.
COPY . .
RUN npm install && npm run build
# --- Stage 3: Production Runtime ---
FROM php:8.3-fpm
# Set working directory
WORKDIR /var/www
# Install required PHP extensions (must be in runtime)
RUN apt-get update && apt-get install -y \
libicu-dev \
libpq-dev \
libonig-dev \
libzip-dev \
&& docker-php-ext-install intl pdo pdo_pgsql zip
# Copy built app from PHP build stage
COPY --from=php_builder /var/www ./
# Copy only built frontend assets
COPY --from=frontend_builder /app/public/build ./public/build
# Clean up
RUN rm -rf .DS_Store .idea .env.local /var/www/html /var/www/var/cache/dev Dockerfile* compose.yaml .git LICENSE README.md node_modules
# Copy settings for PHP-FPM
COPY --link ./.docker/conf.d/05-app.ini $PHP_INI_DIR/conf.d/
COPY --link ./.docker/conf.d/50-opcache_prod.ini $PHP_INI_DIR/conf.d/
# Set entrypoints + terminal
RUN echo "export PS1='\h:\w\$ '" >> /root/.bashrc
COPY --chmod=744 --link .docker/docker-entrypoint.d /docker-entrypoint.d
COPY --link ./.docker/docker-healthcheck.sh /usr/local/bin/docker-healthcheck
COPY --link ./.docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
HEALTHCHECK --interval=10s --timeout=3s --retries=3 CMD ["/usr/local/bin/docker-healthcheck"]
ENTRYPOINT ["/usr/local/bin/docker-entrypoint"]
# Expose PHP-FPM port
EXPOSE 9000
# Start PHP-FPM
CMD ["php-fpm"]

404
LICENSE Normal file
View File

@ -0,0 +1,404 @@
Copyright © 2023 BowlOfSoup
Attribution-NonCommercial-NoDerivatives 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial-NoDerivatives 4.0
International Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-NoDerivatives 4.0 International Public
License ("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
c. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
d. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
e. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
f. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
g. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
h. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
i. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
j. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
k. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce and reproduce, but not Share, Adapted Material
for NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material, You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
For the avoidance of doubt, You do not have permission under
this Public License to Share Adapted Material.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only and provided You do not Share Adapted Material;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.

65
README.md Normal file
View File

@ -0,0 +1,65 @@
# cgk-streaming-roster
Streaming roster for CGK Leerdam - keeping track of who is streaming when.
### Prerequisites
If you actually want to develop on this, you'll need a Nginx container to serve the requests, and a Postgres container as database.
The author is running this with a specific shared-services container setup.
You can use the following configuration in a `compose.override.yaml` file:
```
services:
nginx:
container_name: 'cgk-streaming-roster-nginx'
image: nginx:1.27-alpine
networks:
- 'cgk-streaming-roster-network'
ports:
- "80:80"
volumes:
- .:/var/www:rw
- ./docker/dev/nginx:/etc/nginx/conf.d/
postgres:
container_name: 'cgk-streaming-roster-database'
image: postgres:17-alpine
environment:
POSTGRES_USER: cgk-streaming-roster
POSTGRES_PASSWORD: mypassword
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
ports:
- "5432:5432"
networks:
- 'cgk-streaming-roster-network'
healthcheck:
test: [ "CMD", "pg_isready", "-d", "${POSTGRES_USER}", "-U", "${POSTGRES_USER}" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 60s
volumes:
- ./docker/volumes/postgres/data:/var/lib/postgresql/data:rw
php-cgk-streaming-roster:
networks:
- 'cgk-streaming-roster-network'
networks:
cgk-streaming-roster-network
driver: bridge
```
* Create a proper .env.local file in the PHP container that matches the database settings
* Change the Nginx container port mapping if needed (e.g. 8080:80, where you can reach the app on localhost:8080)
Now run `docker compose up -d`. If successful, you can reach the app on http://localhost:80 (if you didn't change the port mapping).
### Fresh install, execute the following in the PHP container
1. `cp .env .env.local` and set up the correct values
2. `composer install`
3. `bin/console doctrine:migrations:migrate`
4. `npm install && npm run dev`
If you develop on the front-end:
* `npm run watch`

9
assets/app.js Normal file
View File

@ -0,0 +1,9 @@
/*
* Welcome to your app's main JavaScript file!
*
* We recommend including the built version of this JavaScript file
* (and its CSS file) in your base layout (base.html.twig).
*/
// any CSS you import will output into a single css file (app.css in this case)
import './styles/app.css';

5
assets/roster.js Normal file
View File

@ -0,0 +1,5 @@
document.addEventListener('DOMContentLoaded', function () {
setTimeout(function(){
window.location.reload();
}, 10000);
});

123
assets/styles/app.css Normal file
View File

@ -0,0 +1,123 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
body {
background-color: lightgray;
}
.header-login {
@apply text-white text-center py-4;
background-color: #008A9E;
}
.header-page {
@apply text-white py-4 flex justify-between items-center px-4;
background-color: #008A9E;
}
.submit-button {
@apply text-white font-bold py-2 px-4 rounded;
background-color: #008A9E;
}
.submit-button:hover {
background-color: #003139;
}
th {
text-align: left;
}
#login_card {
@apply justify-center items-start h-screen mt-[5%] w-1/2 max-w-2xl mx-auto;
}
#page_card {
@apply justify-center items-start min-h-screen mt-[1%] mb-[1%] max-w-6xl mx-auto bg-white shadow-md rounded px-4 py-4;
}
.alert {
@apply font-bold rounded px-4 py-2;
margin-bottom: 5px;
}
.alert-error {
@apply bg-red-500 text-white;
}
.alert-success {
@apply bg-green-500 text-white;
}
.alert-warning {
@apply bg-yellow-500 text-white;
}
.user-name-input {
@apply w-full border-2 border-gray-300 rounded p-2;
box-sizing: border-box;
}
.slot-button {
@apply bg-transparent border-none cursor-pointer p-2;
}
.claim-icon {
@apply inline-block;
color: #008A9E;
font-size: 1.2rem;
}
.has-tooltip {
position: relative;
display: inline-block;
}
/* Tooltip text */
.has-tooltip .tooltip-text {
font-family: ui-sans-serif;
font-size: 0.9rem;
visibility: hidden;
width: 150px;
background-color: rgb(128, 128, 128);
color: #FFFFFF;
text-align: center;
padding: 10px;
border-radius: 0.25rem;
position: absolute;
z-index: 1;
bottom: 130%;
left: 50%;
margin-left: -60px; /* Use half of the width (120/2 = 60), to center the tooltip */
}
.has-tooltip:hover .tooltip-text {
visibility: visible;
transition: .75s all ease;
transition-delay: .75s;
}
a:hover {
color: #003139 !important;
}
.roster-line-height td {
padding: 15px;
}
.roster-line-past {
color: #A9A9A9;
background-color: #F8F8F8;
}
.roster-line-mine {
background-color: #F1FDF1;
}
.form-as-icon {
float: left;
}

17
bin/console Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

12
compose.yaml Normal file
View File

@ -0,0 +1,12 @@
services:
php-cgk-streaming-roster:
build: ./Dockerfile
container_name: 'php-cgk-streaming-roster'
volumes:
- .:/var/www/cgk-streaming-roster
networks:
- 'b7d_shared'
networks:
b7d_shared:
external: true

80
composer.json Normal file
View File

@ -0,0 +1,80 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.3",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^2.11",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^2.17",
"ramsey/uuid": "^4.7",
"symfony/console": "6.4.*",
"symfony/dotenv": "6.4.*",
"symfony/flex": "^2",
"symfony/form": "6.4.*",
"symfony/framework-bundle": "6.4.*",
"symfony/runtime": "6.4.*",
"symfony/security-bundle": "6.4.*",
"symfony/translation": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "6.4.*"
},
"require-dev": {
"symfony/maker-bundle": "^1.51"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "6.4.*"
}
}
}

5930
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

11
config/bundles.php Normal file
View File

@ -0,0 +1,11 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
];

View File

@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View File

@ -0,0 +1,50 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '15'
profiling_collect_backtrace: '%kernel.debug%'
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@ -0,0 +1,31 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
#csrf_protection: true
http_method_override: false
handle_all_throwables: true
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
cookie_lifetime: 3600
gc_maxlifetime: 3600
#esi: true
#fragments: true
php_errors:
log: true
form:
csrf_protection:
enabled: false
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@ -0,0 +1,12 @@
framework:
router:
utf8: true
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
when@prod:
framework:
router:
strict_requirements: null

View File

@ -0,0 +1,49 @@
security:
enable_authenticator_manager: true
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
app_user_provider:
entity:
class: App\Entity\User
property: token
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
custom_authenticator: App\Security\TokenAuthenticator
form_login:
login_path: app_login
check_path: app_login
logout:
path: app_logout
target: app_login
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/roster-view, roles: PUBLIC_ACCESS }
- { path: ^/roster, roles: IS_AUTHENTICATED_FULLY }
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@ -0,0 +1,15 @@
framework:
default_locale: nl
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
# providers:
# crowdin:
# dsn: '%env(CROWDIN_DSN)%'
# loco:
# dsn: '%env(LOCO_DSN)%'
# lokalise:
# dsn: '%env(LOKALISE_DSN)%'
# phrase:
# dsn: '%env(PHRASE_DSN)%'

View File

@ -0,0 +1,6 @@
twig:
default_path: '%kernel.project_dir%/templates'
when@test:
twig:
strict_variables: true

View File

@ -0,0 +1,13 @@
framework:
validation:
email_validation_mode: html5
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

View File

@ -0,0 +1,45 @@
webpack_encore:
# The path where Encore is building the assets - i.e. Encore.setOutputPath()
output_path: '%kernel.project_dir%/public/build'
# If multiple builds are defined (as shown below), you can disable the default build:
# output_path: false
# Set attributes that will be rendered on all script and link tags
script_attributes:
defer: true
# Uncomment (also under link_attributes) if using Turbo Drive
# https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change
# 'data-turbo-track': reload
# link_attributes:
# Uncomment if using Turbo Drive
# 'data-turbo-track': reload
# If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials')
# crossorigin: 'anonymous'
# Preload all rendered script and link tags automatically via the HTTP/2 Link header
# preload: true
# Throw an exception if the entrypoints.json file is missing or an entry is missing from the data
# strict_mode: false
# If you have multiple builds:
# builds:
# frontend: '%kernel.project_dir%/public/frontend/build'
# pass the build name as the 3rd argument to the Twig functions
# {{ encore_entry_script_tags('entry1', null, 'frontend') }}
framework:
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
#when@prod:
# webpack_encore:
# # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
# # Available in version 1.2
# cache: true
#when@test:
# webpack_encore:
# strict_mode: false

5
config/preload.php Normal file
View File

@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

5
config/routes.yaml Normal file
View File

@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View File

@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

24
config/services.yaml Normal file
View File

@ -0,0 +1,24 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20231120113913 extends AbstractMigration
{
public function getDescription(): string
{
return 'Initial schema with user and roster tables';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE "user" (
id SERIAL PRIMARY KEY,
full_name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
token VARCHAR(255) NOT NULL,
role VARCHAR(255) DEFAULT NULL
)');
$this->addSql('CREATE TABLE roster (
id SERIAL PRIMARY KEY,
user_id INT DEFAULT NULL,
date DATE NOT NULL,
time TIME NOT NULL,
description VARCHAR(255) DEFAULT NULL,
CONSTRAINT fk_roster_user FOREIGN KEY (user_id) REFERENCES "user" (id)
)');
$this->addSql('CREATE INDEX idx_roster_user_id ON roster (user_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE roster DROP CONSTRAINT fk_roster_user');
$this->addSql('DROP INDEX IF EXISTS idx_roster_user_id');
$this->addSql('DROP TABLE IF EXISTS roster');
$this->addSql('DROP TABLE IF EXISTS "user"');
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20231120114319 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add index on date and time columns in roster table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE INDEX idx_date_time ON roster (date, time)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS idx_date_time');
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20231120225133 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add user_previous_id column and its foreign key constraint';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE roster ADD COLUMN user_previous_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE roster ADD CONSTRAINT fk_roster_user_previous FOREIGN KEY (user_previous_id) REFERENCES "user" (id)');
$this->addSql('CREATE INDEX idx_roster_user_previous ON roster (user_previous_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE roster DROP CONSTRAINT fk_roster_user_previous');
$this->addSql('DROP INDEX IF EXISTS idx_roster_user_previous');
$this->addSql('ALTER TABLE roster DROP COLUMN user_previous_id');
}
}

8919
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"devDependencies": {
"@symfony/webpack-encore": "^4.5.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"postcss-loader": "^7.3.3",
"tailwindcss": "^3.3.5"
},
"scripts": {
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production"
},
"dependencies": {
"core-js": "^3.33.3"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

66
public/.htaccess Normal file
View File

@ -0,0 +1,66 @@
# Use the front controller as index file. It serves as a fallback solution when
# every other rewrite/redirect fails (e.g. in an aliased environment without
# mod_rewrite). Additionally, this reduces the matching process for the
# start page (path "/") because otherwise Apache will apply the rewriting rules
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex index.php
# By default, Apache does not evaluate symbolic links if you did not enable this
# feature in your server configuration. Uncomment the following line if you
# install assets as symlinks or if you experience problems related to symlinks
# when compiling LESS/Sass/CoffeScript assets.
# Options +FollowSymlinks
# Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
# to the front controller "/index.php" but be rewritten to "/index.php/index".
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
# Determine the RewriteBase automatically and set it as environment variable.
# If you are using Apache aliases to do mass virtual hosting or installed the
# project in a subdirectory, the base path will be prepended to allow proper
# resolution of the index.php file and to redirect to the correct URI. It will
# work in environments without path prefix as well, providing a safe, one-size
# fits all solution. But as you do not need it in this case, you can comment
# the following 2 lines to eliminate the overhead.
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# Redirect to URI without front controller to prevent duplicate content
# (with and without `/index.php`). Only do this redirect on the initial
# rewrite by Apache and not on subsequent cycles. Otherwise we would get an
# endless redirect loop (request -> rewrite to front controller ->
# redirect -> request -> ...).
# So in case you get a "too many redirects" error or you always get redirected
# to the start page because your Apache does not expose the REDIRECT_STATUS
# environment variable, you have 2 choices:
# - disable this feature by commenting the following 2 lines or
# - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
# following RewriteCond (best solution)
RewriteCond %{ENV:REDIRECT_STATUS} =""
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]
# If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories.
# Rewrite all other queries to the front controller.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule>
<IfModule !mod_rewrite.c>
<IfModule mod_alias.c>
# When mod_rewrite is not available, we instruct a temporary redirect of
# the start page to the front controller explicitly so that the website
# and the generated links can still be used.
RedirectMatch 307 ^/$ /index.php/
# RedirectTemp cannot be used instead
</IfModule>
</IfModule>

9
public/index.php Normal file
View File

@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-Agent: *
Disallow: /

View File

@ -0,0 +1,71 @@
<?php
namespace App\Command;
use App\Entity\Roster;
use App\Repository\RosterRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:roster:add',
description: 'Add new date and times',
)]
class AppRosterAddCommand extends Command
{
public function __construct(
private readonly RosterRepository $rosterRepository
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('date', InputArgument::OPTIONAL, 'Date for the roster (YYYY-MM-DD)');
}
/**
* @throws \Exception
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dateInput = $input->getArgument('date');
$date = $dateInput ? new \DateTimeImmutable($dateInput) : null;
if (!$date) {
$dateQuestion = new Question('Enter the date for the roster (YYYY-MM-DD): ');
$dateInput = $io->askQuestion($dateQuestion);
$date = new \DateTimeImmutable($dateInput);
}
$io->writeln('Enter times and descriptions for ' . $date->format('Y-m-d') . ':');
while (true) {
$timeInput = $io->ask('Enter a time (HH:MM) or leave blank to finish');
if (empty($timeInput)) {
break;
}
$description = $io->ask('Enter a description for ' . $timeInput);
$roster = $this->rosterRepository->findOneBy(['date' => $date, 'time' => new \DateTimeImmutable($timeInput)]);
if (!$roster) {
$roster = new Roster();
$roster->setDate($date);
$roster->setTime(new \DateTimeImmutable($timeInput));
$roster->setDescription($description);
$this->rosterRepository->save($roster);
}
}
$io->success('Roster records added successfully.');
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Command;
use App\Entity\Roster;
use App\Repository\RosterRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:roster:generate',
description: 'Generate new dates for a roster',
)]
class AppRosterGenerateCommand extends Command
{
public function __construct(
private readonly RosterRepository $rosterRepository
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('endDate', InputArgument::REQUIRED, 'The end date to calculate Sundays until (YYYY-MM-DD)');
}
/**
* @throws \Exception
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$endDate = new \DateTimeImmutable($input->getArgument('endDate'));
$currentDate = new \DateTimeImmutable('next Sunday');
while ($currentDate <= $endDate) {
$this->createRosterRecord($currentDate, new \DateTimeImmutable('10:00'));
$this->createRosterRecord($currentDate, new \DateTimeImmutable('18:00'));
$currentDate = $currentDate->modify('next Sunday');
}
$io->success('Roster records created successfully.');
return Command::SUCCESS;
}
private function createRosterRecord(\DateTimeImmutable $date, \DateTimeImmutable $time): void
{
$roster = $this->rosterRepository->findOneBy(['date' => $date, 'time' => $time]);
if (null !== $roster) {
return;
}
$roster = new Roster();
$roster->setDate($date);
$roster->setTime($time);
$this->rosterRepository->save($roster);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:user:add',
description: 'Add a new user to the system.',
)]
class AppUserAddCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('fullName', InputArgument::OPTIONAL, 'The full name of the user')
->addArgument('email', InputArgument::OPTIONAL, 'The email of the user');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$fullName = $input->getArgument('fullName');
$email = $input->getArgument('email');
if (!$fullName) {
$fullName = $io->ask('Enter the full name of the user');
}
if (!$email) {
$email = $io->ask('Enter the email of the user');
}
$token = Uuid::uuid4()->toString();
$user = new User();
$user->setFullName($fullName);
$user->setEmail($email);
$user->setToken($token);
$this->userRepository->save($user);
$io->success(sprintf('User "%s" with email "%s" has been added', $fullName, $email));
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace App\Controller;
use App\Enum\RosterFormActionEnum;
use App\Form\RosterFormType;
use App\Repository\RosterRepository;
use App\Service\RosterUpdateService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
class RosterController extends AbstractController
{
public function __construct(
private readonly RosterRepository $rosterRepository,
private readonly RosterUpdateService $rosterUpdateService,
private readonly TranslatorInterface $translator
) {}
#[Route('/roster', name: 'app_roster', methods: [Request::METHOD_GET])]
public function index(): Response
{
$rosterDates = $this->rosterRepository->findAllOrderByDateTimeLastSunday();
return $this->render('roster/index.html.twig', [
'currentUserId' => $this->getUser()->getUserIdentifier(),
'rosterDates' => $rosterDates,
]);
}
#[Route('/roster-view', name: 'app_roster_view_anon', methods: [Request::METHOD_GET])]
public function indexViewAnonymous(): Response
{
$rosterDates = $this->rosterRepository->findAllOrderByDateTimeLastSunday();
return $this->render('roster/index.html.twig', [
'currentUserId' => null,
'rosterDates' => $rosterDates,
]);
}
#[Route('/roster', name: 'app_roster_update', methods: [Request::METHOD_POST])]
public function update(Request $request): Response
{
$form = $this->createForm(RosterFormType::class);
$form->handleRequest($request);
if (!$form->isSubmitted() || !$form->isValid()) {
$this->addFlash(
'error',
'Something went wrong, contact the administrator.'
);
return $this->redirectToRoute('app_roster');
}
/** @var \App\Form\RosterForm $rosterForm */
$rosterForm = $form->getData();
switch ($rosterForm->getRosterAction()) {
case RosterFormActionEnum::ActionClaimSlot:
$this->rosterUpdateService->updateRosterByForm($rosterForm);
$this->addFlash(
'success',
$this->translator->trans('roster_update_success', [
'%date%' => $rosterForm->getRoster()->getDate()->format('Y-m-d'),
'%time%' => $rosterForm->getRoster()->getTime()->format('H:i'),
])
);
break;
case RosterFormActionEnum::ActionFreeSlot:
$this->rosterUpdateService->freeRosterSlotByForm($rosterForm);
$this->addFlash(
'warning',
$this->translator->trans('roster_slot_freed', [
'%date%' => $rosterForm->getRoster()->getDate()->format('Y-m-d'),
'%time%' => $rosterForm->getRoster()->getTime()->format('H:i'),
])
);
break;
case RosterFormActionEnum::ActionResetSlot:
if (!$this->rosterUpdateService->resetRosterSlotByForm($rosterForm)) {
return $this->redirectToRoute('app_roster');
}
$this->addFlash(
'warning',
$this->translator->trans('roster_slot_reset', [
'%date%' => $rosterForm->getRoster()->getDate()->format('Y-m-d'),
'%time%' => $rosterForm->getRoster()->getTime()->format('H:i'),
])
);
break;
}
return $this->redirectToRoute('app_roster');
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
// Handled by Symfony.
}
#[Route('/', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
$error = $authenticationUtils->getLastAuthenticationError();
return $this->render('security/login.html.twig', ['error' => $error]);
}
}

97
src/Entity/Roster.php Normal file
View File

@ -0,0 +1,97 @@
<?php
namespace App\Entity;
use App\Repository\RosterRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: RosterRepository::class)]
#[ORM\Index(columns: ['date', 'time'], name: 'idx_date_time')]
class Roster
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private ?\DateTimeImmutable $date = null;
#[ORM\Column(type: Types::TIME_IMMUTABLE)]
private ?\DateTimeImmutable $time = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $description = null;
#[ORM\ManyToOne(inversedBy: 'rosters')]
private ?User $user = null;
#[ORM\ManyToOne]
private ?User $userPrevious = null;
public function getId(): ?int
{
return $this->id;
}
public function getDate(): ?\DateTimeImmutable
{
return $this->date;
}
public function setDate(\DateTimeImmutable $date): static
{
$this->date = $date;
return $this;
}
public function getTime(): ?\DateTimeImmutable
{
return $this->time;
}
public function setTime(\DateTimeImmutable $time): static
{
$this->time = $time;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getUserPrevious(): ?User
{
return $this->userPrevious;
}
public function setUserPrevious(?User $userPrevious): static
{
$this->userPrevious = $userPrevious;
return $this;
}
}

136
src/Entity/User.php Normal file
View File

@ -0,0 +1,136 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: "\"user\"")]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserTokenInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $fullName = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(length: 255)]
private ?string $token = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $role = null;
#[ORM\OneToMany(mappedBy: 'user', targetEntity: Roster::class)]
private Collection $rosters;
public function __construct()
{
$this->rosters = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getFullName(): ?string
{
return $this->fullName;
}
public function setFullName(string $fullName): static
{
$this->fullName = $fullName;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getToken(): ?string
{
return $this->token;
}
public function setToken(string $token): static
{
$this->token = $token;
return $this;
}
public function getRole(): ?string
{
return $this->role;
}
public function setRole(?string $role): static
{
$this->role = $role;
return $this;
}
/**
* @return Collection<int, Roster>
*/
public function getRosters(): Collection
{
return $this->rosters;
}
public function addRoster(Roster $roster): static
{
if (!$this->rosters->contains($roster)) {
$this->rosters->add($roster);
$roster->setUser($this);
}
return $this;
}
public function removeRoster(Roster $roster): static
{
if ($this->rosters->removeElement($roster)) {
// set the owning side to null (unless already changed)
if ($roster->getUser() === $this) {
$roster->setUser(null);
}
}
return $this;
}
public function getRoles(): array
{
return [$this->getRole()];
}
public function eraseCredentials(): void
{
$this->token = null;
}
public function getUserIdentifier(): string
{
return $this->getId();
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use Symfony\Component\Security\Core\User\UserInterface;
interface UserTokenInterface extends UserInterface
{
public function getToken(): ?string;
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum RosterFormActionEnum: string
{
case ActionClaimSlot = 'actionClaimSlot';
case ActionFreeSlot = 'actionFreeSlot';
case ActionResetSlot = 'actionResetSlot';
}

40
src/Form/RosterForm.php Normal file
View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Roster;
use App\Enum\RosterFormActionEnum;
use Symfony\Component\Validator\Constraints as Assert;
class RosterForm
{
#[Assert\NotNull]
private ?Roster $roster;
#[Assert\NotNull]
private ?RosterFormActionEnum $rosterAction;
public function getRoster(): ?Roster
{
return $this->roster;
}
public function setRoster(?Roster $roster): RosterForm
{
$this->roster = $roster;
return $this;
}
public function getRosterAction(): ?RosterFormActionEnum
{
return $this->rosterAction;
}
public function setRosterAction(?RosterFormActionEnum $rosterAction): RosterForm
{
$this->rosterAction = $rosterAction;
return $this;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Roster;
use App\Enum\RosterFormActionEnum;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class RosterFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('roster', EntityType::class, [
'class' => Roster::class,
])
->add('rosterAction', EnumType::class, [
'class' => RosterFormActionEnum::class
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => RosterForm::class,
]);
}
}

11
src/Kernel.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Repository;
use App\Entity\Roster;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\Collection;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Roster>
*
* @method Roster|null find($id, $lockMode = null, $lockVersion = null)
* @method Roster|null findOneBy(array $criteria, array $orderBy = null)
* @method Roster[] findAll()
* @method Roster[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class RosterRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Roster::class);
}
public function save(Roster $roster): void
{
$this->getEntityManager()->persist($roster);
$this->getEntityManager()->flush();
}
/**
* @return \Doctrine\Common\Collections\Collection|\App\Entity\Roster[]|array
*/
public function findAllOrderByDateTimeLastSunday(): array|Collection
{
$today = new \DateTime();
$dayOfWeek = $today->format('w'); // 0 (for Sunday) through 6 (for Saturday)
$lastSunday = $today->modify('-' . $dayOfWeek . ' days');
return $this->createQueryBuilder('r')
->where('r.date >= :lastSunday')
->addOrderBy('r.date', 'ASC')
->addOrderBy('r.time', 'ASC')
->setParameter('lastSunday', $lastSunday->format('Y-m-d'))
->getQuery()
->getResult();
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*
* @method User|null find($id, $lockMode = null, $lockVersion = null)
* @method User|null findOneBy(array $criteria, array $orderBy = null)
* @method User[] findAll()
* @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function save(User $user): void
{
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\UserTokenInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class TokenAuthenticator extends AbstractAuthenticator
{
use TargetPathTrait;
public function __construct(
private readonly RouterInterface $router
) { }
public function supports(Request $request): ?bool
{
// Check if there is a token in the query or request parameters
return $request->query->has('token') || $request->request->has('token');
}
public function authenticate(Request $request): Passport
{
$token = $request->query->get('token') ?? $request->request->get('token');
if (!$token) {
throw new CustomUserMessageAuthenticationException('Missing token.');
}
return new Passport(new UserBadge($token), new CustomCredentials(
function ($credentials, UserTokenInterface $user) {
return $user->getToken() === $credentials;
},
$token
));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($targetPath);
}
// Redirect after successful authentication.
return new RedirectResponse($this->router->generate('app_roster'));
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
// Save the login error in the session
$request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception);
return new RedirectResponse($this->router->generate('app_login'));
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use App\Form\RosterForm;
use App\Repository\RosterRepository;
use App\Repository\UserRepository;
use Symfony\Bundle\SecurityBundle\Security;
class RosterUpdateService
{
public function __construct(
private readonly Security $security,
private readonly UserRepository $userRepository,
private readonly RosterRepository $rosterRepository
) {}
public function updateRosterByForm(RosterForm $rosterForm): void
{
$roster = $rosterForm->getRoster();
if (null === $roster->getUserPrevious()
&& null !== $roster->getUser()
|| (null !== $roster->getUserPrevious()
&& null !== $roster->getUser()
&& $roster->getUser()->getId() !== $roster->getUserPrevious()->getId()
)
) {
$roster->setUserPrevious($roster->getUser());
}
$roster->setUser($this->getCurrentLoggedInUser());
$this->rosterRepository->save($roster);
}
public function freeRosterSlotByForm(RosterForm $rosterForm): void
{
$roster = $rosterForm->getRoster();
$roster->setUser(null);
$this->rosterRepository->save($roster);
}
public function resetRosterSlotByForm(RosterForm $rosterForm): bool
{
$roster = $rosterForm->getRoster();
if (null !== $roster->getUserPrevious()
&& ($roster->getUser() !== null && $roster->getUserPrevious()->getId() !== $roster->getUser()->getId())
) {
$roster->setUser($roster->getUserPrevious());
} else {
return false;
}
$this->rosterRepository->save($roster);
return true;
}
private function getCurrentLoggedInUser(): User
{
$userId = $this->security->getUser()->getUserIdentifier();
return $this->userRepository->find($userId);
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class EnumExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('enum', [$this, 'enum']),
];
}
public function enum(string $fullClassName): object
{
$parts = explode('::', $fullClassName);
$className = $parts[0];
$constant = $parts[1] ?? null;
if (!enum_exists($className)) {
throw new \InvalidArgumentException(sprintf('"%s" is not an enum.', $className));
}
if ($constant) {
return constant($fullClassName);
}
return new class($fullClassName) {
public function __construct(private readonly string $fullClassName)
{
}
public function __call(string $caseName, array $arguments): mixed
{
return call_user_func_array([$this->fullClassName, $caseName], $arguments);
}
};
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class NameExtractorExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('extract_name', [$this, 'extractName']),
];
}
public function extractName(string $fullName): string
{
if ($fullName === "") {
return $fullName;
}
$parts = explode(' ', trim($fullName));
$formattedNameParts = [];
foreach ($parts as $index => $part) {
// Skip parts containing "'t"
if (strpos($part, "'t") !== false) {
continue;
}
if ($this->isStartOfSurname($part, $index, $parts)) {
$letter = substr($part, 0, 1);
$formattedNamePart = ucfirst(strtolower($letter)) . '.';
$formattedNameParts[] = $formattedNamePart;
break;
} else {
$formattedNameParts[] = $part;
}
}
return implode(' ', $formattedNameParts);
}
private function isStartOfSurname($part, $index, $parts): bool
{
// If the part is capitalized, and it's not the first word, it might be the start of the surname
return ctype_lower($part) === false && $index > 0;
}
}

160
symfony.lock Normal file
View File

@ -0,0 +1,160 @@
{
"doctrine/doctrine-bundle": {
"version": "2.11",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.10",
"ref": "0db4b12b5df45f5122213b4ecd18733ab7fa7d53"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"symfony/console": {
"version": "6.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
},
"files": [
".env"
]
},
"symfony/framework-bundle": {
"version": "6.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.2",
"ref": "af47254c5e4cd543e6af3e4508298ffebbdaddd3"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php"
]
},
"symfony/maker-bundle": {
"version": "1.51",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/routing": {
"version": "6.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.2",
"ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "6.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "8a5b112826f7d3d5b07027f93786ae11a1c7de48"
},
"files": [
"config/packages/security.yaml"
]
},
"symfony/translation": {
"version": "6.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "64fe617084223633e1dedf9112935d8c95410d3e"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": {
"version": "6.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "b7772eb20e92f3fb4d4fe756e7505b4ba2ca1a2c"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/validator": {
"version": "6.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "c32cfd98f714894c4f128bb99aa2530c1227603c"
},
"files": [
"config/packages/validator.yaml"
]
},
"symfony/webpack-encore-bundle": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.0",
"ref": "082d754b3bd54b3fc669f278f1eea955cfd23cf5"
},
"files": [
"assets/app.js",
"assets/styles/app.css",
"config/packages/webpack_encore.yaml",
"package.json",
"webpack.config.js"
]
}
}

12
tailwind.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.twig',
'./assets/**/*.js',
],
theme: {
extend: {},
},
plugins: [],
}

22
templates/base.html.twig Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="robots" content="noindex,nofollow" />
<title>{% block title %}Rooster streamen CGK{% 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=%220.9em%22 font-size=%22128%22>✞</text></svg>">
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% endblock %}
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" rel="stylesheet">
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,121 @@
{% extends "base.html.twig" %}
{% block javascripts %}
{{ encore_entry_script_tags('roster') }}
{% endblock %}
{% block body %}
<header class="header-page">
<h1 class="text-3xl">Rooster streamen</h1>
{% if currentUserId != null %}
<a href="{{ path('app_logout') }}" class="font-bold text-white hover:text-blue-200">
[ {{ 'Logout'|trans }} ]
</a>
{% else %}
<a href="{{ path('app_login') }}" class="font-bold text-white hover:text-blue-200">
[ {{ 'Login'|trans }} ]
</a>
{% endif %}
</header>
<div id="page_card" class="bg-white shadow-md rounded px-4 py-4">
{% if app.session.flashbag.peekAll|length > 0 %}
{% for type, messages in app.session.flashbag.all %}
{% for message in messages %}
<div class="flash-message">
<div class="alert alert-{{ type ? type : 'error' }}">
{{ message|trans }}
</div>
</div>
{% endfor %}
{% endfor %}
{% endif %}
<table class="table-auto w-full">
<thead>
<tr class="bg-gray-100">
<th class="px-4 py-2">{{ 'Date'|trans }}</th>
<th class="px-4 py-2">{{ 'Time'|trans }}</th>
<th class="px-4 py-2">{{ 'Description'|trans }}</th>
<th class="px-4 py-2">{{ 'Streamer'|trans }}</th>
{% if currentUserId != null %}
<th class="px-4 py-2">{{ 'Claim timeslot'|trans }}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for roster in rosterDates %}
{% set lineInPast = 'now'|date('Y-m-d') > roster.date|date('Y-m-d') %}
{% if (lineInPast) %}
<tr class="roster-line roster-line-past roster-line-height">
{% else %}
{% if currentUserId == null %}
<tr class="roster-line roster-line-height">
{% elseif currentUserId == roster.user.id|default() %}
<tr class="roster-line roster-line-mine">
{% else %}
<tr class="roster-line">
{% endif %}
{% endif %}
{% set userFullName = roster.user.fullName|default('') %}
{% if currentUserId == null %}
{% set userFullName = userFullName|extract_name %}
{% endif %}
<td class="border px-4 py-2">{{ roster.date|date('Y-m-d') }}</td>
<td class="border px-4 py-2">{{ roster.time|date('H:i') }}</td>
<td class="border px-4 py-2">{{ roster.description }}</td>
<td class="border px-4 py-2">{{ userFullName }}</td>
{% if currentUserId != null %}
<td class="border px-4 py-2">
{% if lineInPast == false %}
{% if currentUserId != roster.user.id|default() %}
<form name="roster_form" method="post" action="{{ path('app_roster_update') }}">
<input type="hidden" name="roster_form[roster]" value="{{ roster.id }}">
<input type="hidden" name="roster_form[rosterAction]" value="{{ enum('App\\Enum\\RosterFormActionEnum::ActionClaimSlot').value }}">
<button type="submit" class="slot-button" id="claim-slot">
<i class="claim-icon claim-icon-plus fas fa-user-plus pr-5 has-tooltip">
<span class="tooltip-text">{{ 'Claim timeslot'| trans }}</span>
</i>
</button>
</form>
{% endif %}
{% if currentUserId == roster.user.id|default() %}
<form name="roster_form" class="form-as-icon" method="post" action="{{ path('app_roster_update') }}">
<input type="hidden" name="roster_form[roster]" value="{{ roster.id }}">
<input type="hidden" name="roster_form[rosterAction]" value="{{ enum('App\\Enum\\RosterFormActionEnum::ActionFreeSlot').value }}">
<button type="submit" class="slot-button" id="free-slot">
<i class="claim-icon claim-icon-minus fas fa-user-xmark pr-5 has-tooltip">
<span class="tooltip-text">{{ 'Remove claim'| trans }}</span>
</i>
</button>
</form>
{% endif %}
{% if currentUserId == roster.user.id|default() %}
<form name="roster_form" class="form-as-icon" method="post" action="{{ path('app_roster_update') }}">
<input type="hidden" name="roster_form[roster]" value="{{ roster.id }}">
<input type="hidden" name="roster_form[rosterAction]" value="{{ enum('App\\Enum\\RosterFormActionEnum::ActionResetSlot').value }}">
<button type="submit" class="slot-button" id="reset-slot">
<i class="claim-icon claim-icon-clock fas fa-user-clock has-tooltip">
<span class="tooltip-text">{{ 'Reset claim to previous'| trans }}</span>
</i>
</button>
</form>
{% endif %}
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,37 @@
{% extends "base.html.twig" %}
{% block body %}
<header class="header-login">
<h1 class="text-3xl">CGK Leerdam - rooster streamen</h1>
</header>
<div class="flex justify-center items-center h-screen">
<div id="login_card">
<div class="flash-message">
{% if error %}
<div class="alert alert-error">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
</div>
<form class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" action="{{ path('app_login') }}" method="post">
<div class="mb-4">
<label class="block text-gray-700 font-bold mb-2" for="token">
{{ 'Please enter your token here.'|trans }}
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="token" name="token" type="password" placeholder="Token">
</div>
<div class="flex items-center justify-between">
<button class="submit-button" type="submit">
Login
</button>
</div>
<div class="text-gray-700 pt-6">
{{ 'Or'|trans }} <a href="{{ path('app_roster_view_anon') }}" class="font-bold hover:text-blue-200 underline">
{{ 'checkout the roster directly'|trans }}
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,16 @@
Please enter your token here.: Please enter your token here.
Logout: Logout
Login: Login
Date: Date
Time: Time
Description: Description
Streamer: Streamer
Claim timeslot: Claim this timeslot
Remove claim: Remove claim
Reset claim to previous: Reset claim
Something went wrong, contact the administrator.: Something went wrong, contact the administrator.
roster_update_success: Roster update for %date% %time% was successful.
roster_slot_freed: Roster slot freed for %date% %time%.
roster_slot_reset: Roster slot reset for %date% %time%.
checkout the roster directly: checkout the roster directly
Or: Or

View File

@ -0,0 +1,16 @@
Please enter your token here.: Voer hier je token in.
Logout: Uitloggen
Login: Inloggen
Date: Datum
Time: Tijd
Description: Omschrijving
Streamer: Streamer
Claim timeslot: Claim dit tijdsslot
Remove claim: Verwijder jezelf van dit tijdsslot
Reset claim to previous: Herstel dit tijdsslot naar de vorige persoon
Something went wrong, contact the administrator.: Er is iets fout gegaan; neem contact op met de beheerder.
roster_update_success: Je hebt jezelf aangemeld voor %date% om %time% uur.
roster_slot_freed: De roosterregel voor %date% om %time% is vrijgegeven.
roster_slot_reset: De roosterregel voor %date% om %time% is hersteld naar de vorige persoon.
checkout the roster directly: bekijk het rooster.
Or: Of

View File

@ -0,0 +1,2 @@
Invalid credentials.: No valid token given.
Missing token.: You did not enter a token.

View File

@ -0,0 +1,2 @@
Invalid credentials.: Geen geldige token opgegeven.
Missing token.: Je hebt geen token ingevoerd.

52
webpack.config.js Normal file
View File

@ -0,0 +1,52 @@
const Encore = require('@symfony/webpack-encore');
Encore
// Directory where compiled assets will be stored
.setOutputPath('public/build/')
// Public path used by the web server to access the output path
.setPublicPath('/build')
// Only needed for CDN's or subdirectory deploy
//.setManifestKeyPrefix('build/')
/*
* ENTRY CONFIG
* Each entry will result in one JavaScript file (and one CSS file if your CSS is imported into JavaScript)
*/
.addEntry('app', './assets/app.js')
.addEntry('roster', './assets/roster.js')
// When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
.splitEntryChunks()
// Will require an extra script tag for runtime.js but, you probably want this, for better caching
.enableSingleRuntimeChunk()
// Enables SourceMaps at dev time
.enableSourceMaps(!Encore.isProduction())
// Enables hashed filenames (e.g. app.abc123.css)
.enableVersioning(Encore.isProduction())
// Enables @babel/preset-env polyfills
.configureBabelPresetEnv((config) => {
config.useBuiltIns = 'usage';
config.corejs = 3;
})
// Enables Vue.js support
//.enableVueLoader()
// Enables Sass/SCSS support
//.enableSassLoader()
// Uncomment if you use TypeScript
//.enableTypeScriptLoader()
// Uncomment if you want to use React
//.enableReactPreset()
// Enables PostCSS support
.enablePostCssLoader()
;
module.exports = Encore.getWebpackConfig();