diff --git a/.gitignore b/.gitignore index 4a5f07e..88a3f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /.env.*.local /config/secrets/prod/prod.decrypt.private.php /public/bundles/ +/public/uploads/qr-logos/ /var/ /vendor/ ###< symfony/framework-bundle ### diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ef6fc..8d9aa80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +[PR-41](https://github.com/itk-dev/itqr/pull/41) + - Added archived concept +[PR-40](https://github.com/itk-dev/itqr/pull/40) + - Added styling to static page. +[PR-39](https://github.com/itk-dev/itqr/pull/39) + - Added various help texts and in-platform-documentation. +[PR-38](https://github.com/itk-dev/itqr/pull/38) + - Limit number of URLs to 1 + - Increase URL data type from varchar to text +[PR-37](https://github.com/itk-dev/itqr/pull/37) + - Rename "theme" to "design" in general + - Rename "delete" to "archive" in general +[PR-36](https://github.com/itk-dev/itqr/pull/36) + - Fixed issue with set url + - Batch set url only for admins +[PR-35](https://github.com/itk-dev/itqr/pull/35) + - Removed redirect popup for batch download [PR-33](https://github.com/itk-dev/itqr/pull/33) - Fixes and Tidy feedback [PR-30](https://github.com/itk-dev/itqr/pull/30) diff --git a/assets/app.js b/assets/app.js index 851be31..4e8f699 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,17 +1,21 @@ import './styles/app.css'; const uploadBasePath = 'uploads/qr_codes/'; + document.addEventListener('DOMContentLoaded', () => { + // Enforce only one URL per QR code. + handleQrUrlCollection(); + const qrCodeContainer = document.getElementById('qrCodeContainer'); const tabsContainer = document.getElementById('qrCodeTabs'); // Navigation tabs const tabContentContainer = document.getElementById('qrCodeTabContent'); // Tab content const form = document.querySelector('.form-wrapper form'); - const formName = form.getAttribute('name'); + const formName = form ? form.getAttribute('name') : ''; const selectedQrCodes = document.getElementById('selectedQrCodes'); // Ensure all containers and elements exist if (!qrCodeContainer || !tabsContainer || !tabContentContainer || !form) { - console.error('Required elements not found!'); + console.log('Required elements not found'); return; } @@ -188,3 +192,51 @@ document.addEventListener('DOMContentLoaded', () => { }); updateQRCode(); }); + +function handleBatchDisableConfirm() { + document.querySelectorAll('.disable-confirm').forEach(actionBtn => { + actionBtn.addEventListener('click', function() { + let modal = document.getElementById('modal-batch-action'); + document.querySelector('.modal-backdrop').classList.add('invisible'); + modal.classList.add('invisible'); + modal.querySelector('#modal-batch-action-button').click(); + }); + }); +} + +function handleQrUrlCollection() { + const qrUrlCollectionParent = document.querySelector('.qr-urls-collection'); + const qrUrlCollectionAddButton = document.querySelector('.qr-urls-collection .field-collection-add-button'); + const urlCollectionCount = qrUrlCollectionParent ? parseInt(qrUrlCollectionParent.getAttribute('data-num-items')) : null; + + // If the button exists, and no URL is added - add it! + if (qrUrlCollectionAddButton) { + setTimeout(() => { + if (urlCollectionCount !== null && urlCollectionCount === 0) { + qrUrlCollectionAddButton.click(); + } + }, 1) + + // Hide the add-button if there is already an URL added. + if (parseInt(urlCollectionCount) === 1) { + qrUrlCollectionAddButton.classList.add('d-none'); + } + + // Handle the click event and hide add-button if an URL was added and the number of URLs is 1. + qrUrlCollectionAddButton.addEventListener('click', () => { + setTimeout(() => { + const urlCollectionCount = qrUrlCollectionParent.getAttribute('data-num-items'); + + if (parseInt(urlCollectionCount) === 1) { + qrUrlCollectionAddButton.classList.add('d-none'); + } + }, 1) + }); + } +} + +document.addEventListener('readystatechange', function(event) { + if ('complete' === document.readyState) { + handleBatchDisableConfirm(); + } +}); diff --git a/assets/styles/app.css b/assets/styles/app.css index 63dd16f..49a2efe 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -101,3 +101,8 @@ form[name="batch_download"] .accordion-button { background-color: #fff; border-bottom: 1px solid #e9e9e9; } + + +.qr-urls-collection .accordion-button { + display: none; +} diff --git a/assets/styles/archived.css b/assets/styles/archived.css new file mode 100644 index 0000000..a31b602 --- /dev/null +++ b/assets/styles/archived.css @@ -0,0 +1,162 @@ +/*!*****************************************************************************************************************!*\ + !*** css ./node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[1].oneOf[1].use[1]!./assets/styles/static.css ***! + \*****************************************************************************************************************/ +:root { + --spacer-md: 1rem; + --spacer-lg: calc(var(--spacer-md) * 2); + --shadow-color: 240deg 27% 62%; + + /* Archived */ + --shadow-color: 40deg 100% 90%; + --shadow-elevation-low: + 0.3px 0.3px 0.4px hsl(var(--shadow-color) / 0.34), + 0.4px 0.4px 0.6px -1.7px hsl(var(--shadow-color) / 0.28), + 0.9px 1px 1.3px -3.5px hsl(var(--shadow-color) / 0.21); + --shadow-elevation-medium: + 0.3px 0.3px 0.4px hsl(var(--shadow-color) / 0.29), + 0.5px 0.6px 0.8px -0.9px hsl(var(--shadow-color) / 0.25), + 1px 1.2px 1.5px -1.7px hsl(var(--shadow-color) / 0.22), + 2.2px 2.6px 3.3px -2.6px hsl(var(--shadow-color) / 0.19), + 4.5px 5.2px 6.7px -3.5px hsl(var(--shadow-color) / 0.16); + --shadow-elevation-high: + 0.3px 0.3px 0.4px hsl(var(--shadow-color) / 0.27), + 0.7px 0.8px 1px -0.4px hsl(var(--shadow-color) / 0.25), + 1.2px 1.4px 1.8px -0.8px hsl(var(--shadow-color) / 0.24), + 1.9px 2.2px 2.8px -1.2px hsl(var(--shadow-color) / 0.22), + 2.9px 3.4px 4.4px -1.6px hsl(var(--shadow-color) / 0.21), + 4.5px 5.2px 6.7px -1.9px hsl(var(--shadow-color) / 0.19), + 6.7px 7.8px 10px -2.3px hsl(var(--shadow-color) / 0.18), + 9.7px 11.3px 14.5px -2.7px hsl(var(--shadow-color) / 0.16), + 13.6px 16px 20.5px -3.1px hsl(var(--shadow-color) / 0.14), + 18.7px 21.9px 28.1px -3.5px hsl(var(--shadow-color) / 0.13); +} +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + } +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; + font-family: "Roboto", sans-serif; + background-color: ghostwhite; + + /* Archived */ + + background-color: #fff7e7; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +p { + text-wrap: pretty; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + text-wrap: balance; +} + +#root, +#__next { + isolation: isolate; +} + +.container { + background-color: #fff; + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 800px; + margin: 5vh auto; + padding: 0 var(--spacer-md); + + > h1 { + font-size: clamp(1.75rem, 4.5vw, 3rem); + margin: var(--spacer-md) 0; + } + + > p { + } + + /* Archived */ + + min-height: auto; + padding: var(--spacer-lg); + text-align: center; + margin: 5vh var(--spacer-md); + border-radius: var(--spacer-lg); + box-shadow: var(--shadow-elevation-high); + + .qr-code-image { + margin: var(--spacer-md) auto; + max-width: 100%; + height: auto; + width: 25vw; + min-width: 150px; + } + + .btn { + text-decoration: 0 underline; + font-weight: bold; + color: black; + transition: text-decoration 0.1s ease-in; + + &:hover { + text-underline-offset: 2px; + text-decoration-thickness: 3px; + transition: text-decoration 0.2s ease-out; + } + } +} + +@media screen and (min-width: 800px) { + .container { + padding: 0 var(--spacer-lg); + + /* Archived */ + padding: var(--spacer-lg); + margin: 5vh auto; + } +} + +/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3RhdGljLmNzcyIsIm1hcHBpbmdzIjoiOzs7QUFBQTtJQUNJLGlCQUFpQjtJQUNqQix1Q0FBdUM7SUFDdkMsOEJBQThCO0lBQzlCOzs7a0VBRzhEO0lBQzlEOzs7OztvRUFLZ0U7SUFDaEU7Ozs7Ozs7Ozs7dUVBVW1FO0FBQ3ZFO0FBQ0E7SUFDSSxzQkFBc0I7QUFDMUI7O0FBRUE7SUFDSSxTQUFTO0FBQ2I7O0FBRUE7SUFDSTtRQUNJLGdDQUFnQztJQUNwQztBQUNKOztBQUVBO0lBQ0ksZ0JBQWdCO0lBQ2hCLG1DQUFtQztJQUNuQyxpQ0FBaUM7SUFDakMsNEJBQTRCO0FBQ2hDOztBQUVBO0lBQ0ksY0FBYztJQUNkLGVBQWU7QUFDbkI7O0FBRUE7SUFDSSxhQUFhO0FBQ2pCOztBQUVBO0lBQ0kseUJBQXlCO0FBQzdCOztBQUVBO0lBQ0ksaUJBQWlCO0FBQ3JCOztBQUVBO0lBQ0ksa0JBQWtCO0FBQ3RCOztBQUVBO0lBQ0ksa0JBQWtCO0FBQ3RCOztBQUVBO0lBQ0ksc0JBQXNCO0lBQ3RCLGFBQWE7SUFDYixzQkFBc0I7SUFDdEIsaUJBQWlCO0lBQ2pCLGdCQUFnQjtJQUNoQixjQUFjO0lBQ2QsMkJBQTJCO0lBQzNCO1FBQ0ksc0NBQXNDO1FBQ3RDLDBCQUEwQjtJQUM5Qjs7SUFFQTs7SUFFQTtBQUNKOztBQUVBO0lBQ0k7UUFDSSwyQkFBMkI7SUFDL0I7QUFDSiIsInNvdXJjZXMiOlsid2VicGFjazovLy8uL2Fzc2V0cy9zdHlsZXMvc3RhdGljLmNzcyJdLCJzb3VyY2VzQ29udGVudCI6WyI6cm9vdCB7XG4gICAgLS1zcGFjZXItbWQ6IDFyZW07XG4gICAgLS1zcGFjZXItbGc6IGNhbGModmFyKC0tc3BhY2VyLW1kKSAqIDIpO1xuICAgIC0tc2hhZG93LWNvbG9yOiAyNDBkZWcgMjclIDYyJTtcbiAgICAtLXNoYWRvdy1lbGV2YXRpb24tbG93OlxuICAgICAgICAgICAgMC4zcHggMC4zcHggMC40cHggaHNsKHZhcigtLXNoYWRvdy1jb2xvcikgLyAwLjM0KSxcbiAgICAgICAgICAgIDAuNHB4IDAuNHB4IDAuNnB4IC0xLjdweCBoc2wodmFyKC0tc2hhZG93LWNvbG9yKSAvIDAuMjgpLFxuICAgICAgICAgICAgMC45cHggMXB4IDEuM3B4IC0zLjVweCBoc2wodmFyKC0tc2hhZG93LWNvbG9yKSAvIDAuMjEpO1xuICAgIC0tc2hhZG93LWVsZXZhdGlvbi1tZWRpdW06XG4gICAgICAgICAgICAwLjNweCAwLjNweCAwLjRweCBoc2wodmFyKC0tc2hhZG93LWNvbG9yKSAvIDAuMjkpLFxuICAgICAgICAgICAgMC41cHggMC42cHggMC44cHggLTAuOXB4IGhzbCh2YXIoLS1zaGFkb3ctY29sb3IpIC8gMC4yNSksXG4gICAgICAgICAgICAxcHggMS4ycHggMS41cHggLTEuN3B4IGhzbCh2YXIoLS1zaGFkb3ctY29sb3IpIC8gMC4yMiksXG4gICAgICAgICAgICAyLjJweCAyLjZweCAzLjNweCAtMi42cHggaHNsKHZhcigtLXNoYWRvdy1jb2xvcikgLyAwLjE5KSxcbiAgICAgICAgICAgIDQuNXB4IDUuMnB4IDYuN3B4IC0zLjVweCBoc2wodmFyKC0tc2hhZG93LWNvbG9yKSAvIDAuMTYpO1xuICAgIC0tc2hhZG93LWVsZXZhdGlvbi1oaWdoOlxuICAgICAgICAgICAgMC4zcHggMC4zcHggMC40cHggaHNsKHZhcigtLXNoYWRvdy1jb2xvcikgLyAwLjI3KSxcbiAgICAgICAgICAgIDAuN3B4IDAuOHB4IDFweCAtMC40cHggaHNsKHZhcigtLXNoYWRvdy1jb2xvcikgLyAwLjI1KSxcbiAgICAgICAgICAgIDEuMnB4IDEuNHB4IDEuOHB4IC0wLjhweCBoc2wodmFyKC0tc2hhZG93LWNvbG9yKSAvIDAuMjQpLFxuICAgICAgICAgICAgMS45cHggMi4ycHggMi44cHggLTEuMnB4IGhzbCh2YXIoLS1zaGFkb3ctY29sb3IpIC8gMC4yMiksXG4gICAgICAgICAgICAyLjlweCAzLjRweCA0LjRweCAtMS42cHggaHNsKHZhcigtLXNoYWRvdy1jb2xvcikgLyAwLjIxKSxcbiAgICAgICAgICAgIDQuNXB4IDUuMnB4IDYuN3B4IC0xLjlweCBoc2wodmFyKC0tc2hhZG93LWNvbG9yKSAvIDAuMTkpLFxuICAgICAgICAgICAgNi43cHggNy44cHggMTBweCAtMi4zcHggaHNsKHZhcigtLXNoYWRvdy1jb2xvcikgLyAwLjE4KSxcbiAgICAgICAgICAgIDkuN3B4IDExLjNweCAxNC41cHggLTIuN3B4IGhzbCh2YXIoLS1zaGFkb3ctY29sb3IpIC8gMC4xNiksXG4gICAgICAgICAgICAxMy42cHggMTZweCAyMC41cHggLTMuMXB4IGhzbCh2YXIoLS1zaGFkb3ctY29sb3IpIC8gMC4xNCksXG4gICAgICAgICAgICAxOC43cHggMjEuOXB4IDI4LjFweCAtMy41cHggaHNsKHZhcigtLXNoYWRvdy1jb2xvcikgLyAwLjEzKTtcbn1cbiosICo6OmJlZm9yZSwgKjo6YWZ0ZXIge1xuICAgIGJveC1zaXppbmc6IGJvcmRlci1ib3g7XG59XG5cbioge1xuICAgIG1hcmdpbjogMDtcbn1cblxuQG1lZGlhIChwcmVmZXJzLXJlZHVjZWQtbW90aW9uOiBuby1wcmVmZXJlbmNlKSB7XG4gICAgaHRtbCB7XG4gICAgICAgIGludGVycG9sYXRlLXNpemU6IGFsbG93LWtleXdvcmRzO1xuICAgIH1cbn1cblxuYm9keSB7XG4gICAgbGluZS1oZWlnaHQ6IDEuNTtcbiAgICAtd2Via2l0LWZvbnQtc21vb3RoaW5nOiBhbnRpYWxpYXNlZDtcbiAgICBmb250LWZhbWlseTogJ1JvYm90bycsIHNhbnMtc2VyaWY7XG4gICAgYmFja2dyb3VuZC1jb2xvcjogZ2hvc3R3aGl0ZTtcbn1cblxuaW1nLCBwaWN0dXJlLCB2aWRlbywgY2FudmFzLCBzdmcge1xuICAgIGRpc3BsYXk6IGJsb2NrO1xuICAgIG1heC13aWR0aDogMTAwJTtcbn1cblxuaW5wdXQsIGJ1dHRvbiwgdGV4dGFyZWEsIHNlbGVjdCB7XG4gICAgZm9udDogaW5oZXJpdDtcbn1cblxucCwgaDEsIGgyLCBoMywgaDQsIGg1LCBoNiB7XG4gICAgb3ZlcmZsb3ctd3JhcDogYnJlYWstd29yZDtcbn1cblxucCB7XG4gICAgdGV4dC13cmFwOiBwcmV0dHk7XG59XG5cbmgxLCBoMiwgaDMsIGg0LCBoNSwgaDYge1xuICAgIHRleHQtd3JhcDogYmFsYW5jZTtcbn1cblxuI3Jvb3QsICNfX25leHQge1xuICAgIGlzb2xhdGlvbjogaXNvbGF0ZTtcbn1cblxuLmNvbnRhaW5lciB7XG4gICAgYmFja2dyb3VuZC1jb2xvcjogI2ZmZjtcbiAgICBkaXNwbGF5OiBmbGV4O1xuICAgIGZsZXgtZGlyZWN0aW9uOiBjb2x1bW47XG4gICAgbWluLWhlaWdodDogMTAwdmg7XG4gICAgbWF4LXdpZHRoOiA4MDBweDtcbiAgICBtYXJnaW46IDAgYXV0bztcbiAgICBwYWRkaW5nOiAwIHZhcigtLXNwYWNlci1tZCk7XG4gICAgPiBoMSB7XG4gICAgICAgIGZvbnQtc2l6ZTogY2xhbXAoMS43NXJlbSwgNC41dncsIDNyZW0pO1xuICAgICAgICBtYXJnaW46IHZhcigtLXNwYWNlci1tZCkgMDtcbiAgICB9XG5cbiAgICA+IHAge1xuXG4gICAgfVxufVxuXG5AbWVkaWEgc2NyZWVuIGFuZCAobWluLXdpZHRoOiA4MDBweCkge1xuICAgIC5jb250YWluZXIge1xuICAgICAgICBwYWRkaW5nOiAwIHZhcigtLXNwYWNlci1sZyk7XG4gICAgfVxufVxuIl0sIm5hbWVzIjpbXSwic291cmNlUm9vdCI6IiJ9*/ diff --git a/assets/styles/static.css b/assets/styles/static.css new file mode 100644 index 0000000..7a57f5e --- /dev/null +++ b/assets/styles/static.css @@ -0,0 +1,95 @@ +:root { + --spacer-md: 1rem; + --spacer-lg: calc(var(--spacer-md) * 2); + --shadow-color: 240deg 27% 62%; + --shadow-elevation-low: + 0.3px 0.3px 0.4px hsl(var(--shadow-color) / 0.34), + 0.4px 0.4px 0.6px -1.7px hsl(var(--shadow-color) / 0.28), + 0.9px 1px 1.3px -3.5px hsl(var(--shadow-color) / 0.21); + --shadow-elevation-medium: + 0.3px 0.3px 0.4px hsl(var(--shadow-color) / 0.29), + 0.5px 0.6px 0.8px -0.9px hsl(var(--shadow-color) / 0.25), + 1px 1.2px 1.5px -1.7px hsl(var(--shadow-color) / 0.22), + 2.2px 2.6px 3.3px -2.6px hsl(var(--shadow-color) / 0.19), + 4.5px 5.2px 6.7px -3.5px hsl(var(--shadow-color) / 0.16); + --shadow-elevation-high: + 0.3px 0.3px 0.4px hsl(var(--shadow-color) / 0.27), + 0.7px 0.8px 1px -0.4px hsl(var(--shadow-color) / 0.25), + 1.2px 1.4px 1.8px -0.8px hsl(var(--shadow-color) / 0.24), + 1.9px 2.2px 2.8px -1.2px hsl(var(--shadow-color) / 0.22), + 2.9px 3.4px 4.4px -1.6px hsl(var(--shadow-color) / 0.21), + 4.5px 5.2px 6.7px -1.9px hsl(var(--shadow-color) / 0.19), + 6.7px 7.8px 10px -2.3px hsl(var(--shadow-color) / 0.18), + 9.7px 11.3px 14.5px -2.7px hsl(var(--shadow-color) / 0.16), + 13.6px 16px 20.5px -3.1px hsl(var(--shadow-color) / 0.14), + 18.7px 21.9px 28.1px -3.5px hsl(var(--shadow-color) / 0.13); +} +*, *::before, *::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + } +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; + font-family: 'Roboto', sans-serif; + background-color: ghostwhite; +} + +img, picture, video, canvas, svg { + display: block; + max-width: 100%; +} + +input, button, textarea, select { + font: inherit; +} + +p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; +} + +p { + text-wrap: pretty; +} + +h1, h2, h3, h4, h5, h6 { + text-wrap: balance; +} + +#root, #__next { + isolation: isolate; +} + +.container { + background-color: #fff; + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 800px; + margin: 0 auto; + padding: 0 var(--spacer-md); + > h1 { + font-size: clamp(1.75rem, 4.5vw, 3rem); + margin: var(--spacer-md) 0; + } + + > p { + + } +} + +@media screen and (min-width: 800px) { + .container { + padding: 0 var(--spacer-lg); + } +} diff --git a/config/packages/security.yaml b/config/packages/security.yaml index ec28fb1..95ccf17 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -13,6 +13,9 @@ security: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false + api: + pattern: ^/api + stateless: true main: custom_authenticators: - App\Security\AzureOIDCAuthenticator diff --git a/config/routes.yaml b/config/routes.yaml index 41ef814..b4d3326 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -3,3 +3,9 @@ controllers: path: ../src/Controller/ namespace: App\Controller type: attribute +root_redirect: + path: / + controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController + defaults: + route: 'admin' + permanent: true diff --git a/migrations/Version20250905124617.php b/migrations/Version20250905124617.php new file mode 100644 index 0000000..a2dfd16 --- /dev/null +++ b/migrations/Version20250905124617.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE qr ADD alternative_url VARCHAR(255) DEFAULT NULL, ADD status VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE url CHANGE url url TEXT NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE url CHANGE url url VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE qr DROP alternative_url, DROP status'); + } +} diff --git a/public/img/qrcode-archived.png b/public/img/qrcode-archived.png new file mode 100644 index 0000000..3529539 Binary files /dev/null and b/public/img/qrcode-archived.png differ diff --git a/src/Controller/Admin/BatchDownloadController.php b/src/Controller/Admin/BatchDownloadController.php index 405d9aa..b262eff 100644 --- a/src/Controller/Admin/BatchDownloadController.php +++ b/src/Controller/Admin/BatchDownloadController.php @@ -5,11 +5,13 @@ use App\Form\Type\BatchDownloadType; use App\Helper\DownloadHelper; use App\Repository\QrRepository; +use GuzzleHttp\Utils; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -final class BatchDownloadController extends DashboardController +final class BatchDownloadController extends AbstractController { public function __construct( private readonly RequestStack $requestStack, @@ -22,10 +24,16 @@ public function __construct( * @throws \Endroid\QrCode\Exception\ValidationException */ #[Route('/admin/batch/download', name: 'admin_batch_download')] - public function index(): Response + public function batchDownload(string|array $selectedEntityIds): Response { + /* + If this method is called from the crud context menu and only regards a single item, + selectedEntityIds is a string. Convert to array to compatibilize. + */ + if (!is_array($selectedEntityIds)) { + $selectedEntityIds = [$selectedEntityIds]; + } $form = $this->createForm(BatchDownloadType::class); - $request = $this->requestStack->getCurrentRequest(); $form->handleRequest($request); @@ -33,7 +41,7 @@ public function index(): Response if ($form->isSubmitted() && $form->isValid()) { $data = $form->getData(); - $qrEntities = $this->qrRepository->findBy(['id' => $request->query->all()]); + $qrEntities = $this->qrRepository->findBy(['id' => $selectedEntityIds]); if (!$qrEntities) { throw $this->createNotFoundException('No QR codes found'); @@ -44,8 +52,8 @@ public function index(): Response return $this->render('form/batchDownload.html.twig', [ 'form' => $form, - 'selectedQrCodes' => json_encode($request->query->all()), - 'count' => count($request->query->all()), + 'selectedQrCodes' => Utils::jsonEncode($selectedEntityIds), + 'count' => count($selectedEntityIds), ]); } } diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index ec392d0..35cd425 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -16,7 +16,15 @@ class DashboardController extends AbstractDashboardController #[Route('/admin', name: 'admin')] public function index(): Response { - return $this->render('@EasyAdmin/page/content.html.twig'); + // Redirect to the qr crud page until dashboad functionality is implemented + return $this->redirectToRoute('qr_index', [ + 'filters' => [ + 'status' => [ + 'comparison' => '=', + 'value' => 'ACTIVE', + ], + ], + ]); } public function configureDashboard(): Dashboard @@ -26,7 +34,6 @@ public function configureDashboard(): Dashboard ->setFaviconPath('favicon.svg') ->renderContentMaximized() ->disableDarkMode() - ->generateRelativeUrls() ->setLocales([ 'da' => 'Dansk', 'en' => 'English', @@ -35,7 +42,9 @@ public function configureDashboard(): Dashboard public function configureMenuItems(): iterable { - yield MenuItem::linkToCrud(new TranslatableMessage('QR Codes'), 'fa fa-qrcode', Qr::class); - yield MenuItem::linkToCrud(new TranslatableMessage('QR Themes'), 'fa fa-palette', QrVisualConfig::class); + yield MenuItem::linkToCrud(new TranslatableMessage('menu.qr'), 'fa fa-qrcode', Qr::class) + ->setQueryParameter('filters[status][comparison]', '=') + ->setQueryParameter('filters[status][value]', 'ACTIVE'); + yield MenuItem::linkToCrud(new TranslatableMessage('menu.designs'), 'fa fa-palette', QrVisualConfig::class); } } diff --git a/src/Controller/Admin/QrArchiveController.php b/src/Controller/Admin/QrArchiveController.php new file mode 100644 index 0000000..02c1666 --- /dev/null +++ b/src/Controller/Admin/QrArchiveController.php @@ -0,0 +1,84 @@ +createForm(QrArchiveType::class); + $request = $this->requestStack->getCurrentRequest(); + + // Get the QR entity first + $qrEntity = $this->qrRepository->find($id); + if (!$qrEntity) { + throw $this->createNotFoundException('No QR code found'); + } + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $alternativeUrl = $form->get('alternativeUrl')->getData(); + $response = $this->qrHelper->archive($qrEntity, $alternativeUrl); + + $message = json_decode($response->getContent(), true)['message']; + $this->addFlash('success', $message); + + return $this->redirectToRoute('qr_index', [ + 'filters' => [ + 'status' => [ + 'comparison' => '=', + 'value' => 'ACTIVE', + ], + ], + ]); + } + + return $this->render('form/qrArchive.html.twig', [ + 'form' => $form, + ]); + } + + #[Route('/admin/unarchive', name: 'admin_qr_unarchive')] + public function unarchive(int $id): Response + { + $qrEntity = $this->qrRepository->find($id); + if (!$qrEntity) { + throw $this->createNotFoundException('No QR code found'); + } + $response = $this->qrHelper->unarchive($qrEntity); + + $message = json_decode($response->getContent(), true)['message']; + $this->addFlash('success', $message); + + return $this->redirectToRoute('qr_index', [ + 'filters' => [ + 'status' => [ + 'comparison' => '=', + 'value' => 'ACTIVE', + ], + ], + ]); + } +} diff --git a/src/Controller/Admin/QrCrudController.php b/src/Controller/Admin/QrCrudController.php index 45b579c..e274595 100644 --- a/src/Controller/Admin/QrCrudController.php +++ b/src/Controller/Admin/QrCrudController.php @@ -4,6 +4,7 @@ use App\Controller\Admin\Embed\UrlCrudController; use App\Entity\Tenant\Qr; +use App\Enum\QrStatusEnum; use App\Helper\DownloadHelper; use App\Repository\QrHitTrackerRepository; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; @@ -21,10 +22,13 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextEditorField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; +use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; use EasyCorp\Bundle\EasyAdminBundle\Filter\ChoiceFilter; +use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; use Endroid\QrCode\Exception\ValidationException; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Translation\TranslatableMessage; /** @@ -32,9 +36,11 @@ */ class QrCrudController extends AbstractTenantAwareCrudController { + #[Route('/admin/qr', name: 'qr_index')] public function __construct( private readonly DownloadHelper $downloadHelper, private readonly QrHitTrackerRepository $hitTrackerRepository, + private readonly AdminUrlGenerator $adminUrlGenerator, ) { } @@ -46,7 +52,11 @@ public static function getEntityFqcn(): string public function configureCrud(Crud $crud): Crud { return $crud - ->setDefaultSort(['modifiedAt' => 'DESC']); + ->setDefaultSort(['modifiedAt' => 'DESC']) + ->setPageTitle('index', new TranslatableMessage('qr.index.label')) + ->setEntityLabelInSingular(new TranslatableMessage('qr.label_singular')) + ->overrideTemplate('crud/index', 'admin/qr/index.html.twig') + ->setSearchFields(null); } public function new(AdminContext $context) @@ -58,15 +68,18 @@ public function configureFields(string $pageName): iterable { if (Crud::PAGE_INDEX === $pageName) { return [ - TextField::new('title', new TranslatableMessage('qr.title')), - TextEditorField::new('description', new TranslatableMessage('qr.description')), - CollectionField::new('urls', new TranslatableMessage('qr.urls')) + TextField::new('title', new TranslatableMessage('qr.title.label')) + ->setTemplatePath('fields/link/link_to_edit.html.twig'), + TextEditorField::new('description', new TranslatableMessage('qr.description.label')) + ->formatValue(fn ($value) => nl2br($value)), + CollectionField::new('urls', new TranslatableMessage('qr.url.label')) ->allowAdd() ->allowDelete() ->renderExpanded() ->useEntryCrudForm(UrlCrudController::class), - ChoiceField::new('mode', new TranslatableMessage('qr.mode.title')) + ChoiceField::new('mode', new TranslatableMessage('qr.mode.label')) ->renderAsNativeWidget(), + ChoiceField::new('status', new TranslatableMessage('qr.status.label')), Field::new('customUrlButton', new TranslatableMessage('qr.preview')) ->setTemplatePath('fields/link/link.html.twig') ->hideOnForm(), @@ -87,16 +100,23 @@ public function configureFields(string $pageName): iterable IdField::new('id', 'ID') ->setDisabled() ->hideOnForm(), - TextField::new('title', new TranslatableMessage('qr.title')), - ChoiceField::new('mode', new TranslatableMessage('qr.mode.title')) + TextField::new('title', new TranslatableMessage('qr.title.label')) + ->setHelp(new TranslatableMessage('qr.title.help')), + ChoiceField::new('mode', new TranslatableMessage('qr.mode.label')) ->setHelp(new TranslatableMessage('qr.mode.help')) ->renderAsNativeWidget(), - TextEditorField::new('description', new TranslatableMessage('qr.description')), - CollectionField::new('urls', new TranslatableMessage('qr.urls')) + TextEditorField::new('description', new TranslatableMessage('qr.description.label')) + ->setHelp(new TranslatableMessage('qr.description.help')), + CollectionField::new('urls', new TranslatableMessage('qr.url.label')) ->allowAdd() - ->allowDelete() - ->renderExpanded() - ->useEntryCrudForm(UrlCrudController::class), + ->allowDelete(false) + ->renderExpanded(true) + ->useEntryCrudForm(UrlCrudController::class) + ->addCssClass('qr-urls-collection') + ->setHelp(new TranslatableMessage('qr.url.help')), + UrlField::new('alternativeUrl', new TranslatableMessage('qr.alternativeUrl.label')) + ->setRequired(false) + ->setHelp(new TranslatableMessage('qr.alternativeUrl.help')), ]; } @@ -105,16 +125,20 @@ public function configureFields(string $pageName): iterable IdField::new('id', 'ID') ->setDisabled() ->hideOnForm(), - TextField::new('title', new TranslatableMessage('qr.title')), - ChoiceField::new('mode', new TranslatableMessage('qr.mode.title')) + TextField::new('title', new TranslatableMessage('qr.title.label')) + ->setHelp(new TranslatableMessage('qr.title.help')), + ChoiceField::new('mode', new TranslatableMessage('qr.mode.label')) ->setHelp(new TranslatableMessage('qr.mode.help')) ->renderAsNativeWidget(), - TextEditorField::new('description', new TranslatableMessage('qr.description')), - CollectionField::new('urls', new TranslatableMessage('qr.urls')) + TextEditorField::new('description', new TranslatableMessage('qr.description.label')) + ->setHelp(new TranslatableMessage('qr.description.help')), + CollectionField::new('urls', new TranslatableMessage('qr.url.label')) ->allowAdd() - ->allowDelete() + ->allowDelete(false) + ->useEntryCrudForm(UrlCrudController::class) ->renderExpanded() - ->useEntryCrudForm(UrlCrudController::class), + ->addCssClass('qr-urls-collection') + ->setHelp(new TranslatableMessage('qr.url.help')), ]; } @@ -130,6 +154,12 @@ public function configureFilters(Filters $filters): Filters ->add(ChoiceFilter::new('department') ->setChoices(['a', 'b']) ) + ->add(ChoiceFilter::new('status') + ->setChoices([ + 'ACTIVE' => QrStatusEnum::ACTIVE->value, + 'ARCHIVED' => QrStatusEnum::ARCHIVED->value, + ]) + ) ->add('title') ->add('description'); } @@ -139,32 +169,65 @@ public function configureActions(Actions $actions): Actions // Define batch download action $batchDownloadAction = Action::new('download', new TranslatableMessage('qr.configure_download')) ->linkToCrudAction('batchDownload') - ->addCssClass('btn btn-success') + ->addCssClass('btn btn-success disable-confirm') ->setIcon('fa fa-download') ->displayAsButton(); // Define single download action - $singleDownloadAction = Action::new('quickDownload', new TranslatableMessage('qr.quick_download')) - ->linkToCrudAction('quickDownload') + $singleDownloadActionNoConfig = Action::new('downloadWithoutConfig', new TranslatableMessage('qr.quick_download_without_config')) + ->linkToCrudAction('downloadWithoutConfig') + ->setIcon('fa fa-download'); + $singleDownloadActionConfig = Action::new('downloadWithConfig', new TranslatableMessage('qr.quick_download_with_config')) + ->linkToRoute('admin_batch_download', function ($entity) { + return ['selectedEntityIds' => [$entity->getId()]]; + }) ->setIcon('fa fa-download'); // Define batch url change action $setUrlAction = Action::new('setUrl', new TranslatableMessage('qr.set_url')) - ->linkToCrudAction('setUrl') - ->addCssClass('btn btn-primary') + ->linkToCrudAction('batchSetUrl') + ->addCssClass('btn btn-primary disable-confirm') + ->displayIf(fn () => $this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_SUPER_ADMIN')) ->setIcon('fa fa-link'); + // Define archive action + $archiveAction = Action::new('archive', new TranslatableMessage('qr.archive.label')) + ->linkToRoute('admin_qr_archive', function ($entity) { + return ['id' => $entity->getId()]; + }) + ->setIcon('fa fa-archive') + ->addCssClass('text-danger') + ->displayIf(fn ($entity) => QrStatusEnum::ACTIVE === $entity->getStatus()); + + $unArchiveAction = Action::new('unArchive', new TranslatableMessage('qr.unarchive.label')) + ->linkToRoute('admin_qr_unarchive', function ($entity) { + return ['id' => $entity->getId()]; + }) + ->setIcon('fa fa-seedling') + ->displayIf(fn ($entity) => QrStatusEnum::ARCHIVED === $entity->getStatus()); + return $actions ->update(Crud::PAGE_INDEX, Action::EDIT, fn (Action $action) => $action->setIcon('fa fa-pencil')->setLabel('qr.edit')) - ->update(Crud::PAGE_INDEX, Action::DELETE, fn (Action $action) => $action->setIcon('fa fa-trash')->setLabel('qr.delete')) + ->remove(Crud::PAGE_INDEX, Action::DELETE) + ->add(Crud::PAGE_INDEX, $archiveAction) + ->add(Crud::PAGE_INDEX, $unArchiveAction) + ->add(Crud::PAGE_INDEX, $singleDownloadActionNoConfig) + ->add(Crud::PAGE_INDEX, $singleDownloadActionConfig) ->addBatchAction($batchDownloadAction) ->addBatchAction($setUrlAction) - ->add(Crud::PAGE_INDEX, $singleDownloadAction); + ->reorder(Crud::PAGE_INDEX, [ + 'downloadWithConfig', + 'downloadWithoutConfig', + 'edit', + ]); } - public function setUrl(BatchActionDto $batchActionDto): RedirectResponse + public function batchSetUrl(BatchActionDto $batchActionDto): RedirectResponse { - return $this->redirectToRoute('admin_set_url', $batchActionDto->getEntityIds()); + return $this->redirect($this->adminUrlGenerator + ->setRoute('admin_set_url', ['selectedEntityIds' => $batchActionDto->getEntityIds()]) + ->generateUrl() + ); } /** @@ -176,7 +239,7 @@ public function setUrl(BatchActionDto $batchActionDto): RedirectResponse * * @throws ValidationException */ - public function quickDownload(AdminContext $context): StreamedResponse + public function downloadWithoutConfig(AdminContext $context): StreamedResponse { $qrEntity = $context->getEntity()->getInstance(); @@ -193,7 +256,10 @@ public function quickDownload(AdminContext $context): StreamedResponse */ public function batchDownload(BatchActionDto $batchActionDto): RedirectResponse { - return $this->redirectToRoute('admin_batch_download', $batchActionDto->getEntityIds()); + return $this->redirect($this->adminUrlGenerator + ->setRoute('admin_batch_download', ['selectedEntityIds' => $batchActionDto->getEntityIds()]) + ->generateUrl() + ); } public function configureAssets(Assets $assets): Assets diff --git a/src/Controller/Admin/QrVisualConfigCrudController.php b/src/Controller/Admin/QrVisualConfigCrudController.php index 9d59db2..a6a1bf4 100644 --- a/src/Controller/Admin/QrVisualConfigCrudController.php +++ b/src/Controller/Admin/QrVisualConfigCrudController.php @@ -34,7 +34,10 @@ public function configureCrud(Crud $crud): Crud ->setPageTitle('edit', new TranslatableMessage('visual.edit')) ->setEntityLabelInSingular(new TranslatableMessage('visual.label_singular')) ->overrideTemplate('crud/edit', 'admin/qr_visual_config/edit.html.twig') - ->overrideTemplate('crud/new', 'admin/qr_visual_config/new.html.twig'); + ->overrideTemplate('crud/new', 'admin/qr_visual_config/new.html.twig') + ->setPageTitle('index', new TranslatableMessage('design.index.label')) + ->overrideTemplate('crud/index', 'admin/design/index.html.twig') + ->setSearchFields(null); } public function new(AdminContext $context) @@ -53,7 +56,7 @@ public function configureFields(string $pageName): iterable { if (Crud::PAGE_INDEX === $pageName) { return [ - TextField::new('name')->setLabel(new TranslatableMessage('qr.title')), + TextField::new('name')->setLabel(new TranslatableMessage('qr.title.label')), IntegerField::new('size')->setLabel(new TranslatableMessage('qr.size.label')), Field::new('customUrlButton', new TranslatableMessage('qr.preview')) ->setTemplatePath('fields/link/linkExample.html.twig') @@ -67,37 +70,42 @@ public function configureFields(string $pageName): iterable ->setFormTypeOption('mapped', false) ->setFormTypeOption('data', $this->getContext()->getEntity()->getInstance()->getId()), TextField::new('name') - ->setLabel(new TranslatableMessage('qr.title')) - ->setHelp(new TranslatableMessage('Name of the theme.')), + ->setLabel(new TranslatableMessage('design.name.label')) + ->setHelp(new TranslatableMessage('design.name.help')), IntegerField::new('size') - ->setLabel(new TranslatableMessage('qr.size.label')) - ->setHelp(new TranslatableMessage('qr.size.help')), + ->setLabel(new TranslatableMessage('design.size.label')) + ->setHelp(new TranslatableMessage('design.size.help')), IntegerField::new('margin') - ->setLabel(new TranslatableMessage('qr.margin.label')) - ->setHelp(new TranslatableMessage('qr.margin.help')), + ->setLabel(new TranslatableMessage('design.margin.label')) + ->setHelp(new TranslatableMessage('design.margin.help')), Field::new('backgroundColor') ->setFormType(ColorType::class) - ->setLabel(new TranslatableMessage('qr.code_background')), + ->setLabel(new TranslatableMessage('design.background_color.label')) + ->setHelp(new TranslatableMessage('design.background_color.help')), Field::new('foregroundColor') ->setFormType(ColorType::class) - ->setLabel(new TranslatableMessage('qr.code_color')), + ->setLabel(new TranslatableMessage('design.foreground_color.label')) + ->setHelp(new TranslatableMessage('design.foreground_color.help')), TextField::new('labelText') - ->setLabel(new TranslatableMessage('qr.text.label')) - ->setHelp(new TranslatableMessage('Label is a text that is displayed below the QR code.')) + ->setLabel(new TranslatableMessage('design.label_text.label')) + ->setHelp(new TranslatableMessage('design.label_text.help')) ->setRequired(false), Field::new('labelSize') - ->setLabel(new TranslatableMessage('qr.text.size')) - ->setHelp(new TranslatableMessage('Text size is the size of the label in pixels.')), + ->setLabel(new TranslatableMessage('design.label_size.label')) + ->setHelp(new TranslatableMessage('design.label_size.help')), Field::new('labelTextColor') ->setFormType(ColorType::class) - ->setLabel(new TranslatableMessage('qr.text.color')), + ->setLabel(new TranslatableMessage('design.label_text_color.label')) + ->setHelp(new TranslatableMessage('design.label_text_color.help')), Field::new('labelMarginTop') - ->setLabel(new TranslatableMessage('qr.text.margin.top.label')) - ->setHelp(new TranslatableMessage('qr.text.margin.top.help')), + ->setLabel(new TranslatableMessage('design.label_margin_top.label')) + ->setHelp(new TranslatableMessage('design.label_margin_top.help')), Field::new('labelMarginBottom') - ->setLabel(new TranslatableMessage('qr.text.margin.bottom.label')) - ->setHelp(new TranslatableMessage('qr.text.margin.bottom.help')), + ->setLabel(new TranslatableMessage('design.label_margin_bottom.label')) + ->setHelp(new TranslatableMessage('design.label_margin_bottom.help')), ImageField::new('logo') + ->setLabel(new TranslatableMessage('design.logo.label')) + ->setHelp(new TranslatableMessage('design.logo.help')) ->setBasePath('uploads/qr-logos') ->setUploadedFileNamePattern('[ulid]-[slug].[extension]') ->setUploadDir('public/uploads/qr-logos') @@ -105,8 +113,8 @@ public function configureFields(string $pageName): iterable 'required' => false, ]), ChoiceField::new('errorCorrectionLevel') - ->setLabel(new TranslatableMessage('error_correction.label')) - ->setHelp(new TranslatableMessage('error_correction.help')) + ->setLabel(new TranslatableMessage('design.error_correction_level.label')) + ->setHelp(new TranslatableMessage('design.error_correction_level.help')) ->setFormType(ChoiceType::class) ->setFormTypeOptions([ 'class' => ErrorCorrectionLevel::class, diff --git a/src/Controller/Admin/SetUrlController.php b/src/Controller/Admin/SetUrlController.php index ff0a940..db462ce 100644 --- a/src/Controller/Admin/SetUrlController.php +++ b/src/Controller/Admin/SetUrlController.php @@ -6,16 +6,15 @@ use App\Entity\Tenant\Url; use App\Form\SetUrlType; use Doctrine\ORM\EntityManagerInterface; -use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -final class SetUrlController extends DashboardController +final class SetUrlController extends AbstractController { public function __construct( - private EntityManagerInterface $entityManager, - private readonly AdminUrlGenerator $adminUrlGenerator, + private readonly EntityManagerInterface $entityManager, private readonly RequestStack $requestStack, ) { } @@ -24,7 +23,7 @@ public function __construct( * @todo add permission check here. */ #[Route('/admin/batch/set_url', name: 'admin_set_url')] - public function index(): Response + public function batchSetUrl(array $selectedEntityIds): Response { $form = $this->createForm(SetUrlType::class); @@ -35,7 +34,7 @@ public function index(): Response if ($form->isSubmitted() && $form->isValid()) { $data = $form->getData(); - foreach ($request->query->all() as $id) { + foreach ($selectedEntityIds as $id) { $qr = $this->entityManager->find(Qr::class, $id); // Remove all existing urls from the Qr code. @@ -52,18 +51,19 @@ public function index(): Response $this->entityManager->flush(); - // Create redirect url. - $redirectUrl = $this->adminUrlGenerator - ->setRoute('admin') - ->setController(QrCrudController::class) - ->setAction('index') - ->generateUrl(); - - return $this->redirect($redirectUrl); + return $this->redirectToRoute('qr_index', [ + 'filters' => [ + 'status' => [ + 'comparison' => '=', + 'value' => 'ACTIVE', + ], + ], + ]); } return $this->render('form/setUrl.html.twig', [ 'form' => $form, + 'count' => count($selectedEntityIds), ]); } } diff --git a/src/Controller/QrController.php b/src/Controller/QrController.php index ad63b98..f2d63c9 100644 --- a/src/Controller/QrController.php +++ b/src/Controller/QrController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Enum\QrStatusEnum; use App\Repository\QrRepository; use App\Service\QrService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -28,6 +29,13 @@ public function index(string $uuid): Response throw $this->createNotFoundException('QR code not found'); } + // Check if QR is archived + if (QrStatusEnum::ARCHIVED === $qr->getStatus()) { + return $this->render('archived.html.twig', [ + 'alternativeUrl' => $qr->getAlternativeUrl(), + ]); + } + try { $data = $this->qrService->handleQrResponse($qr); diff --git a/src/Entity/Tenant/Qr.php b/src/Entity/Tenant/Qr.php index bba8b39..91d09bb 100644 --- a/src/Entity/Tenant/Qr.php +++ b/src/Entity/Tenant/Qr.php @@ -8,6 +8,7 @@ use ApiPlatform\Metadata\ApiResource; use App\Entity\QrHitTracker; use App\Enum\QrModeEnum; +use App\Enum\QrStatusEnum; use App\Repository\QrRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -35,10 +36,17 @@ class Qr extends AbstractTenantScopedEntity #[ORM\Column(length: 2500, nullable: true)] private ?string $description = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $alternativeUrl = null; + #[ORM\Column(type: 'string', enumType: QrModeEnum::class)] #[ApiFilter(BackedEnumFilter::class)] private QrModeEnum $mode; + #[ORM\Column(type: 'string', enumType: QrStatusEnum::class)] + #[ApiFilter(BackedEnumFilter::class)] + private QrStatusEnum $status; + /** * @var Collection */ @@ -59,6 +67,7 @@ public function __construct() $this->urls = new ArrayCollection(); $this->hitTrackers = new ArrayCollection(); $this->uuid = Uuid::v7(); + $this->status = QrStatusEnum::ACTIVE; } public function getTitle(): ?string @@ -97,6 +106,18 @@ public function setDescription(?string $description): self return $this; } + public function getAlternativeUrl(): ?string + { + return $this->alternativeUrl; + } + + public function setAlternativeUrl(?string $alternativeUrl): self + { + $this->alternativeUrl = $alternativeUrl; + + return $this; + } + public function getMode(): ?QrModeEnum { return $this->mode; @@ -164,4 +185,16 @@ public function getHitTrackers(): Collection { return $this->hitTrackers; } + + public function getStatus(): QrStatusEnum + { + return $this->status; + } + + public function setStatus(QrStatusEnum $status): self + { + $this->status = $status; + + return $this; + } } diff --git a/src/Entity/Tenant/Url.php b/src/Entity/Tenant/Url.php index 3f3bb85..714ea2e 100644 --- a/src/Entity/Tenant/Url.php +++ b/src/Entity/Tenant/Url.php @@ -13,7 +13,7 @@ class Url extends AbstractTenantScopedEntity { #[Assert\NotBlank(message: new TranslatableMessage('The URL field cannot be empty.'))] #[Assert\Url(message: new TranslatableMessage('The value "{{ value }}" is not a valid URL.'))] - #[ORM\Column(length: 255)] + #[ORM\Column(type: 'text', length: 65535)] private string $url = ''; #[ORM\ManyToOne(targetEntity: Qr::class, inversedBy: 'urls')] diff --git a/src/Enum/QrStatusEnum.php b/src/Enum/QrStatusEnum.php new file mode 100644 index 0000000..875a9a1 --- /dev/null +++ b/src/Enum/QrStatusEnum.php @@ -0,0 +1,21 @@ + + */ + public static function getAsArray(): array + { + return array_reduce( + self::cases(), + static fn (array $choices, QrStatusEnum $type) => $choices + [$type->name => $type->value], + [], + ); + } +} diff --git a/src/Form/SetUrlType.php b/src/Form/SetUrlType.php index 78844f6..37b053e 100644 --- a/src/Form/SetUrlType.php +++ b/src/Form/SetUrlType.php @@ -3,10 +3,12 @@ namespace App\Form; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ButtonType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\UrlType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Translation\TranslatableMessage; /** * @extends AbstractType @@ -17,8 +19,18 @@ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->add('url', UrlType::class, [ 'default_protocol' => 'https', + 'label' => new TranslatableMessage('seturl.url'), + ]); + $builder->add('Continue', SubmitType::class, [ + 'label' => new TranslatableMessage('seturl.do'), + ]); + $builder->add('Cancel', ButtonType::class, [ + 'label' => new TranslatableMessage('seturl.cancel'), + 'attr' => [ + 'class' => 'btn btn-default', + 'onclick' => 'window.location.href="/"', + ], ]); - $builder->add('Continue', SubmitType::class); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Form/Type/BatchDownloadType.php b/src/Form/Type/BatchDownloadType.php index 4548074..ce897cc 100644 --- a/src/Form/Type/BatchDownloadType.php +++ b/src/Form/Type/BatchDownloadType.php @@ -29,57 +29,63 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, ]) ->add('size', IntegerType::class, [ - 'label' => new TranslatableMessage('qr.size.label'), + 'label' => new TranslatableMessage('design.size.label'), 'data' => 400, 'attr' => ['data-controller' => 'advanced-settings'], - 'help' => new TranslatableMessage('qr.size.help'), + 'help' => new TranslatableMessage('design.size.help'), ]) ->add('margin', IntegerType::class, [ - 'label' => new TranslatableMessage('qr.margin.label'), + 'label' => new TranslatableMessage('design.margin.label'), 'data' => '0', 'attr' => ['data-controller' => 'advanced-settings'], - 'help' => new TranslatableMessage('qr.margin.help'), + 'help' => new TranslatableMessage('design.margin.help'), ]) ->add('backgroundColor', ColorType::class, [ - 'label' => new TranslatableMessage('qr.code_background'), + 'label' => new TranslatableMessage('design.background_color.label'), 'data' => '#ffffff', 'attr' => ['data-controller' => 'advanced-settings'], + 'help' => new TranslatableMessage('design.background_color.help'), ]) ->add('foregroundColor', ColorType::class, [ - 'label' => new TranslatableMessage('qr.code_color'), + 'label' => new TranslatableMessage('design.foreground_color.label'), 'data' => '#000000', 'attr' => ['data-controller' => 'advanced-settings'], + 'help' => new TranslatableMessage('design.foreground_color.help'), ]) ->add('labelText', TextType::class, [ - 'label' => new TranslatableMessage('text.label'), + 'label' => new TranslatableMessage('design.label_text.label'), 'required' => false, 'attr' => ['data-controller' => 'advanced-settings'], + 'help' => new TranslatableMessage('design.label_text.help'), ]) ->add('labelSize', IntegerType::class, [ - 'label' => new TranslatableMessage('text.size'), + 'label' => new TranslatableMessage('design.label_size.label'), 'data' => 15, 'attr' => ['data-controller' => 'advanced-settings'], + 'help' => new TranslatableMessage('design.label_size.help'), ]) ->add('labelTextColor', ColorType::class, [ - 'label' => new TranslatableMessage('text.color'), + 'label' => new TranslatableMessage('design.label_text_color.label'), 'attr' => ['data-controller' => 'advanced-settings'], + 'help' => new TranslatableMessage('design.label_text_color.help'), ]) ->add('labelMarginTop', IntegerType::class, [ - 'label' => new TranslatableMessage('text.margin.top.label'), + 'label' => new TranslatableMessage('design.label_margin_top.label'), 'data' => 15, 'attr' => ['data-controller' => 'advanced-settings'], - 'help' => new TranslatableMessage('text.margin.top.help'), + 'help' => new TranslatableMessage('design.label_margin_top.help'), ]) ->add('labelMarginBottom', IntegerType::class, [ - 'label' => new TranslatableMessage('text.margin.bottom.label'), + 'label' => new TranslatableMessage('design.label_margin_bottom.label'), 'data' => 15, 'attr' => ['data-controller' => 'advanced-settings'], - 'help' => new TranslatableMessage('text.margin.bottom.help'), + 'help' => new TranslatableMessage('design.label_margin_bottom.help'), ]) ->add('logo', FileType::class, [ - 'label' => new TranslatableMessage('logo.label'), + 'label' => new TranslatableMessage('design.logo.label'), 'required' => false, 'attr' => ['data-controller' => 'advanced-settings'], + 'help' => new TranslatableMessage('design.logo.help'), ]) ->add('logoPath', HiddenType::class, [ 'label' => false, @@ -87,7 +93,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => ['data-controller' => 'advanced-settings'], ]) ->add('errorCorrectionLevel', ChoiceType::class, [ - 'label' => new TranslatableMessage('error_correction.label'), + 'label' => new TranslatableMessage('design.error_correction_level.label'), 'choices' => [ 'error_correction.Low' => ErrorCorrectionLevel::Low->value, 'error_correction.Medium' => ErrorCorrectionLevel::Medium->value, @@ -96,7 +102,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], 'choice_translation_domain' => true, 'attr' => ['data-controller' => 'advanced-settings'], - 'help' => new TranslatableMessage('error_correction.help'), + 'help' => new TranslatableMessage('design.error_correction_level.help'), ]) ->add('download', SubmitType::class, [ 'label' => new TranslatableMessage('qr.download'), diff --git a/src/Form/Type/QrArchiveType.php b/src/Form/Type/QrArchiveType.php new file mode 100644 index 0000000..fde7f81 --- /dev/null +++ b/src/Form/Type/QrArchiveType.php @@ -0,0 +1,48 @@ +add('alternativeUrl', UrlType::class, [ + 'label' => new TranslatableMessage('qr.alternativeUrl.label'), + 'attr' => ['data-controller' => 'advanced-settings'], + 'help' => new TranslatableMessage('qr.alternativeUrl.help'), + 'required' => false, + ]) + ->add('archive', SubmitType::class, [ + 'label' => new TranslatableMessage('qr.archive.do'), + ]) + ->add('Cancel', ButtonType::class, [ + 'label' => new TranslatableMessage('qr.archive.cancel'), + 'attr' => [ + 'class' => 'btn btn-default', + 'onclick' => 'window.location.href="/"', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + // enable/disable CSRF protection for this form + 'csrf_protection' => true, + // the name of the hidden HTML field that stores the token + 'csrf_field_name' => '_token', + // an arbitrary string used to generate the value of the token + // using a different string for each form improves its security + 'csrf_token_id' => 'task_item', + ]); + } +} diff --git a/src/Helper/QrHelper.php b/src/Helper/QrHelper.php new file mode 100644 index 0000000..c990fa5 --- /dev/null +++ b/src/Helper/QrHelper.php @@ -0,0 +1,65 @@ +getTitle(); + if ($alternativeUrl) { + $qrEntity->setAlternativeUrl($alternativeUrl); + } + $qrEntity->setStatus(QrStatusEnum::ARCHIVED); + $this->entityManager->flush(); + + $message = new TranslatableMessage( + 'qr.archive.success', + [ + '%title%' => $qrTitle, + '%url%' => $alternativeUrl ? sprintf(' med alternativ URL: %s', $alternativeUrl) : '', + ] + ); + + return new JsonResponse([ + 'message' => $message->trans($this->translator), + ]); + } + + public function unarchive(Qr $qrEntity): Response + { + $qrTitle = $qrEntity->getTitle(); + $qrEntity->setStatus(QrStatusEnum::ACTIVE); + $this->entityManager->flush(); + + $message = new TranslatableMessage( + 'qr.unarchive.success', + [ + '%title%' => $qrTitle, + ] + ); + + return new JsonResponse([ + 'message' => $message->trans($this->translator), + ]); + } +} diff --git a/templates/admin/design/index.html.twig b/templates/admin/design/index.html.twig new file mode 100644 index 0000000..037781f --- /dev/null +++ b/templates/admin/design/index.html.twig @@ -0,0 +1,9 @@ +{% extends '@EasyAdmin/crud/index.html.twig' %} + +{% block content_header_wrapper %} +{{ parent() }} +
+ {{ 'design.index.description'|trans|raw }} +
+
+{% endblock %} diff --git a/templates/admin/qr/index.html.twig b/templates/admin/qr/index.html.twig new file mode 100644 index 0000000..a1a9ce1 --- /dev/null +++ b/templates/admin/qr/index.html.twig @@ -0,0 +1,9 @@ +{% extends '@EasyAdmin/crud/index.html.twig' %} + +{% block content_header_wrapper %} + {{ parent() }} +
+ {{ 'qr.index.description'|trans|raw }} +
+
+{% endblock %} diff --git a/templates/archived.html.twig b/templates/archived.html.twig new file mode 100644 index 0000000..fa495e8 --- /dev/null +++ b/templates/archived.html.twig @@ -0,0 +1,12 @@ +{% block stylesheets %} + {{ encore_entry_link_tags('archived') }} +{% endblock %} + +
+ QR Code +

Denne QR kode er arkiveret

+ {% if alternativeUrl %} +

Du kan finde mere information på

+

{{ alternativeUrl }}

+ {% endif %} +
diff --git a/templates/fields/link/link_to_edit.html.twig b/templates/fields/link/link_to_edit.html.twig new file mode 100644 index 0000000..ea6270b --- /dev/null +++ b/templates/fields/link/link_to_edit.html.twig @@ -0,0 +1 @@ +{{ field.formattedValue }} diff --git a/templates/form/batchDownload.html.twig b/templates/form/batchDownload.html.twig index 3066d7e..533e9c5 100644 --- a/templates/form/batchDownload.html.twig +++ b/templates/form/batchDownload.html.twig @@ -1,7 +1,18 @@ {% extends '@EasyAdmin/content.html.twig' %} {% form_theme form '@EasyAdmin/crud/form_theme.html.twig' %} +{% block content_header %} +
+

{{ 'download.batch.title.label'|trans }}

+ +
+ +{% endblock %} {% block main %} +
+ {{ 'download.batch.title.description'|trans|raw }} +
+
@@ -23,7 +34,7 @@
+

{{ 'qr.archive.title'|trans }}

+
+

{{ 'qr.archive.description'|trans }}

+
+{% endblock %} + +{% block main %} + +
+
+
+ {{ form_start(form) }} + {{ form_end(form) }} +
+
+
+{% endblock %} diff --git a/templates/form/setUrl.html.twig b/templates/form/setUrl.html.twig index 56e95a1..539a701 100644 --- a/templates/form/setUrl.html.twig +++ b/templates/form/setUrl.html.twig @@ -1,21 +1,20 @@ -{% extends 'EasyAdminBundle/content.html.twig' %} +{% extends '@EasyAdmin/content.html.twig' %} {% form_theme form '@EasyAdmin/crud/form_theme.html.twig' %} +{% block content_header %} +
+

{{ 'seturl.title'|trans }}

+
+{% endblock %} + {% block main %} -