Compare commits
27
Commits
779ddcbccb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b696461128 | ||
|
|
d1249adeca | ||
|
|
9ad6c92759 | ||
|
|
ab0976751f | ||
|
|
28f7e9809e | ||
|
|
008887ab60 | ||
|
|
0edaa9d8cc | ||
|
|
97d35f8fcb | ||
|
|
b565825cd9 | ||
|
|
07e8e68742 | ||
|
|
e470a9848d | ||
|
|
6fb3ed597b | ||
|
|
fd8d1730e8 | ||
|
|
cafa60b0f2 | ||
|
|
5dc01a358a | ||
|
|
4daaa8d102 | ||
|
|
996087ec4e | ||
|
|
c63af7becc | ||
|
|
552c71b718 | ||
|
|
c6227ea8c1 | ||
|
|
1c0ab4780b | ||
|
|
7f389fbbc2 | ||
|
|
ffa2b12786 | ||
|
|
51c542c7bb | ||
|
|
a290b75238 | ||
|
|
2b3c90d3fc | ||
|
|
5d9c113eea |
@@ -8,6 +8,7 @@
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/config/reference.php
|
||||
/public/bundles/
|
||||
/var/
|
||||
!/var/volumes/
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DataTable } from 'simple-datatables';
|
||||
import 'simple-datatables/dist/style.css';
|
||||
|
||||
// Paginate/search/sort any admin table opted in via class="admin-datatable".
|
||||
// Kept off tables that are admin-curated and stay small (games) or where row
|
||||
// order is meaningful and must not be disturbed by sorting (hints).
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('table.admin-datatable').forEach((table) => {
|
||||
new DataTable(table, {
|
||||
perPage: 25,
|
||||
perPageSelect: [10, 25, 50, 100],
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
labels: {
|
||||
placeholder: 'Search...',
|
||||
perPage: '{select} entries per page',
|
||||
noRows: 'No matching entries found',
|
||||
info: 'Showing {start} to {end} of {rows} entries',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,3 +8,4 @@ import 'bootstrap/js/dist/dropdown';
|
||||
import './game-waiting';
|
||||
import './game-lobby';
|
||||
import './admin-session-tabs';
|
||||
import './admin-datatables';
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Small single-purpose Leaflet maps:
|
||||
* - #escaperoom-detail-map : read-only pin on a room's detail page
|
||||
* - #escaperoom-suggest-map : click to drop / move a pin on the "suggest a room"
|
||||
* form, writing the coordinates into the hidden latitude/longitude fields.
|
||||
*/
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
|
||||
|
||||
const OSM = {
|
||||
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
opts: {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
},
|
||||
};
|
||||
|
||||
function initDetailMap(el) {
|
||||
const lat = parseFloat(el.dataset.lat);
|
||||
const lng = parseFloat(el.dataset.lng);
|
||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 15);
|
||||
L.tileLayer(OSM.url, OSM.opts).addTo(map);
|
||||
L.marker([lat, lng]).addTo(map);
|
||||
}
|
||||
|
||||
function initSuggestMap(el) {
|
||||
const form = el.closest('form');
|
||||
const latField = form && form.querySelector('input[name$="[latitude]"]');
|
||||
const lngField = form && form.querySelector('input[name$="[longitude]"]');
|
||||
if (!latField || !lngField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startLat = parseFloat(latField.value);
|
||||
const startLng = parseFloat(lngField.value);
|
||||
const hasStart = !Number.isNaN(startLat) && !Number.isNaN(startLng);
|
||||
|
||||
const map = L.map(el).setView(hasStart ? [startLat, startLng] : [20, 0], hasStart ? 15 : 2);
|
||||
L.tileLayer(OSM.url, OSM.opts).addTo(map);
|
||||
|
||||
let marker = hasStart ? L.marker([startLat, startLng], { draggable: true }).addTo(map) : null;
|
||||
|
||||
const write = (latlng) => {
|
||||
latField.value = latlng.lat.toFixed(7);
|
||||
lngField.value = latlng.lng.toFixed(7);
|
||||
latField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
|
||||
if (marker) {
|
||||
marker.on('dragend', () => write(marker.getLatLng()));
|
||||
}
|
||||
|
||||
map.on('click', (e) => {
|
||||
if (marker) {
|
||||
marker.setLatLng(e.latlng);
|
||||
} else {
|
||||
marker = L.marker(e.latlng, { draggable: true }).addTo(map);
|
||||
marker.on('dragend', () => write(marker.getLatLng()));
|
||||
}
|
||||
write(e.latlng);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const detail = document.getElementById('escaperoom-detail-map');
|
||||
if (detail) {
|
||||
initDetailMap(detail);
|
||||
}
|
||||
|
||||
const suggest = document.getElementById('escaperoom-suggest-map');
|
||||
if (suggest) {
|
||||
initSuggestMap(suggest);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Public "physical escape rooms" map + list.
|
||||
*
|
||||
* One JSON fetch (website_escaperooms_data) feeds both the Leaflet map (with
|
||||
* marker clustering, so a worldwide dataset stays responsive) and the searchable
|
||||
* table underneath it.
|
||||
*/
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet.markercluster';
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||
import { DataTable } from 'simple-datatables';
|
||||
import 'simple-datatables/dist/style.css';
|
||||
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
// Webpack rewrites the image paths, so hand them to Leaflet explicitly.
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
|
||||
|
||||
function slugify(name) {
|
||||
return (name || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'room';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value == null ? '' : String(value);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function ratingLabel(avg, count) {
|
||||
if (!count) {
|
||||
return '—';
|
||||
}
|
||||
return `${avg.toFixed(1)} ★ (${count})`;
|
||||
}
|
||||
|
||||
function detailUrl(template, room) {
|
||||
return template.replace('__ID__', room.id).replace('__SLUG__', slugify(room.name));
|
||||
}
|
||||
|
||||
function buildMap(el, rooms, detailTemplate) {
|
||||
const map = L.map(el, { worldCopyJump: true }).setView([20, 0], 2);
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
const cluster = L.markerClusterGroup({ chunkedLoading: true });
|
||||
const bounds = [];
|
||||
|
||||
rooms.forEach((room) => {
|
||||
if (typeof room.lat !== 'number' || typeof room.lng !== 'number') {
|
||||
return;
|
||||
}
|
||||
const where = [room.city, room.country].filter(Boolean).join(', ');
|
||||
const popup = `
|
||||
<strong>${escapeHtml(room.name)}</strong><br>
|
||||
${where ? `${escapeHtml(where)}<br>` : ''}
|
||||
<span>${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</span><br>
|
||||
<a href="${detailUrl(detailTemplate, room)}">View & review »</a>
|
||||
`;
|
||||
const marker = L.marker([room.lat, room.lng]).bindPopup(popup);
|
||||
cluster.addLayer(marker);
|
||||
bounds.push([room.lat, room.lng]);
|
||||
});
|
||||
|
||||
map.addLayer(cluster);
|
||||
|
||||
if (bounds.length) {
|
||||
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
}
|
||||
}
|
||||
|
||||
function buildList(table, rooms, detailTemplate) {
|
||||
const tbody = table.querySelector('tbody');
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
rooms.forEach((room) => {
|
||||
const tr = document.createElement('tr');
|
||||
const where = room.city ? escapeHtml(room.city) : '';
|
||||
const country = room.country ? escapeHtml(room.country) : '';
|
||||
const ratingValue = room.reviewCount ? room.avgRating : 0;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td><a href="${detailUrl(detailTemplate, room)}">${escapeHtml(room.name)}</a></td>
|
||||
<td>${where}</td>
|
||||
<td>${country}</td>
|
||||
<td data-order="${ratingValue}">${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</td>
|
||||
`;
|
||||
frag.appendChild(tr);
|
||||
});
|
||||
|
||||
tbody.replaceChildren(frag);
|
||||
|
||||
new DataTable(table, {
|
||||
perPage: 25,
|
||||
perPageSelect: [25, 50, 100],
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
labels: {
|
||||
placeholder: 'Search rooms…',
|
||||
perPage: '{select} per page',
|
||||
noRows: 'No matching rooms',
|
||||
info: 'Showing {start}–{end} of {rows} rooms',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const mapEl = document.getElementById('escaperoom-map');
|
||||
if (!mapEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = mapEl.dataset.endpoint;
|
||||
const detailTemplate = mapEl.dataset.detailTemplate;
|
||||
const listEl = document.getElementById('escaperoom-list');
|
||||
const statusEl = document.getElementById('escaperoom-status');
|
||||
|
||||
fetch(endpoint, { headers: { Accept: 'application/json' } })
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
const rooms = Array.isArray(payload.rooms) ? payload.rooms : [];
|
||||
buildMap(mapEl, rooms, detailTemplate);
|
||||
if (listEl) {
|
||||
buildList(listEl, rooms, detailTemplate);
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `${rooms.length.toLocaleString()} rooms on the map`;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Could not load escape rooms right now.';
|
||||
}
|
||||
console.error('escaperoom-map:', err);
|
||||
});
|
||||
});
|
||||
+12
-4
@@ -40,7 +40,6 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl)
|
||||
const messageContainer = document.getElementById('message-container');
|
||||
if (messageContainer) {
|
||||
appendResultMessage(messageContainer, data[1], data[2] || 'mainframe');
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
if(stillPlayingSound)
|
||||
playSound();
|
||||
console.log('[Mercure][game1] sequenceFinished status:', sequenceFinished);
|
||||
@@ -144,7 +143,6 @@ async function fetchLockReveal(apiEchoUrl, messageContainer) {
|
||||
const result = response && response.result;
|
||||
if (result && Array.isArray(result.result)) {
|
||||
result.result.forEach(text => appendResultMessage(messageContainer, text, result.messageType));
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
if (result && result.locked === false) {
|
||||
clearLock();
|
||||
@@ -401,7 +399,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
msgEl.className = 'message ' + extraClass;
|
||||
msgEl.textContent = msg[0];
|
||||
messageContainer.appendChild(msgEl);
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
|
||||
playSound();
|
||||
|
||||
@@ -418,6 +415,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const inputField = document.getElementById('input-message');
|
||||
inputField.disabled = false;
|
||||
|
||||
let lastCommand = '';
|
||||
|
||||
// Recall the last submitted command on ArrowUp, cursor at the end
|
||||
inputField.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
inputField.value = lastCommand;
|
||||
inputField.setSelectionRange(inputField.value.length, inputField.value.length);
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listener for Enter key
|
||||
inputField.addEventListener('keypress', async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -431,6 +439,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
messageContainer.appendChild(msgEl);
|
||||
|
||||
if (message && apiEchoUrl) {
|
||||
lastCommand = message;
|
||||
inputField.value = '';
|
||||
try {
|
||||
const response = await fetchJson(apiEchoUrl, {
|
||||
@@ -440,7 +449,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.log('[API][game1] message sent →', response);
|
||||
if (response && response.result && Array.isArray(response.result.result)) {
|
||||
response.result.result.forEach(text => appendResultMessage(messageContainer, text, response.result.messageType));
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
if (response && response.result) {
|
||||
if (response.result.gameWon === true && wonUrl) {
|
||||
|
||||
+4
-4
@@ -7,12 +7,12 @@
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"doctrine/dbal": "^3.10.5",
|
||||
"doctrine/dbal": "^4.4.4",
|
||||
"doctrine/doctrine-bundle": "^2.18.3",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.7",
|
||||
"doctrine/orm": "^3.6.7",
|
||||
"karser/karser-recaptcha3-bundle": "^0.3.0",
|
||||
"phpdocumentor/reflection-docblock": "^5.6.7",
|
||||
"phpdocumentor/reflection-docblock": "^6.0.3",
|
||||
"phpstan/phpdoc-parser": "^2.3.2",
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/asset-mapper": "7.4.*",
|
||||
@@ -27,9 +27,9 @@
|
||||
"symfony/intl": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mailgun-mailer": "7.4.*",
|
||||
"symfony/mercure-bundle": "^0.3.9",
|
||||
"symfony/mercure-bundle": "^0.5.0",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/monolog-bundle": "^3.11.2",
|
||||
"symfony/monolog-bundle": "^4.0.2",
|
||||
"symfony/notifier": "7.4.*",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
|
||||
Generated
+364
-360
File diff suppressed because it is too large
Load Diff
@@ -15,11 +15,9 @@ doctrine:
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
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
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Test / staging environment (`test.escapepage.com`)
|
||||
|
||||
Runs a second copy of the full Docker stack on the same server as production,
|
||||
behind the same Nginx Proxy Manager (NPM). Production is untouched: with no
|
||||
`docker/.env` overrides, `compose.yaml` behaves exactly as before.
|
||||
|
||||
## How isolation works
|
||||
|
||||
`docker/compose.yaml` derives everything instance-specific from `docker/.env`:
|
||||
|
||||
| Concern | Mechanism | prod (no overrides) | test |
|
||||
|---|---|---|---|
|
||||
| Container names | `${STACK_NAME:-escapepage}-*` | `escapepage-php` … | `escapepage-test-php` … |
|
||||
| Compose project (networks, labels) | `COMPOSE_PROJECT_NAME` | `docker` (unchanged) | `escapepage-test` |
|
||||
| Published ports | `${NGINX_HTTP_PORT:-8080}` etc. | `0.0.0.0:8080/8443/3306/8090/8025` | `127.0.0.1:8081/8444/3307/8091/8026` |
|
||||
| Database files | bind mount `../var/volumes/db` | per checkout | per checkout |
|
||||
| Web reachability | `nginx` joined to external `nginx_default` | via NPM | via NPM |
|
||||
|
||||
`STACK_NAME` is a plain variable (not the Compose-managed `COMPOSE_PROJECT_NAME`),
|
||||
so its `:-escapepage` default is reliable and **production needs no `docker/.env`
|
||||
change** to keep its `escapepage-*` container names.
|
||||
|
||||
`nginx`, `mercure` and `mailer` all sit on the external `nginx_default` network,
|
||||
so NPM forwards straight to `escapepage-test-nginx` / `-mercure` by name — no
|
||||
public host port needed.
|
||||
|
||||
## Production checkout — optional
|
||||
|
||||
Nothing is required. Two optional tidy-ups, each needing a `docker compose up -d`
|
||||
to recreate the affected container:
|
||||
|
||||
- The `nginx` service now also attaches to `nginx_default`. If you'd rather have
|
||||
NPM forward to `escapepage-nginx` by name instead of `host:8080`, recreate it
|
||||
and repoint the NPM proxy host. Otherwise the published `8080/8443` still work
|
||||
as before and you can ignore this.
|
||||
- Add `STACK_NAME=escapepage` and `COMPOSE_PROJECT_NAME=escapepage` to the
|
||||
production `docker/.env` to move it off the implicit `docker` project name.
|
||||
Cosmetic; do it only if you want the two stacks named symmetrically.
|
||||
|
||||
## Standing up the test stack
|
||||
|
||||
### 1. DNS
|
||||
`test.escapepage.com` → server IP. (`mercure-test.escapepage.com` too if you want
|
||||
live game features.) On Cloudflare, DNS-only ("grey cloud") keeps it low-profile.
|
||||
|
||||
### 2. Separate checkout
|
||||
```bash
|
||||
git clone <repo> /opt/escapepage-test
|
||||
cd /opt/escapepage-test
|
||||
git checkout <branch-to-test>
|
||||
```
|
||||
Use a **separate clone**, not `git worktree` — the Docker build context is the
|
||||
checkout directory and full isolation avoids surprises.
|
||||
|
||||
### 3. Compose env
|
||||
```bash
|
||||
cp docker/.env.test.example docker/.env
|
||||
# edit: real passwords, fresh MERCURE_JWT_SECRET (openssl rand -hex 32), reCAPTCHA keys
|
||||
```
|
||||
|
||||
### 4. Symfony env overrides
|
||||
Create `/opt/escapepage-test/.env.local` in the checkout (git-ignored):
|
||||
```
|
||||
APP_SECRET=<openssl rand -hex 16>
|
||||
# Behind NPM the forwarded client IP lands from a Docker-private range; trust them
|
||||
# all on staging so URL generation / HTTPS detection work.
|
||||
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
```
|
||||
`APP_ENV=prod`, `DATABASE_URL`, `SITE_BASE_URL`, `MERCURE_*` and `MAILER_DSN` are
|
||||
already supplied to the containers by `docker/.env`.
|
||||
|
||||
### 5. Build & start
|
||||
```bash
|
||||
./docker/setup.test.sh
|
||||
```
|
||||
The test-specific script (not `setup.sh`): it refuses to run unless
|
||||
`COMPOSE_PROJECT_NAME` in `docker/.env` is a non-prod value, scopes every compose
|
||||
call to that project, runs the DB/cache/console steps as `www-data`, and repairs
|
||||
`var/cache` / `var/log` / `var/sessions` ownership at the end (bare `docker exec`
|
||||
runs as root, which otherwise leaves php-fpm unable to read its own cache — a
|
||||
silent 500). It never touches `var/volumes/db`.
|
||||
|
||||
Builds `escapepage-test-*` images, starts the containers, creates + migrates
|
||||
`escapepage_test`, builds assets. Re-run any time; `--no-build` skips the rebuild.
|
||||
|
||||
### 6. Nginx Proxy Manager — proxy host
|
||||
- Domain: `test.escapepage.com`
|
||||
- Forward to: `escapepage-test-nginx`, port **443**, scheme **https**
|
||||
(the app nginx force-redirects 80→443 and serves a self-signed cert; leave
|
||||
"Verify SSL" off — same arrangement as production)
|
||||
- SSL: request a Let's Encrypt cert, Force SSL, HTTP/2
|
||||
- Optional: add response header `X-Robots-Tag: noindex`
|
||||
|
||||
If you want live game screens, add a second proxy host
|
||||
`mercure-test.escapepage.com` → `escapepage-test-mercure` port `80` (http).
|
||||
|
||||
### 7. Restrict access to your IP — NPM Access List
|
||||
A host firewall on 80/443 can't help: prod and test share those ports on NPM.
|
||||
Restrict at the proxy instead.
|
||||
|
||||
- **NPM → Access Lists → Add Access List**
|
||||
- Name: e.g. `staging-allowlist`
|
||||
- Authorisation: leave empty
|
||||
- Access: `Allow <your.public.ip>` then a final `Deny all`
|
||||
- Satisfy: **Any**
|
||||
- Edit the `test.escapepage.com` proxy host → **Access List** → select it.
|
||||
- Do the same on `mercure-test.escapepage.com` if you added it.
|
||||
|
||||
Everyone else gets `403` at the proxy. Update the IP in one place when it changes.
|
||||
|
||||
Defence in depth (optional): in `/opt/escapepage-test/docker/nginx/default.conf`,
|
||||
inside the `server { listen 443 ... }` block:
|
||||
```nginx
|
||||
set_real_ip_from 172.16.0.0/12; # NPM's docker network
|
||||
real_ip_header X-Forwarded-For;
|
||||
allow <your.public.ip>;
|
||||
deny all;
|
||||
```
|
||||
|
||||
## Deploying a new version to test
|
||||
|
||||
```bash
|
||||
cd /var/sites/escapepage-test
|
||||
git fetch && git checkout <branch> && git pull
|
||||
./docker/setup.test.sh --no-build # composer install, migrate, build assets, fix perms
|
||||
```
|
||||
|
||||
`--no-build` skips the image rebuild; drop it if the Dockerfile changed.
|
||||
|
||||
## Restarting
|
||||
|
||||
```bash
|
||||
./docker/restart.test.sh # down + up, keeps the DB and images
|
||||
./docker/restart.test.sh --build # also rebuild images
|
||||
./docker/restart.test.sh --fresh-db # also wipe var/volumes/db and re-init MySQL
|
||||
```
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Use the **`*.test.sh`** scripts on this checkout, not `setup.sh` / `restart.sh`.
|
||||
Both refuse to run unless `docker/.env` names a non-prod
|
||||
`COMPOSE_PROJECT_NAME`, and both scope every action to that project — they
|
||||
can't reach the production stack.
|
||||
- `restart.test.sh` **keeps the database** by default (you loaded prod data into
|
||||
it); `--fresh-db` is the only thing that wipes it. It never runs a host-wide
|
||||
`docker system prune` / `docker builder prune`, and it only repairs ownership
|
||||
of `var/cache` / `var/log` / `var/sessions` — **never `var/volumes/db`**
|
||||
(chowning the MySQL data dir is what corrupted it earlier this build).
|
||||
- The test `docker/.env` uses its own `DB_NAME` and passwords so a config slip
|
||||
can't reach the production database.
|
||||
- `MAILER_DSN=smtp://mailer:1026` keeps staging mail inside Mailpit instead of
|
||||
sending through Mailgun.
|
||||
@@ -0,0 +1,77 @@
|
||||
# =============================================================================
|
||||
# docker/.env for a TEST / STAGING stack (e.g. test.escapepage.com)
|
||||
# =============================================================================
|
||||
# Copy this to docker/.env inside the *test* checkout and fill in the blanks.
|
||||
# docker/.env is git-ignored, so it never leaves the server.
|
||||
#
|
||||
# Compose reads this file automatically. Anything unique per stack is derived
|
||||
# from COMPOSE_PROJECT_NAME + the *_PORT vars below, so the same compose.yaml
|
||||
# serves both production and this copy.
|
||||
#
|
||||
# Two config layers, don't mix them up:
|
||||
# - THIS file -> consumed by `docker compose` (container names, ports, and the
|
||||
# runtime env it injects into the php containers).
|
||||
# - <checkout>/.env(.local) -> consumed by Symfony itself (APP_SECRET,
|
||||
# TRUSTED_PROXIES, messenger DSN, CLI database access, ...).
|
||||
# =============================================================================
|
||||
|
||||
## --- Instance identity -------------------------------------------------------
|
||||
# Both must be unique on the host.
|
||||
# STACK_NAME -> prefix for every container_name (escapepage-test-php …)
|
||||
# COMPOSE_PROJECT_NAME -> compose project: isolates networks, volumes and the
|
||||
# labels that `docker compose down` / *.test.sh act on.
|
||||
# Must be a non-prod value or setup.test.sh / restart.test.sh
|
||||
# refuse to run.
|
||||
STACK_NAME=escapepage-test
|
||||
COMPOSE_PROJECT_NAME=escapepage-test
|
||||
|
||||
## --- Published host ports ---------------------------------------------------
|
||||
# Bound to localhost only: the sole public entrypoint is Nginx Proxy Manager,
|
||||
# which reaches the containers over the shared `nginx_default` network by name.
|
||||
# Pick ports that don't clash with the production stack (8080/8443/3306/8090/8025/1025).
|
||||
NGINX_HTTP_PORT=127.0.0.1:8081
|
||||
NGINX_HTTPS_PORT=127.0.0.1:8444
|
||||
DB_HOST_PORT=127.0.0.1:3307
|
||||
MERCURE_HTTP_PORT=127.0.0.1:8091
|
||||
MAILPIT_UI_PORT=127.0.0.1:8026
|
||||
MAILPIT_SMTP_PORT=127.0.0.1:1026
|
||||
|
||||
## --- PHP image build ------------------------------------------------------
|
||||
USER_ID=1000
|
||||
GROUP_ID=1000
|
||||
|
||||
## --- Symfony runtime (injected into php / php-worker / php-cron) ------------
|
||||
APP_ENV=prod
|
||||
SITE_BASE_URL=https://test.escapepage.com
|
||||
|
||||
# Staging should NOT send real mail. Point at the bundled Mailpit and read it
|
||||
# at http://127.0.0.1:8026 on the server (or via an NPM host if you expose it).
|
||||
MAILER_DSN=smtp://mailer:1026
|
||||
MAILER_FROM=mailer@test.escapepage.com
|
||||
|
||||
## --- Database -------------------------------------------------------------
|
||||
# `database` is the compose *service* name and resolves inside this stack's
|
||||
# own network - keep it as-is. Use its own name + fresh credentials so a mistake
|
||||
# here can never point at the production database.
|
||||
DB_NAME=escapepage_test
|
||||
DB_USER=escapepage
|
||||
DB_PASSWORD=CHANGE_ME_test_db_password
|
||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_test_root_password
|
||||
DATABASE_URL=pdo_mysql://escapepage:CHANGE_ME_test_db_password@database:3306/escapepage_test?serverVersion=8.0.32&charset=utf8mb4
|
||||
|
||||
## --- Mercure -----------------------------------------------------------------
|
||||
# Internal hub URL (service name, stays the same). Public URL + CORS must be the
|
||||
# test domain. Add a `mercure-test.escapepage.com` proxy host in NPM ->
|
||||
# <project>-mercure:80.
|
||||
MERCURE_URL=http://mercure/.well-known/mercure
|
||||
MERCURE_PUBLIC_URL=https://mercure-test.escapepage.com/.well-known/mercure
|
||||
MERCURE_JWT_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32
|
||||
MERCURE_CORS_ALLOWED_ORIGINS="https://test.escapepage.com"
|
||||
MERCURE_TOPIC_BASE=https://test.escapepage.com
|
||||
|
||||
## --- reCAPTCHA v3 ----------------------------------------------------------
|
||||
# Register test.escapepage.com in the reCAPTCHA admin console and paste its keys,
|
||||
# or leave the placeholders and expect the contact / "suggest a room" forms to
|
||||
# fail captcha validation on staging.
|
||||
RECAPTCHA3_KEY=CHANGE_ME_or_reuse_prod_if_domain_added
|
||||
RECAPTCHA3_SECRET=CHANGE_ME_or_reuse_prod_if_domain_added
|
||||
@@ -17,8 +17,8 @@ services:
|
||||
mailer:
|
||||
image: axllent/mailpit
|
||||
ports:
|
||||
- "1025:1025"
|
||||
- "8025:8025"
|
||||
- "${MAILPIT_SMTP_PORT:-1025}:1025"
|
||||
- "${MAILPIT_UI_PORT:-8025}:8025"
|
||||
environment:
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
|
||||
+26
-15
@@ -1,5 +1,14 @@
|
||||
version: '3.7'
|
||||
|
||||
# This stack can run more than once on the same host (e.g. production + a
|
||||
# test.escapepage.com staging copy). Everything that must be unique per instance
|
||||
# comes from docker/.env:
|
||||
# STACK_NAME -> container name prefix (default: escapepage)
|
||||
# COMPOSE_PROJECT_NAME -> compose project / network namespace
|
||||
# NGINX_HTTP_PORT etc. -> published host ports
|
||||
# With no docker/.env overrides it behaves exactly as before: containers
|
||||
# escapepage-*, ports 8080/8443/3306/8090/8025.
|
||||
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
@@ -8,7 +17,7 @@ services:
|
||||
args:
|
||||
USER_ID: ${USER_ID}
|
||||
GROUP_ID: ${GROUP_ID}
|
||||
container_name: escapepage-php
|
||||
container_name: ${STACK_NAME:-escapepage}-php
|
||||
volumes:
|
||||
- ../:/var/www/html:delegated
|
||||
- /etc/hosts:/etc/hosts:ro
|
||||
@@ -40,7 +49,7 @@ services:
|
||||
args:
|
||||
USER_ID: ${USER_ID}
|
||||
GROUP_ID: ${GROUP_ID}
|
||||
container_name: escapepage-php-worker
|
||||
container_name: ${STACK_NAME:-escapepage}-php-worker
|
||||
volumes:
|
||||
- ../:/var/www/html:delegated
|
||||
- /etc/hosts:/etc/hosts:ro
|
||||
@@ -73,7 +82,7 @@ services:
|
||||
args:
|
||||
USER_ID: ${USER_ID}
|
||||
GROUP_ID: ${GROUP_ID}
|
||||
container_name: escapepage-php-cron
|
||||
container_name: ${STACK_NAME:-escapepage}-php-cron
|
||||
volumes:
|
||||
- ../:/var/www/html:delegated
|
||||
- /etc/hosts:/etc/hosts:ro
|
||||
@@ -101,10 +110,10 @@ services:
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29.4-alpine
|
||||
container_name: escapepage-nginx
|
||||
container_name: ${STACK_NAME:-escapepage}-nginx
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8443:443"
|
||||
- "${NGINX_HTTP_PORT:-8080}:80"
|
||||
- "${NGINX_HTTPS_PORT:-8443}:443"
|
||||
volumes:
|
||||
- ../:/var/www/html:ro
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
@@ -112,16 +121,18 @@ services:
|
||||
- /etc/hosts:/etc/hosts:ro
|
||||
depends_on:
|
||||
- php
|
||||
# networks:
|
||||
# backend:
|
||||
# ipv4_address: 172.23.0.12
|
||||
# Joined to the Nginx Proxy Manager network so NPM can forward straight to
|
||||
# "<project>-nginx" without going back out to a published host port.
|
||||
networks:
|
||||
- default
|
||||
- nginx_proxy
|
||||
restart: unless-stopped
|
||||
|
||||
mailer:
|
||||
image: axllent/mailpit:latest
|
||||
container_name: escapepage-mailer
|
||||
container_name: ${STACK_NAME:-escapepage}-mailer
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "${MAILPIT_UI_PORT:-8025}:8025"
|
||||
volumes:
|
||||
- /etc/hosts:/etc/hosts:ro
|
||||
networks:
|
||||
@@ -131,7 +142,7 @@ services:
|
||||
|
||||
mercure:
|
||||
image: dunglas/mercure:v0.21
|
||||
container_name: escapepage-mercure
|
||||
container_name: ${STACK_NAME:-escapepage}-mercure
|
||||
environment:
|
||||
SERVER_NAME: "http://:80"
|
||||
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
|
||||
@@ -143,7 +154,7 @@ services:
|
||||
publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
|
||||
anonymous
|
||||
ports:
|
||||
- "8090:80"
|
||||
- "${MERCURE_HTTP_PORT:-8090}:80"
|
||||
volumes:
|
||||
- /etc/hosts:/etc/hosts:ro
|
||||
networks:
|
||||
@@ -154,7 +165,7 @@ services:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database:
|
||||
image: mysql:8.0
|
||||
container_name: escapepage-db
|
||||
container_name: ${STACK_NAME:-escapepage}-db
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
@@ -173,7 +184,7 @@ services:
|
||||
- /etc/hosts:/etc/hosts:ro
|
||||
# Uncomment the two lines below if you need to access MySQL from your host (workbench, etc.)
|
||||
ports:
|
||||
- "3306:3306"
|
||||
- "${DB_HOST_PORT:-3306}:3306"
|
||||
# networks:
|
||||
# backend:
|
||||
# ipv4_address: 172.23.0.15
|
||||
|
||||
+39
-11
@@ -1,22 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Script to completely restart the project as requested
|
||||
# Can be run from any directory
|
||||
# Completely restart ONE project stack (prod or a test/staging copy).
|
||||
# Can be run from any directory. Everything is scoped to the Compose project
|
||||
# name so running this from the test checkout never touches the prod stack.
|
||||
#
|
||||
# ./docker/restart.sh # rebuild-less restart of this checkout's stack
|
||||
# ./docker/restart.sh --prune-all # also run host-wide `docker system/builder prune`
|
||||
# # (old behaviour; skip it when another stack shares the host)
|
||||
|
||||
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
|
||||
|
||||
echo "Stopping and removing containers..."
|
||||
(cd "$DOCKER_DIR" && docker compose -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true
|
||||
docker network rm escapepage_network || true
|
||||
docker network rm $(docker network ls -q --filter name=escapepage) || true
|
||||
docker network prune -f || true
|
||||
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker escapepage-php-cron || true
|
||||
docker system prune -f || true
|
||||
PRUNE_ALL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--prune-all) PRUNE_ALL=1 ;;
|
||||
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Clearing Docker build cache..."
|
||||
docker builder prune -af
|
||||
# Read the identifiers from docker/.env (same file Compose uses). STACK_NAME is
|
||||
# the container-name prefix; COMPOSE_PROJECT_NAME is the compose project.
|
||||
read_env() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"'" || true; }
|
||||
STACK="$(read_env STACK_NAME)"; STACK="${STACK:-escapepage}"
|
||||
PROJECT="$(read_env COMPOSE_PROJECT_NAME)"; PROJECT="${PROJECT:-$STACK}"
|
||||
echo "Restarting stack: $STACK (compose project: $PROJECT)"
|
||||
|
||||
echo "Stopping and removing containers..."
|
||||
(cd "$DOCKER_DIR" && docker compose -p "$PROJECT" -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true
|
||||
|
||||
# Belt-and-suspenders: drop anything still lingering for THIS stack only.
|
||||
docker rm -f \
|
||||
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
|
||||
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
|
||||
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
|
||||
docker network rm "$net" || true
|
||||
done
|
||||
|
||||
if [ "$PRUNE_ALL" -eq 1 ]; then
|
||||
echo "Host-wide prune (containers, networks, build cache)..."
|
||||
docker system prune -f || true
|
||||
docker builder prune -af || true
|
||||
else
|
||||
echo "Skipping host-wide prune (pass --prune-all to force it)."
|
||||
fi
|
||||
|
||||
echo "Setting permissions for var/volumes/db and var directories..."
|
||||
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Restart the TEST (staging) stack. Scoped entirely to this checkout's compose
|
||||
# project, so it can never touch the production stack. Unlike docker/restart.sh
|
||||
# it:
|
||||
# - keeps the database by default (you loaded prod data into it) — pass
|
||||
# --fresh-db to wipe var/volumes/db and let MySQL re-initialise
|
||||
# - never runs a host-wide `docker system/builder prune`
|
||||
# - only repairs ownership of var/cache, var/log, var/sessions — NEVER
|
||||
# var/volumes/db (that dir belongs to the mysql process; chowning it is
|
||||
# what corrupts the data directory)
|
||||
#
|
||||
# Usage:
|
||||
# ./docker/restart.test.sh # down + up (keeps DB and images)
|
||||
# ./docker/restart.test.sh --build # also rebuild images
|
||||
# ./docker/restart.test.sh --fresh-db # also wipe + re-initialise the database
|
||||
|
||||
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
|
||||
|
||||
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
|
||||
|
||||
if [ ! -f "$DOCKER_DIR/.env" ]; then
|
||||
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env first." >&2
|
||||
exit 1
|
||||
fi
|
||||
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
|
||||
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
|
||||
case "${PROJECT:-}" in
|
||||
""|escapepage|docker)
|
||||
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
|
||||
echo "Refusing to run a test script against the production stack." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE="docker-compose"
|
||||
else
|
||||
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FRESH_DB=0; BUILD=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--fresh-db) FRESH_DB=1 ;;
|
||||
--build) BUILD=1 ;;
|
||||
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
|
||||
|
||||
echo "Restarting test stack: $STACK (compose project: $PROJECT)"
|
||||
|
||||
echo "Stopping containers..."
|
||||
dc down --remove-orphans || true
|
||||
# scoped fallback — only this stack
|
||||
docker rm -f \
|
||||
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
|
||||
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
|
||||
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
|
||||
docker network rm "$net" || true
|
||||
done
|
||||
|
||||
if [ "$FRESH_DB" -eq 1 ]; then
|
||||
echo "Wiping the test database (var/volumes/db)..."
|
||||
sudo rm -rf "$ROOT_DIR/var/volumes/db"
|
||||
fi
|
||||
|
||||
echo "Repairing var/ ownership (cache/log/sessions only)..."
|
||||
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/sessions"
|
||||
sudo chown -R 1000:1000 "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
|
||||
sudo chmod -R u+rwX,g+rwX "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
|
||||
|
||||
echo "Bringing the stack back up..."
|
||||
if [ "$BUILD" -eq 1 ]; then
|
||||
"$DOCKER_DIR/setup.test.sh"
|
||||
else
|
||||
"$DOCKER_DIR/setup.test.sh" --no-build
|
||||
fi
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Bootstrap / re-provision a TEST (staging) stack — e.g. test.escapepage.com.
|
||||
# Same job as docker/setup.sh, but:
|
||||
# - refuses to run unless docker/.env marks this as a non-prod stack
|
||||
# - scopes every compose call to COMPOSE_PROJECT_NAME
|
||||
# - runs DB / cache / console steps as www-data and repairs var/ ownership at
|
||||
# the end, so php-fpm (www-data) can actually read the compiled container
|
||||
# (bare `docker exec` runs as root and would leave var/cache root-owned)
|
||||
# - NEVER touches var/volumes/db (MySQL owns that)
|
||||
#
|
||||
# Usage:
|
||||
# ./docker/setup.test.sh # full setup (build images)
|
||||
# ./docker/setup.test.sh --no-build # skip image rebuild
|
||||
# ./docker/setup.test.sh --recreate # force-recreate containers
|
||||
# ./docker/setup.test.sh --down # stop and remove this stack's containers
|
||||
|
||||
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
|
||||
|
||||
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
|
||||
|
||||
# --- safety gate: never let a *.test.sh script act on the production stack ----
|
||||
if [ ! -f "$DOCKER_DIR/.env" ]; then
|
||||
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env and fill it in." >&2
|
||||
exit 1
|
||||
fi
|
||||
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
|
||||
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
|
||||
case "${PROJECT:-}" in
|
||||
""|escapepage|docker)
|
||||
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
|
||||
echo "Refusing to run a test script against the production stack." >&2
|
||||
echo "Set COMPOSE_PROJECT_NAME and STACK_NAME to e.g. 'escapepage-test' in docker/.env." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# --- compose command -------------------------------------------------------
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE="docker-compose"
|
||||
else
|
||||
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
|
||||
exit 1
|
||||
fi
|
||||
command -v docker >/dev/null 2>&1 || { echo "Error: docker is required." >&2; exit 1; }
|
||||
|
||||
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
|
||||
pexec() { dc exec -T php "$@"; } # as root (composer / npm)
|
||||
pexecwww() { dc exec -T -u www-data php "$@"; } # as www-data (console / DB / cache)
|
||||
|
||||
REBUILD=1; RECREATE=0; DOWN_ONLY=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) REBUILD=0 ;;
|
||||
--recreate) RECREATE=1 ;;
|
||||
--down) DOWN_ONLY=1 ;;
|
||||
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Test stack: $STACK (compose project: $PROJECT)"
|
||||
|
||||
if [ "$DOWN_ONLY" -eq 1 ]; then
|
||||
dc down --remove-orphans
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BUILD_ARGS=()
|
||||
[ "$REBUILD" -eq 1 ] && BUILD_ARGS+=("--build")
|
||||
[ "$RECREATE" -eq 1 ] && BUILD_ARGS+=("--force-recreate")
|
||||
|
||||
dc up -d "${BUILD_ARGS[@]}"
|
||||
|
||||
# --- wait for the database ------------------------------------------------
|
||||
printf "Waiting for database to be healthy..."
|
||||
for i in $(seq 1 60); do
|
||||
DB_ID=$(dc ps -q database 2>/dev/null || true)
|
||||
if [ -n "$DB_ID" ] && [ "$(docker inspect -f '{{.State.Health.Status}}' "$DB_ID" 2>/dev/null || true)" = "healthy" ]; then
|
||||
echo " OK"; break
|
||||
fi
|
||||
printf "."; sleep 2
|
||||
[ "$i" -eq 60 ] && echo -e "\nWarning: database not healthy yet, continuing anyway."
|
||||
done
|
||||
|
||||
# --- dependencies (root: writes vendor/ and node_modules/) --------------
|
||||
pexec composer install --no-interaction
|
||||
|
||||
# --- APP_SECRET: generate into .env.local if it's set nowhere -----------
|
||||
if ! grep -qsE '^APP_SECRET=.+' "$ROOT_DIR/.env" "$ROOT_DIR/.env.local"; then
|
||||
echo "Generating APP_SECRET in .env.local..."
|
||||
printf 'APP_SECRET=%s\n' "$(openssl rand -hex 16)" >> "$ROOT_DIR/.env.local"
|
||||
fi
|
||||
|
||||
# --- database (www-data: keeps var/ writable by php-fpm) ---------------
|
||||
echo "Creating database if it doesn't exist..."
|
||||
pexecwww php bin/console doctrine:database:create --if-not-exists || {
|
||||
echo "Error: database creation failed." >&2; dc logs database | tail -n 40; exit 1;
|
||||
}
|
||||
echo "Running migrations..."
|
||||
pexecwww php bin/console doctrine:migrations:migrate -n || { echo "Error: migrations failed." >&2; exit 1; }
|
||||
|
||||
[ -f "$ROOT_DIR/importmap.php" ] && pexec php bin/console importmap:install || true
|
||||
|
||||
if [ -f "$ROOT_DIR/package.json" ]; then
|
||||
echo "Installing npm dependencies..."; pexec npm ci || pexec npm install
|
||||
echo "Building assets..."; pexec npm run build
|
||||
fi
|
||||
|
||||
# --- repair var/ ownership for php-fpm (www-data), never var/volumes ----
|
||||
echo "Fixing var/ ownership for php-fpm..."
|
||||
pexec sh -lc 'mkdir -p var/cache var/log/php var/log/cron var/sessions && chown -R www-data:www-data var/cache var/log var/sessions'
|
||||
|
||||
pexecwww php bin/console cache:clear
|
||||
|
||||
NGINX_HTTPS="$(env_val NGINX_HTTPS_PORT)"
|
||||
MAILPIT_UI="$(env_val MAILPIT_UI_PORT)"
|
||||
|
||||
cat <<EOT
|
||||
|
||||
Test stack '$PROJECT' is up.
|
||||
|
||||
NPM upstream: ${STACK}-nginx (scheme https, port 443, "Verify SSL" off)
|
||||
Local check: curl -k https://${NGINX_HTTPS:-127.0.0.1:18443}/
|
||||
Mailpit UI: http://${MAILPIT_UI:-127.0.0.1:18026}
|
||||
|
||||
Logs: docker logs -f ${STACK}-php
|
||||
Shell: docker exec -it -u www-data ${STACK}-php sh
|
||||
Restart: ./docker/restart.test.sh (add --fresh-db to wipe + re-init the DB)
|
||||
|
||||
Re-run this script any time. Use --no-build to skip the image rebuild.
|
||||
EOT
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260810140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create hint table and seed it with the previously-hardcoded mainframe hints for every existing game';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE hint (id INT AUTO_INCREMENT NOT NULL, game_id INT NOT NULL, hint_condition VARCHAR(30) NOT NULL, threshold_seconds INT NOT NULL, message LONGTEXT NOT NULL, sort_order INT NOT NULL, INDEX IDX_HINT_GAME (game_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE hint ADD CONSTRAINT FK_HINT_GAME FOREIGN KEY (game_id) REFERENCES `game` (id) ON DELETE CASCADE');
|
||||
|
||||
// Seed every existing game with the hints that used to be hardcoded in
|
||||
// SendMainframeHintsCommand, so behavior doesn't regress the moment the
|
||||
// command switches to reading from this table.
|
||||
$seed = [
|
||||
['help_used', 120, 'Have you already checked which functions are available to you through the help command?', 10],
|
||||
['broadcast_done', 300, 'You should establish communications with your fellow agents.', 20],
|
||||
['contacted_all_privately', 420, 'The AI virus can see all communication if you are using open communication channels. Make sure you send private messages to all other agents as well, so I know you can.', 30],
|
||||
['verified', 600, 'You need the help of your fellow agents to verify you. If both other agents have helped you in your verification, i can get you more rights. The help command might give you some more information.', 40],
|
||||
['verified', 780, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', 50],
|
||||
['left_home_directory', 900, 'You can change the folder you are working in. Check the help command for more information', 60],
|
||||
['all_decoded', 1020, 'You should go to the rapports directory to check out the rapports.', 70],
|
||||
['all_decoded', 1140, "Not all messages can be decoded by every agent. Every agent has their own personal decoding method. If you can't decode a message, maybe a colleague can.", 80],
|
||||
['none', 1380, 'Have you checked out the help command to see what you can do?', 90],
|
||||
['none', 1560, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', 100],
|
||||
['none', 1680, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', 110],
|
||||
];
|
||||
|
||||
foreach ($seed as [$condition, $threshold, $message, $sortOrder]) {
|
||||
$this->addSql(
|
||||
'INSERT INTO hint (game_id, hint_condition, threshold_seconds, message, sort_order) '
|
||||
. 'SELECT id, ?, ?, ?, ? FROM `game`',
|
||||
[$condition, $threshold, $message, $sortOrder]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE hint DROP FOREIGN KEY FK_HINT_GAME');
|
||||
$this->addSql('DROP TABLE hint');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260829120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create contact_message table for the public contact form';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE contact_message (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, message LONGTEXT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE contact_message');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260829130000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add read_at to contact_message so admin can track which messages have been read';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE contact_message ADD read_at DATETIME DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE contact_message DROP read_at');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260829140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add deleted_at to contact_message for soft-deleting messages from the admin list';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE contact_message ADD deleted_at DATETIME DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE contact_message DROP deleted_at');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260829150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add escape_room and escape_room_review tables for the physical escape room map and reviews';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE escape_room (
|
||||
id INT AUTO_INCREMENT NOT NULL,
|
||||
submitted_by_id INT DEFAULT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
venue VARCHAR(255) DEFAULT NULL,
|
||||
street VARCHAR(255) DEFAULT NULL,
|
||||
house_number VARCHAR(32) DEFAULT NULL,
|
||||
postcode VARCHAR(32) DEFAULT NULL,
|
||||
city VARCHAR(128) DEFAULT NULL,
|
||||
country VARCHAR(2) DEFAULT NULL,
|
||||
latitude NUMERIC(10, 7) NOT NULL,
|
||||
longitude NUMERIC(10, 7) NOT NULL,
|
||||
website VARCHAR(511) DEFAULT NULL,
|
||||
phone VARCHAR(64) DEFAULT NULL,
|
||||
source VARCHAR(16) NOT NULL,
|
||||
osm_type VARCHAR(8) DEFAULT NULL,
|
||||
osm_id BIGINT DEFAULT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
locked TINYINT(1) DEFAULT 0 NOT NULL,
|
||||
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
INDEX idx_escape_room_submitted_by (submitted_by_id),
|
||||
UNIQUE INDEX uniq_escape_room_osm (osm_type, osm_id),
|
||||
INDEX idx_escape_room_status (status),
|
||||
INDEX idx_escape_room_bbox (latitude, longitude),
|
||||
PRIMARY KEY(id)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE escape_room_review (
|
||||
id INT AUTO_INCREMENT NOT NULL,
|
||||
escape_room_id INT NOT NULL,
|
||||
author_id INT DEFAULT NULL,
|
||||
rating SMALLINT NOT NULL,
|
||||
title VARCHAR(150) NOT NULL,
|
||||
body LONGTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
|
||||
INDEX idx_escape_room_review_room (escape_room_id),
|
||||
INDEX idx_escape_room_review_author (author_id),
|
||||
PRIMARY KEY(id)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||
SQL);
|
||||
|
||||
$this->addSql('ALTER TABLE escape_room ADD CONSTRAINT fk_escape_room_submitted_by FOREIGN KEY (submitted_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_room FOREIGN KEY (escape_room_id) REFERENCES escape_room (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_author FOREIGN KEY (author_id) REFERENCES `user` (id) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_room');
|
||||
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_author');
|
||||
$this->addSql('ALTER TABLE escape_room DROP FOREIGN KEY fk_escape_room_submitted_by');
|
||||
$this->addSql('DROP TABLE escape_room_review');
|
||||
$this->addSql('DROP TABLE escape_room');
|
||||
}
|
||||
}
|
||||
Generated
+1471
-4191
File diff suppressed because it is too large
Load Diff
+8
-5
@@ -12,21 +12,24 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.0",
|
||||
"@babel/preset-env": "^7.25.0",
|
||||
"@symfony/webpack-encore": "^4.6.1",
|
||||
"babel-loader": "^9.1.3",
|
||||
"@symfony/webpack-encore": "^6.0.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
"core-js": "^3.37.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"mini-css-extract-plugin": "^2.9.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"sass": "^1.101.0",
|
||||
"sass-loader": "^14.2.1",
|
||||
"sass-loader": "^16.0.1",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-cli": "^6.0.0",
|
||||
"webpack-notifier": "^1.15.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1"
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"simple-datatables": "^10.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ namespace App\Command;
|
||||
use App\Game\Entity\Player;
|
||||
use App\Game\Entity\Session;
|
||||
use App\Game\Enum\GameSettingType;
|
||||
use App\Game\Enum\HintCondition;
|
||||
use App\Game\Enum\SessionSettingType;
|
||||
use App\Game\Enum\SessionStatus;
|
||||
use App\Game\Event\PushMercureMessageEvent;
|
||||
use App\Game\Repository\GameSettingRepository;
|
||||
use App\Game\Repository\HintRepository;
|
||||
use App\Game\Repository\SessionRepository;
|
||||
use App\Game\Repository\SessionSettingRepository;
|
||||
use App\Game\Service\PlayerService;
|
||||
@@ -28,22 +30,11 @@ final class SendMainframeHintsCommand extends Command
|
||||
{
|
||||
private const DEFAULT_TOTAL_TIME = 3600;
|
||||
|
||||
private const HELP_THRESHOLD_SECONDS = 2 * 60;
|
||||
private const BROADCAST_CHAT_THRESHOLD_SECONDS = 5 * 60;
|
||||
private const PRIVATE_CHAT_THRESHOLD_SECONDS = 7 * 60;
|
||||
private const VERIFY_THRESHOLD_SECONDS = 10 * 60;
|
||||
private const VERIFY_ESCALATION_THRESHOLD_SECONDS = 13 * 60;
|
||||
private const CD_THRESHOLD_SECONDS = 15 * 60;
|
||||
private const RAPPORTS_THRESHOLD_SECONDS = 17 * 60;
|
||||
private const RAPPORTS_ESCALATION_THRESHOLD_SECONDS = 19 * 60;
|
||||
private const ENDGAME_NUDGE_THRESHOLD_SECONDS = 23 * 60;
|
||||
private const ENDGAME_HURRY_THRESHOLD_SECONDS = 26 * 60;
|
||||
private const ENDGAME_URGENT_THRESHOLD_SECONDS = 28 * 60;
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionRepository $sessionRepository,
|
||||
private readonly SessionSettingRepository $sessionSettingRepository,
|
||||
private readonly GameSettingRepository $gameSettingRepository,
|
||||
private readonly HintRepository $hintRepository,
|
||||
private readonly PlayerService $playerService,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
@@ -110,90 +101,66 @@ final class SendMainframeHintsCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks this player's progress in strict dependency order and sends at most one
|
||||
* hint: the one for the first step that's both unresolved and old enough to nudge
|
||||
* about. Later steps genuinely depend on earlier ones (e.g. /verify isn't even
|
||||
* usable until a player has finished contacting everyone), so we stop at the
|
||||
* first unresolved step instead of also evaluating - and possibly firing - hints
|
||||
* for steps that aren't actionable yet.
|
||||
* Walks this game's admin-configured hints in order. For the first distinct
|
||||
* condition that's still unresolved for this player, fires the most-escalated
|
||||
* hint sharing that condition whose threshold has been reached, then stops -
|
||||
* later conditions are only reached once every one before them resolves, since
|
||||
* they genuinely depend on it (e.g. /verify isn't even usable until a player has
|
||||
* finished contacting everyone).
|
||||
*/
|
||||
private function sendNextHint(Session $session, Player $player, int $elapsed): bool
|
||||
{
|
||||
if (!$this->hasUsedHelp($session, $player)) {
|
||||
if ($elapsed >= self::HELP_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, 'Have you already checked which functions are available to you through the help command?', $player);
|
||||
return true;
|
||||
$hints = $this->hintRepository->findByGameOrdered($session->getGame());
|
||||
|
||||
$seenConditions = [];
|
||||
foreach ($hints as $hint) {
|
||||
$condition = $hint->getCondition();
|
||||
|
||||
if (in_array($condition, $seenConditions, true)) {
|
||||
continue; // already handled this condition's group further up the list
|
||||
}
|
||||
return false;
|
||||
$seenConditions[] = $condition;
|
||||
|
||||
if ($condition !== HintCondition::NONE && $this->isConditionResolved($condition, $session, $player)) {
|
||||
continue; // move on to the next distinct condition
|
||||
}
|
||||
|
||||
$hasBroadcast = $this->hasBroadcast($session, $player);
|
||||
$hasContactedEveryonePrivately = $this->hasContactedEveryonePrivately($session, $player);
|
||||
$group = array_filter($hints, static fn ($h) => $h->getCondition() === $condition);
|
||||
|
||||
if (!$hasBroadcast || !$hasContactedEveryonePrivately) {
|
||||
if ($elapsed >= self::PRIVATE_CHAT_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, 'The AI virus can see all communication if you are using open communication channels. Contact each other privately as well.', $player);
|
||||
return true;
|
||||
$best = null;
|
||||
foreach ($group as $candidate) {
|
||||
if ($elapsed < $candidate->getThresholdSeconds()) {
|
||||
continue;
|
||||
}
|
||||
if (!$hasBroadcast && $elapsed >= self::BROADCAST_CHAT_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, 'You should establish communications with your fellow agents.', $player);
|
||||
return true;
|
||||
if ($best === null || $candidate->getThresholdSeconds() > $best->getThresholdSeconds()) {
|
||||
$best = $candidate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->isFullyVerified($session, $player)) {
|
||||
if ($elapsed >= self::VERIFY_ESCALATION_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', $player);
|
||||
return true;
|
||||
}
|
||||
if ($elapsed >= self::VERIFY_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, 'You need the help of your fellow agents to verify you. If both other agents have helped you in your verification, i can get you more rights. The help command might give you some more information.', $player);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isStillInHomeDirectory($player)) {
|
||||
if ($elapsed >= self::CD_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, 'You can change the folder you are working in. Check the help command for more information', $player);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->allMessagesDecoded($session, $player)) {
|
||||
if ($elapsed >= self::RAPPORTS_ESCALATION_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, "Not all messages can be decoded by every agent. Every agent has their own personal decoding method. If you can't decode a message, maybe a colleague can.", $player);
|
||||
return true;
|
||||
}
|
||||
if ($elapsed >= self::RAPPORTS_THRESHOLD_SECONDS && !$this->isInRapportsFolder($player)) {
|
||||
$this->publishHint($session, 'You should go to the rapports directory to check out the rapports.', $player);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Every earlier step is resolved for this player - only the endgame urgency
|
||||
// nudges are left, gated purely on elapsed time.
|
||||
if ($elapsed >= self::ENDGAME_URGENT_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', $player);
|
||||
if ($best !== null) {
|
||||
$this->publishHint($session, $best->getMessage(), $player);
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($elapsed >= self::ENDGAME_HURRY_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', $player);
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($elapsed >= self::ENDGAME_NUDGE_THRESHOLD_SECONDS) {
|
||||
$this->publishHint($session, 'Have you checked out the help command to see what you can do?', $player);
|
||||
return true;
|
||||
return false; // condition unresolved, but no threshold reached yet
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isConditionResolved(HintCondition $condition, Session $session, Player $player): bool
|
||||
{
|
||||
return match ($condition) {
|
||||
HintCondition::HELP_USED => $this->hasUsedHelp($session, $player),
|
||||
HintCondition::BROADCAST_DONE => $this->hasBroadcast($session, $player),
|
||||
HintCondition::CONTACTED_ALL_PRIVATELY => $this->hasContactedEveryonePrivately($session, $player),
|
||||
HintCondition::VERIFIED => $this->isFullyVerified($session, $player),
|
||||
HintCondition::LEFT_HOME_DIRECTORY => !$this->isStillInHomeDirectory($player),
|
||||
HintCondition::ALL_DECODED => $this->allMessagesDecoded($session, $player),
|
||||
HintCondition::NONE => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function getElapsedPlayingSeconds(Session $session): ?int
|
||||
{
|
||||
$timer = $session->getTimer();
|
||||
@@ -275,13 +242,6 @@ final class SendMainframeHintsCommand extends Command
|
||||
return $pwd === '/var/home/' . $player->getUser()->getUsername();
|
||||
}
|
||||
|
||||
private function isInRapportsFolder(Player $player): bool
|
||||
{
|
||||
$pwd = $this->playerService->getCurrentPwdOfPlayer($player);
|
||||
|
||||
return $pwd !== null && str_starts_with($pwd, '/var/rapports');
|
||||
}
|
||||
|
||||
/**
|
||||
* True once all three agents have decoded their personal message - decoding
|
||||
* player 1's grants 'sudo' to everyone, player 2's grants 'scan', player 3's
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Controller;
|
||||
|
||||
use App\Website\Entity\ContactMessage;
|
||||
use App\Website\Repository\ContactMessageRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/admin/contact')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
final class GameAdminContactController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'game_admin_contact', methods: ['GET'])]
|
||||
public function index(ContactMessageRepository $contactMessageRepository): Response
|
||||
{
|
||||
return $this->render('game/admin/contact/index.html.twig', [
|
||||
'messages' => $contactMessageRepository->findActive(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'game_admin_contact_show', methods: ['GET'])]
|
||||
public function show(ContactMessage $contactMessage, EntityManagerInterface $entityManager): Response
|
||||
{
|
||||
if (!$contactMessage->isRead()) {
|
||||
$contactMessage->setReadAt(new \DateTimeImmutable());
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
return $this->render('game/admin/contact/show.html.twig', [
|
||||
'message' => $contactMessage,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/delete', name: 'game_admin_contact_delete', methods: ['POST'])]
|
||||
public function delete(ContactMessage $contactMessage, Request $request, EntityManagerInterface $entityManager): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('delete_contact_' . $contactMessage->getId(), $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('game_admin_contact');
|
||||
}
|
||||
|
||||
if (!$contactMessage->isDeleted()) {
|
||||
$contactMessage->setDeletedAt(new \DateTimeImmutable());
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
$this->addFlash('success', sprintf('Message from "%s" deleted.', $contactMessage->getName()));
|
||||
|
||||
return $this->redirectToRoute('game_admin_contact');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Controller;
|
||||
|
||||
use App\Game\Service\FeedbackService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/admin/feedback')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
final class GameAdminFeedbackController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'game_admin_feedback', methods: ['GET'])]
|
||||
public function index(FeedbackService $feedbackService): Response
|
||||
{
|
||||
return $this->render('game/admin/feedback/index.html.twig', [
|
||||
'entries' => $feedbackService->getFeedbackEntries(),
|
||||
'summary' => $feedbackService->getFeedbackSummary(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Game\Controller;
|
||||
|
||||
use App\Game\Entity\GameSetting;
|
||||
use App\Game\Enum\GameSettingType;
|
||||
use App\Game\Enum\GameStatus;
|
||||
use App\Game\Form\AdminGameType;
|
||||
use App\Game\Entity\Game;
|
||||
use App\Game\Repository\GameRepository;
|
||||
@@ -29,6 +30,41 @@ final class GameAdminGameController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/new', name: 'game_admin_game_new', methods: ['GET', 'POST'])]
|
||||
public function new(
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
): Response {
|
||||
$game = new Game();
|
||||
$game->setStatus(GameStatus::IN_DEVELOPMENT);
|
||||
|
||||
$form = $this->createForm(AdminGameType::class, $game);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$totalTime = $form->get('totalTime')->getData();
|
||||
|
||||
$em->persist($game);
|
||||
|
||||
if ($totalTime !== null) {
|
||||
$totalTimeSetting = new GameSetting();
|
||||
$totalTimeSetting->setGame($game);
|
||||
$totalTimeSetting->setName(GameSettingType::TOTAL_TIME);
|
||||
$totalTimeSetting->setValue((string) $totalTime);
|
||||
$em->persist($totalTimeSetting);
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
$this->addFlash('success', sprintf('Game "%s" created.', $game->getName()));
|
||||
|
||||
return $this->redirectToRoute('game_admin_games');
|
||||
}
|
||||
|
||||
return $this->render('game/admin/games/new.html.twig', [
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/edit', name: 'game_admin_game_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(
|
||||
Game $game,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Controller;
|
||||
|
||||
use App\Game\Entity\Game;
|
||||
use App\Game\Entity\Hint;
|
||||
use App\Game\Form\AdminHintType;
|
||||
use App\Game\Repository\HintRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/admin/games/{game}/hints')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
final class GameAdminHintController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'game_admin_hints', methods: ['GET'])]
|
||||
public function index(Game $game, HintRepository $hintRepository): Response
|
||||
{
|
||||
return $this->render('game/admin/hints/index.html.twig', [
|
||||
'game' => $game,
|
||||
'hints' => $hintRepository->findByGameOrdered($game),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/new', name: 'game_admin_hint_new', methods: ['GET', 'POST'])]
|
||||
public function new(Game $game, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$hint = new Hint();
|
||||
$hint->setGame($game);
|
||||
|
||||
$form = $this->createForm(AdminHintType::class, $hint);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$em->persist($hint);
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Hint created.');
|
||||
|
||||
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('game/admin/hints/new.html.twig', [
|
||||
'game' => $game,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/edit', name: 'game_admin_hint_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(Game $game, Hint $hint, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$form = $this->createForm(AdminHintType::class, $hint);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Hint updated.');
|
||||
|
||||
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('game/admin/hints/edit.html.twig', [
|
||||
'game' => $game,
|
||||
'hint' => $hint,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/delete', name: 'game_admin_hint_delete', methods: ['POST'])]
|
||||
public function delete(Game $game, Hint $hint, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('delete_hint_' . $hint->getId(), $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
|
||||
}
|
||||
|
||||
$em->remove($hint);
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Hint deleted.');
|
||||
|
||||
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Game\Entity;
|
||||
|
||||
use App\Game\Enum\HintCondition;
|
||||
use App\Game\Repository\HintRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: HintRepository::class)]
|
||||
#[ORM\Table(name: 'hint')]
|
||||
class Hint
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Game::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Game $game = null;
|
||||
|
||||
#[ORM\Column(name: 'hint_condition', type: 'string', length: 30, enumType: HintCondition::class)]
|
||||
private ?HintCondition $condition = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $thresholdSeconds = 0;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private ?string $message = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $sortOrder = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getGame(): ?Game
|
||||
{
|
||||
return $this->game;
|
||||
}
|
||||
|
||||
public function setGame(?Game $game): static
|
||||
{
|
||||
$this->game = $game;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCondition(): ?HintCondition
|
||||
{
|
||||
return $this->condition;
|
||||
}
|
||||
|
||||
public function setCondition(HintCondition $condition): static
|
||||
{
|
||||
$this->condition = $condition;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getThresholdSeconds(): int
|
||||
{
|
||||
return $this->thresholdSeconds;
|
||||
}
|
||||
|
||||
public function setThresholdSeconds(int $thresholdSeconds): static
|
||||
{
|
||||
$this->thresholdSeconds = $thresholdSeconds;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function setMessage(string $message): static
|
||||
{
|
||||
$this->message = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSortOrder(): int
|
||||
{
|
||||
return $this->sortOrder;
|
||||
}
|
||||
|
||||
public function setSortOrder(int $sortOrder): static
|
||||
{
|
||||
$this->sortOrder = $sortOrder;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,5 @@ enum DecodeMessage: string
|
||||
{
|
||||
case PLAYER_1 = 'Sudo is now available';
|
||||
case PLAYER_2 = 'AI virus protects its own files by replacing them';
|
||||
case PLAYER_3 = 'The locked up bash files should be removed to lock it up';
|
||||
case PLAYER_3 = 'The AI virus is having a lock on certain files. Remove these to disable it.';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Game\Enum;
|
||||
|
||||
/**
|
||||
* The fixed set of game-progress checks a hint can be gated behind. A hint fires
|
||||
* only once its condition is still unresolved for that player - admins compose,
|
||||
* order, and word hints freely, but the underlying checks are real game state and
|
||||
* stay in code (see SendMainframeHintsCommand::isConditionResolved()).
|
||||
*/
|
||||
enum HintCondition: string
|
||||
{
|
||||
case HELP_USED = 'help_used';
|
||||
case BROADCAST_DONE = 'broadcast_done';
|
||||
case CONTACTED_ALL_PRIVATELY = 'contacted_all_privately';
|
||||
case VERIFIED = 'verified';
|
||||
case LEFT_HOME_DIRECTORY = 'left_home_directory';
|
||||
case ALL_DECODED = 'all_decoded';
|
||||
case NONE = 'none';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::HELP_USED => "Player hasn't used the help command yet",
|
||||
self::BROADCAST_DONE => "Player hasn't sent an open (broadcast) chat message yet",
|
||||
self::CONTACTED_ALL_PRIVATELY => "Player hasn't privately messaged every other agent yet",
|
||||
self::VERIFIED => "Player isn't fully verified yet",
|
||||
self::LEFT_HOME_DIRECTORY => "Player hasn't left their home directory",
|
||||
self::ALL_DECODED => "Player hasn't decoded all messages",
|
||||
self::NONE => 'No condition - always eligible once earlier hints are resolved (e.g. endgame nudges)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Form;
|
||||
|
||||
use App\Game\Entity\Hint;
|
||||
use App\Game\Enum\HintCondition;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\PositiveOrZero;
|
||||
|
||||
class AdminHintType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('condition', ChoiceType::class, [
|
||||
'label' => 'Fires while...',
|
||||
'choices' => array_combine(
|
||||
array_map(fn (HintCondition $c) => $c->label(), HintCondition::cases()),
|
||||
HintCondition::cases(),
|
||||
),
|
||||
])
|
||||
->add('thresholdSeconds', IntegerType::class, [
|
||||
'label' => 'Threshold (seconds since game start)',
|
||||
'constraints' => [new NotBlank(), new PositiveOrZero()],
|
||||
])
|
||||
->add('message', TextareaType::class, [
|
||||
'constraints' => [new NotBlank()],
|
||||
'attr' => ['rows' => 4],
|
||||
])
|
||||
->add('sortOrder', IntegerType::class, [
|
||||
'label' => 'Order (lower fires first)',
|
||||
'constraints' => [new NotBlank()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => Hint::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Game\Repository;
|
||||
|
||||
use App\Game\Entity\Game;
|
||||
use App\Game\Enum\GameStatus;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -15,4 +16,9 @@ class GameRepository extends ServiceEntityRepository
|
||||
{
|
||||
parent::__construct($registry, Game::class);
|
||||
}
|
||||
|
||||
public function hasOpenGame(): bool
|
||||
{
|
||||
return $this->count(['status' => GameStatus::OPEN]) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Game\Repository;
|
||||
|
||||
use App\Game\Entity\Game;
|
||||
use App\Game\Entity\Hint;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Hint>
|
||||
*/
|
||||
class HintRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Hint::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Hint[]
|
||||
*/
|
||||
public function findByGameOrdered(Game $game): array
|
||||
{
|
||||
return $this->findBy(['game' => $game], ['sortOrder' => 'ASC']);
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,22 @@ class SessionSettingRepository extends ServiceEntityRepository
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SessionSetting[]
|
||||
*/
|
||||
public function findFeedbackSettings(): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->andWhere('s.name IN (:names)')
|
||||
->setParameter('names', [
|
||||
SessionSettingType::FEEDBACK_DIFFICULTY->value,
|
||||
SessionSettingType::FEEDBACK_ENTERTAINING->value,
|
||||
SessionSettingType::FEEDBACK_THEME->value,
|
||||
SessionSettingType::FEEDBACK_TEXT->value,
|
||||
])
|
||||
->orderBy('s.player', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Game\Service;
|
||||
|
||||
use App\Game\Enum\SessionSettingType;
|
||||
use App\Game\Repository\SessionSettingRepository;
|
||||
|
||||
final class FeedbackService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionSettingRepository $sessionSettingRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per player who submitted feedback, most recent first.
|
||||
*
|
||||
* @return array<int, array{session: \App\Game\Entity\Session, player: \App\Game\Entity\Player, difficulty: ?int, entertaining: ?int, theme: ?int, text: ?string}>
|
||||
*/
|
||||
public function getFeedbackEntries(): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($this->sessionSettingRepository->findFeedbackSettings() as $setting) {
|
||||
$player = $setting->getPlayer();
|
||||
if (!$player) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $player->getId();
|
||||
if (!isset($entries[$key])) {
|
||||
$entries[$key] = [
|
||||
'session' => $setting->getSession(),
|
||||
'player' => $player,
|
||||
'difficulty' => null,
|
||||
'entertaining' => null,
|
||||
'theme' => null,
|
||||
'text' => null,
|
||||
];
|
||||
}
|
||||
|
||||
match ($setting->getName()) {
|
||||
SessionSettingType::FEEDBACK_DIFFICULTY => $entries[$key]['difficulty'] = (int) $setting->getValue(),
|
||||
SessionSettingType::FEEDBACK_ENTERTAINING => $entries[$key]['entertaining'] = (int) $setting->getValue(),
|
||||
SessionSettingType::FEEDBACK_THEME => $entries[$key]['theme'] = (int) $setting->getValue(),
|
||||
SessionSettingType::FEEDBACK_TEXT => $entries[$key]['text'] = $setting->getValue(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return array_values($entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate numbers only - free-text comments are left out since they're
|
||||
* unmoderated player input and this summary is meant for the public
|
||||
* briefing page. Use getFeedbackEntries() for the admin-only raw view.
|
||||
*
|
||||
* @return array{count: int, avgDifficulty: ?float, avgEntertaining: ?float, avgTheme: ?float}
|
||||
*/
|
||||
public function getFeedbackSummary(): array
|
||||
{
|
||||
$entries = $this->getFeedbackEntries();
|
||||
|
||||
return [
|
||||
'count' => count($entries),
|
||||
'avgDifficulty' => $this->average(array_column($entries, 'difficulty')),
|
||||
'avgEntertaining' => $this->average(array_column($entries, 'entertaining')),
|
||||
'avgTheme' => $this->average(array_column($entries, 'theme')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|null> $values
|
||||
*/
|
||||
private function average(array $values): ?float
|
||||
{
|
||||
$values = array_filter($values, static fn ($v) => $v !== null);
|
||||
|
||||
return $values ? round(array_sum($values) / count($values), 1) : null;
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,14 @@ class GameResponseService
|
||||
|
||||
$result = $this->handleVerifyMessage($message, $player);
|
||||
return ['result' => [$result]];
|
||||
case '/pwd':
|
||||
case '/ls':
|
||||
case '/scan':
|
||||
case '/sudo':
|
||||
case '/rm':
|
||||
case '/cd':
|
||||
case '/cat':
|
||||
return $this->checkConsoleCommando(substr($message, 1), $player);
|
||||
default:
|
||||
return ['result' => ['Unknown command']];
|
||||
}
|
||||
@@ -240,7 +248,9 @@ class GameResponseService
|
||||
}
|
||||
|
||||
$filename = $messagePart[1];
|
||||
$fullPath = ($pwd === '/' ? '' : $pwd) . '/' . $filename;
|
||||
$fullPath = str_starts_with($filename, '/')
|
||||
? $filename
|
||||
: ($pwd === '/' ? '' : $pwd) . '/' . $filename;
|
||||
|
||||
if(!$this->isAllowedToRemove($fullPath, $player, $sudo))
|
||||
return ['result' => ['You are not allowed to remove this file.']];
|
||||
@@ -292,6 +302,10 @@ class GameResponseService
|
||||
$result[] = 'These files are protected by the AI virus. Use sudo rm {file} to remove them.';
|
||||
|
||||
return ['result' => $result];
|
||||
case 'chat':
|
||||
case 'verify':
|
||||
case 'decode':
|
||||
return $this->checkGameCommando('/'.$message, $player);
|
||||
default:
|
||||
return ['result' => ['Unknown command']];
|
||||
}
|
||||
@@ -537,33 +551,7 @@ class GameResponseService
|
||||
}
|
||||
|
||||
// Grant verify right
|
||||
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
|
||||
|
||||
if (!$rightsSettingName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
|
||||
if (!$setting) {
|
||||
return; // Should have been initialized
|
||||
}
|
||||
|
||||
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
|
||||
$newRights = ['verify', 'cat'];
|
||||
|
||||
$updated = false;
|
||||
foreach ($newRights as $newRight) {
|
||||
if (!in_array($newRight, $rights)) {
|
||||
$rights[] = $newRight;
|
||||
$updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
$setting->setValue(json_encode($rights));
|
||||
$this->entityManager->persist($setting);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
$this->grantRights($player, ['verify', 'cat']);
|
||||
}
|
||||
|
||||
private function handleDecodeMessage(string $message, Player $player): string
|
||||
@@ -606,31 +594,59 @@ class GameResponseService
|
||||
|
||||
private function grantRightToAllPlayers(Session $session, string $right): void
|
||||
{
|
||||
$updated = false;
|
||||
|
||||
foreach ($session->getPlayers() as $sessionPlayer) {
|
||||
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $sessionPlayer->getScreen());
|
||||
if (!$rightsSettingName) {
|
||||
continue;
|
||||
$this->grantRights($sessionPlayer, [$right]);
|
||||
}
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($session, $rightsSettingName, $sessionPlayer);
|
||||
/**
|
||||
* The single place a player's rights ever get granted. Persists any rights the
|
||||
* player doesn't already have and, if anything actually changed, notifies that
|
||||
* player (and only that player) over Mercure that they've gained access.
|
||||
*
|
||||
* @param string[] $newRights
|
||||
* @return bool Whether any right was actually newly granted.
|
||||
*/
|
||||
private function grantRights(Player $player, array $newRights): bool
|
||||
{
|
||||
$screen = $player->getScreen();
|
||||
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
|
||||
|
||||
if (!$rightsSettingName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
|
||||
if (!$setting) {
|
||||
continue;
|
||||
return false; // Should have been initialized
|
||||
}
|
||||
|
||||
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
|
||||
if (!in_array($right, $rights)) {
|
||||
$rights[] = $right;
|
||||
$setting->setValue(json_encode($rights));
|
||||
$this->entityManager->persist($setting);
|
||||
$updated = true;
|
||||
$granted = [];
|
||||
|
||||
foreach ($newRights as $newRight) {
|
||||
if (!in_array($newRight, $rights)) {
|
||||
$rights[] = $newRight;
|
||||
$granted[] = $newRight;
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
$this->entityManager->flush();
|
||||
if (empty($granted)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$setting->setValue(json_encode($rights));
|
||||
$this->entityManager->persist($setting);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
|
||||
$player->getSession(),
|
||||
[$screen, 'MAINFRAME: You have received new access: ' . implode(', ', $granted) . '.', 'mainframe'],
|
||||
'rights_granted',
|
||||
$screen,
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -890,34 +906,7 @@ class GameResponseService
|
||||
|
||||
private function grantVerificationRights(Player $player): void
|
||||
{
|
||||
$screen = $player->getScreen();
|
||||
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
|
||||
|
||||
if (!$rightsSettingName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
|
||||
if (!$setting) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
|
||||
$newRights = ['cd', 'decode'];
|
||||
|
||||
$updated = false;
|
||||
foreach ($newRights as $newRight) {
|
||||
if (!in_array($newRight, $rights)) {
|
||||
$rights[] = $newRight;
|
||||
$updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
$setting->setValue(json_encode($rights));
|
||||
$this->entityManager->persist($setting);
|
||||
$this->entityManager->flush();
|
||||
|
||||
if ($this->grantRights($player, ['cd', 'decode'])) {
|
||||
$this->checkIfAllPlayersVerified($player);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Website\Command;
|
||||
|
||||
use App\Website\Entity\EscapeRoom;
|
||||
use App\Website\Enum\EscapeRoomSource;
|
||||
use App\Website\Enum\EscapeRoomStatus;
|
||||
use App\Website\Repository\EscapeRoomRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:escaperooms:import-osm',
|
||||
description: 'Import physical escape rooms (leisure=escape_game) from OpenStreetMap via the Overpass API',
|
||||
)]
|
||||
final class ImportEscapeRoomsFromOsmCommand extends Command
|
||||
{
|
||||
private const DEFAULT_ENDPOINT = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
/**
|
||||
* Continental-ish tiles covering inhabited land. Used with --tiled so no single
|
||||
* Overpass response has to carry the whole planet at once.
|
||||
*
|
||||
* @var array<string, array{float, float, float, float}> name => [south, west, north, east]
|
||||
*/
|
||||
private const WORLD_TILES = [
|
||||
'Europe' => [34.0, -25.0, 72.0, 45.0],
|
||||
'Africa' => [-36.0, -20.0, 38.0, 52.0],
|
||||
'Middle East' => [12.0, 26.0, 43.0, 63.0],
|
||||
'North Asia' => [40.0, 45.0, 78.0, 180.0],
|
||||
'South Asia' => [5.0, 63.0, 40.0, 93.0],
|
||||
'East Asia' => [18.0, 93.0, 54.0, 146.0],
|
||||
'SE Asia/Oceania' => [-48.0, 93.0, 18.0, 180.0],
|
||||
'North America' => [10.0, -168.0, 72.0, -52.0],
|
||||
'Central America' => [7.0, -92.0, 33.0, -58.0],
|
||||
'South America' => [-56.0, -82.0, 13.0, -34.0],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly EscapeRoomRepository $rooms,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('area', null, InputOption::VALUE_REQUIRED, 'Restrict to one country by ISO 3166-1 alpha-2 code, e.g. --area=NL')
|
||||
->addOption('bbox', null, InputOption::VALUE_REQUIRED, 'Restrict to a bounding box: --bbox=south,west,north,east')
|
||||
->addOption('tiled', null, InputOption::VALUE_NONE, 'Split a worldwide import into continental tiles (recommended for the first run)')
|
||||
->addOption('endpoint', null, InputOption::VALUE_REQUIRED, 'Overpass API endpoint', self::DEFAULT_ENDPOINT)
|
||||
->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'Overpass server-side timeout in seconds', '600')
|
||||
->addOption('sleep', null, InputOption::VALUE_REQUIRED, 'Seconds to wait between requests', '5')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Fetch and parse but write nothing to the database');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$endpoint = (string) $input->getOption('endpoint');
|
||||
$timeout = max(30, (int) $input->getOption('timeout'));
|
||||
$sleep = max(0, (int) $input->getOption('sleep'));
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
$queries = $this->buildQueries($input, $timeout);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$io->title('Importing escape rooms from OpenStreetMap');
|
||||
$io->writeln(sprintf('Endpoint: <info>%s</info> · batches: <info>%d</info>%s', $endpoint, \count($queries), $dryRun ? ' · <comment>dry run</comment>' : ''));
|
||||
|
||||
$totals = ['created' => 0, 'updated' => 0, 'locked' => 0, 'noname' => 0, 'nocoords' => 0];
|
||||
$seen = 0;
|
||||
$first = true;
|
||||
|
||||
foreach ($queries as $label => $ql) {
|
||||
if (!$first && $sleep > 0) {
|
||||
$io->writeln(sprintf(' <comment>sleeping %ds…</comment>', $sleep));
|
||||
sleep($sleep);
|
||||
}
|
||||
$first = false;
|
||||
|
||||
$io->section((string) $label);
|
||||
|
||||
$elements = $this->fetch($endpoint, $ql, $timeout, $io);
|
||||
if ($elements === null) {
|
||||
$io->warning('Skipping this batch after a failed request.');
|
||||
continue;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf(' %d element(s) returned', \count($elements)));
|
||||
$batch = 0;
|
||||
|
||||
foreach ($elements as $element) {
|
||||
++$seen;
|
||||
$outcome = $this->upsert($element, $dryRun);
|
||||
++$totals[$outcome];
|
||||
|
||||
if (!$dryRun && $outcome !== 'noname' && $outcome !== 'nocoords' && ++$batch % 400 === 0) {
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->success(sprintf(
|
||||
"%d element(s) processed\n created: %d\n updated: %d\n skipped (locked): %d\n skipped (no name): %d\n skipped (no coords): %d",
|
||||
$seen,
|
||||
$totals['created'],
|
||||
$totals['updated'],
|
||||
$totals['locked'],
|
||||
$totals['noname'],
|
||||
$totals['nocoords'],
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> label => Overpass QL
|
||||
*/
|
||||
private function buildQueries(InputInterface $input, int $timeout): array
|
||||
{
|
||||
$area = $input->getOption('area');
|
||||
$bbox = $input->getOption('bbox');
|
||||
$tiled = (bool) $input->getOption('tiled');
|
||||
|
||||
$header = sprintf('[out:json][timeout:%d];', $timeout);
|
||||
$footer = 'out center tags;';
|
||||
|
||||
if ($area !== null) {
|
||||
$code = strtoupper(trim((string) $area));
|
||||
if (!preg_match('/^[A-Z]{2}$/', $code)) {
|
||||
throw new \InvalidArgumentException('--area expects a two-letter ISO country code, e.g. NL');
|
||||
}
|
||||
|
||||
return [
|
||||
'Country '.$code => sprintf('%s area["ISO3166-1"="%s"][admin_level=2]->.a; nwr["leisure"="escape_game"](area.a); %s', $header, $code, $footer),
|
||||
];
|
||||
}
|
||||
|
||||
if ($bbox !== null) {
|
||||
$box = $this->parseBbox((string) $bbox);
|
||||
|
||||
return [
|
||||
'Bounding box' => sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer),
|
||||
];
|
||||
}
|
||||
|
||||
if ($tiled) {
|
||||
$queries = [];
|
||||
foreach (self::WORLD_TILES as $name => $box) {
|
||||
$queries[$name] = sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer);
|
||||
}
|
||||
|
||||
return $queries;
|
||||
}
|
||||
|
||||
return [
|
||||
'Worldwide' => sprintf('%s nwr["leisure"="escape_game"]; %s', $header, $footer),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{float, float, float, float} south, west, north, east
|
||||
*/
|
||||
private function parseBbox(string $raw): array
|
||||
{
|
||||
$parts = array_map('trim', explode(',', $raw));
|
||||
if (\count($parts) !== 4 || array_filter($parts, static fn ($p) => !is_numeric($p))) {
|
||||
throw new \InvalidArgumentException('--bbox expects four comma-separated numbers: south,west,north,east');
|
||||
}
|
||||
|
||||
return [(float) $parts[0], (float) $parts[1], (float) $parts[2], (float) $parts[3]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private function fetch(string $endpoint, string $ql, int $timeout, SymfonyStyle $io): ?array
|
||||
{
|
||||
for ($attempt = 1; $attempt <= 2; ++$attempt) {
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', $endpoint, [
|
||||
'body' => ['data' => $ql],
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
'timeout' => $timeout + 60,
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
if (isset($data['remark']) && str_contains((string) $data['remark'], 'error')) {
|
||||
$io->warning('Overpass reported: '.$data['remark']);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data['elements'] ?? [];
|
||||
} catch (\Throwable $e) {
|
||||
$io->writeln(sprintf(' <comment>request failed (attempt %d/2): %s</comment>', $attempt, $e->getMessage()));
|
||||
if ($attempt < 2) {
|
||||
sleep(15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $element
|
||||
*
|
||||
* @return 'created'|'updated'|'locked'|'noname'|'nocoords'
|
||||
*/
|
||||
private function upsert(array $element, bool $dryRun): string
|
||||
{
|
||||
$tags = $element['tags'] ?? [];
|
||||
$name = mb_substr(trim((string) ($tags['name'] ?? '')), 0, 255);
|
||||
if ($name === '') {
|
||||
return 'noname';
|
||||
}
|
||||
|
||||
[$lat, $lng] = $this->coords($element);
|
||||
if ($lat === null || $lng === null) {
|
||||
return 'nocoords';
|
||||
}
|
||||
|
||||
$osmType = (string) ($element['type'] ?? 'node');
|
||||
$osmId = (string) ($element['id'] ?? '');
|
||||
|
||||
$room = $this->rooms->findOneByOsm($osmType, $osmId);
|
||||
$isNew = $room === null;
|
||||
|
||||
if (!$isNew && $room->isLocked()) {
|
||||
return 'locked';
|
||||
}
|
||||
|
||||
if ($isNew) {
|
||||
$room = (new EscapeRoom())
|
||||
->setSource(EscapeRoomSource::OSM)
|
||||
->setStatus(EscapeRoomStatus::PUBLISHED)
|
||||
->setOsmType($osmType)
|
||||
->setOsmId($osmId);
|
||||
}
|
||||
|
||||
// Clamp each value to its column width - OSM address tags are free text
|
||||
// and occasionally blow past these (e.g. addr:housenumber "12-14, 16, 18").
|
||||
$room
|
||||
->setName($name)
|
||||
->setVenue($this->firstTag($tags, ['brand', 'operator'], 255))
|
||||
->setStreet($this->firstTag($tags, ['addr:street'], 255))
|
||||
->setHouseNumber($this->firstTag($tags, ['addr:housenumber'], 32))
|
||||
->setPostcode($this->firstTag($tags, ['addr:postcode'], 32))
|
||||
->setCity($this->firstTag($tags, ['addr:city', 'addr:town', 'addr:suburb'], 128))
|
||||
->setCountry($this->normaliseCountry($this->firstTag($tags, ['addr:country'], 8)))
|
||||
->setWebsite($this->firstTag($tags, ['website', 'contact:website', 'url'], 511))
|
||||
->setPhone($this->firstTag($tags, ['phone', 'contact:phone'], 64))
|
||||
->setLatitude($lat)
|
||||
->setLongitude($lng);
|
||||
|
||||
if (!$isNew) {
|
||||
$room->touch();
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->em->persist($room);
|
||||
}
|
||||
|
||||
return $isNew ? 'created' : 'updated';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $element
|
||||
*
|
||||
* @return array{?float, ?float}
|
||||
*/
|
||||
private function coords(array $element): array
|
||||
{
|
||||
if (isset($element['lat'], $element['lon'])) {
|
||||
return [(float) $element['lat'], (float) $element['lon']];
|
||||
}
|
||||
|
||||
if (isset($element['center']['lat'], $element['center']['lon'])) {
|
||||
return [(float) $element['center']['lat'], (float) $element['center']['lon']];
|
||||
}
|
||||
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* First non-empty value among $keys, trimmed and clamped to $maxLen chars.
|
||||
*
|
||||
* @param array<string, mixed> $tags
|
||||
* @param string[] $keys
|
||||
*/
|
||||
private function firstTag(array $tags, array $keys, int $maxLen = 255): ?string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
$value = trim((string) ($tags[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
return mb_substr($value, 0, $maxLen);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normaliseCountry(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = strtoupper(trim($value));
|
||||
|
||||
return preg_match('/^[A-Z]{2}$/', $value) === 1 ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Website\Controller;
|
||||
|
||||
use App\Website\Entity\EscapeRoom;
|
||||
use App\Website\Entity\EscapeRoomReview;
|
||||
use App\Website\Enum\EscapeRoomStatus;
|
||||
use App\Website\Form\AdminEscapeRoomType;
|
||||
use App\Website\Repository\EscapeRoomRepository;
|
||||
use App\Website\Repository\EscapeRoomReviewRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/admin/escape-rooms')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
final class AdminEscapeRoomController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'website_admin_escaperooms', methods: ['GET'])]
|
||||
public function index(Request $request, EscapeRoomRepository $rooms): Response
|
||||
{
|
||||
$filter = EscapeRoomStatus::tryFrom((string) $request->query->get('status'));
|
||||
|
||||
return $this->render('website/admin/escape_room/index.html.twig', [
|
||||
'rooms' => $rooms->findForAdmin($filter),
|
||||
'reviewCounts' => $rooms->reviewCounts(),
|
||||
'filter' => $filter,
|
||||
'counts' => [
|
||||
'all' => \count($rooms->findForAdmin()),
|
||||
EscapeRoomStatus::PENDING->value => $rooms->countByStatus(EscapeRoomStatus::PENDING),
|
||||
EscapeRoomStatus::PUBLISHED->value => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
|
||||
EscapeRoomStatus::HIDDEN->value => $rooms->countByStatus(EscapeRoomStatus::HIDDEN),
|
||||
EscapeRoomStatus::REJECTED->value => $rooms->countByStatus(EscapeRoomStatus::REJECTED),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/new', name: 'website_admin_escaperoom_new', methods: ['GET', 'POST'])]
|
||||
public function new(Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$room = new EscapeRoom();
|
||||
$form = $this->createForm(AdminEscapeRoomType::class, $room);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$em->persist($room);
|
||||
$em->flush();
|
||||
$this->addFlash('success', sprintf('"%s" added.', $room->getName()));
|
||||
|
||||
return $this->redirectToRoute('website_admin_escaperooms');
|
||||
}
|
||||
|
||||
return $this->render('website/admin/escape_room/form.html.twig', [
|
||||
'form' => $form,
|
||||
'room' => $room,
|
||||
'heading' => 'New escape room',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/edit', name: 'website_admin_escaperoom_edit', methods: ['GET', 'POST'], requirements: ['id' => '\d+'])]
|
||||
public function edit(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$form = $this->createForm(AdminEscapeRoomType::class, $room);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$room->touch();
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Changes saved.');
|
||||
|
||||
return $this->redirectToRoute('website_admin_escaperooms');
|
||||
}
|
||||
|
||||
return $this->render('website/admin/escape_room/form.html.twig', [
|
||||
'form' => $form,
|
||||
'room' => $room,
|
||||
'heading' => 'Edit: '.$room->getName(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/status/{status}', name: 'website_admin_escaperoom_moderate', methods: ['POST'], requirements: ['id' => '\d+'])]
|
||||
public function moderate(EscapeRoom $room, string $status, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('moderate_room_'.$room->getId(), (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Invalid CSRF token.');
|
||||
|
||||
return $this->redirectToRoute('website_admin_escaperooms');
|
||||
}
|
||||
|
||||
$target = EscapeRoomStatus::tryFrom($status);
|
||||
if ($target === null) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$room->setStatus($target)->touch();
|
||||
$em->flush();
|
||||
$this->addFlash('success', sprintf('"%s" is now %s.', $room->getName(), $target->label()));
|
||||
|
||||
return $this->redirectToRoute('website_admin_escaperooms', ['status' => $request->request->get('return_to')]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/delete', name: 'website_admin_escaperoom_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
|
||||
public function delete(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('delete_room_'.$room->getId(), (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Invalid CSRF token.');
|
||||
|
||||
return $this->redirectToRoute('website_admin_escaperooms');
|
||||
}
|
||||
|
||||
if (!$room->isDeleted()) {
|
||||
$room->setDeletedAt(new \DateTimeImmutable());
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
$this->addFlash('success', sprintf('"%s" deleted.', $room->getName()));
|
||||
|
||||
return $this->redirectToRoute('website_admin_escaperooms');
|
||||
}
|
||||
|
||||
#[Route('/reviews', name: 'website_admin_escaperoom_reviews', methods: ['GET'])]
|
||||
public function reviews(EscapeRoomReviewRepository $reviews): Response
|
||||
{
|
||||
return $this->render('website/admin/review/index.html.twig', [
|
||||
'reviews' => $reviews->findRecent(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/reviews/{id}/delete', name: 'website_admin_escaperoom_review_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
|
||||
public function deleteReview(EscapeRoomReview $review, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('delete_review_'.$review->getId(), (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Invalid CSRF token.');
|
||||
|
||||
return $this->redirectToRoute('website_admin_escaperoom_reviews');
|
||||
}
|
||||
|
||||
if (!$review->isDeleted()) {
|
||||
$review->setDeletedAt(new \DateTimeImmutable());
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Review removed.');
|
||||
|
||||
return $this->redirectToRoute('website_admin_escaperoom_reviews');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Website\Controller;
|
||||
|
||||
use App\Website\Form\ContactFormType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final class ContactController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/contact', name: 'website_contact', methods: ['GET', 'POST'])]
|
||||
public function contact(Request $request, EntityManagerInterface $entityManager): Response
|
||||
{
|
||||
$form = $this->createForm(ContactFormType::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$entityManager->persist($form->getData());
|
||||
$entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Thanks for reaching out — your message has been sent.');
|
||||
|
||||
return $this->redirectToRoute('website_contact');
|
||||
}
|
||||
|
||||
return $this->render('website/contact.html.twig', [
|
||||
'contactForm' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Website\Controller;
|
||||
|
||||
use App\Tech\Entity\User;
|
||||
use App\Website\Entity\EscapeRoom;
|
||||
use App\Website\Entity\EscapeRoomReview;
|
||||
use App\Website\Enum\EscapeRoomSource;
|
||||
use App\Website\Enum\EscapeRoomStatus;
|
||||
use App\Website\Form\EscapeRoomReviewType;
|
||||
use App\Website\Form\SuggestEscapeRoomType;
|
||||
use App\Website\Repository\EscapeRoomRepository;
|
||||
use App\Website\Repository\EscapeRoomReviewRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
final class EscapeRoomController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/escape-rooms', name: 'website_escaperooms', methods: ['GET'])]
|
||||
public function index(EscapeRoomRepository $rooms): Response
|
||||
{
|
||||
return $this->render('website/escape_room/index.html.twig', [
|
||||
'publishedCount' => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim feed the map and the list are both built from. Cached at the edge for
|
||||
* a few minutes; the dataset only changes when the importer runs or an admin
|
||||
* edits a room.
|
||||
*/
|
||||
#[Route(path: '/escape-rooms/data.json', name: 'website_escaperooms_data', methods: ['GET'])]
|
||||
public function data(EscapeRoomRepository $rooms): JsonResponse
|
||||
{
|
||||
$response = new JsonResponse([
|
||||
'generatedAt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
|
||||
'rooms' => $rooms->findMapData(),
|
||||
]);
|
||||
$response->setPublic();
|
||||
$response->setMaxAge(600);
|
||||
$response->setSharedMaxAge(600);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/escape-rooms/suggest', name: 'website_escaperoom_suggest', methods: ['GET', 'POST'])]
|
||||
public function suggest(Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$room = new EscapeRoom();
|
||||
$form = $this->createForm(SuggestEscapeRoomType::class, $room);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$room->setSource(EscapeRoomSource::USER)
|
||||
->setStatus(EscapeRoomStatus::PENDING);
|
||||
|
||||
if ($this->getUser() instanceof User) {
|
||||
$room->setSubmittedBy($this->getUser());
|
||||
}
|
||||
|
||||
$em->persist($room);
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Thanks! Your suggestion has been sent for review and will appear on the map once approved.');
|
||||
|
||||
return $this->redirectToRoute('website_escaperooms');
|
||||
}
|
||||
|
||||
return $this->render('website/escape_room/suggest.html.twig', [
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/escape-rooms/{id}-{slug}', name: 'website_escaperoom_show', methods: ['GET'], requirements: ['id' => '\d+', 'slug' => '[a-z0-9-]*'])]
|
||||
public function show(EscapeRoom $room, EscapeRoomReviewRepository $reviews): Response
|
||||
{
|
||||
$this->guardVisible($room);
|
||||
|
||||
return $this->renderRoom($room, $reviews, $this->createForm(EscapeRoomReviewType::class));
|
||||
}
|
||||
|
||||
#[Route(path: '/escape-rooms/{id}/reviews', name: 'website_escaperoom_review', methods: ['POST'], requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function review(
|
||||
EscapeRoom $room,
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
EscapeRoomReviewRepository $reviews,
|
||||
): Response {
|
||||
$this->guardVisible($room);
|
||||
|
||||
$review = new EscapeRoomReview();
|
||||
$form = $this->createForm(EscapeRoomReviewType::class, $review);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$review->setEscapeRoom($room)->setAuthor($user);
|
||||
|
||||
$em->persist($review);
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Thanks for your review!');
|
||||
|
||||
return $this->redirectToRoute('website_escaperoom_show', [
|
||||
'id' => $room->getId(),
|
||||
'slug' => $room->getSlug(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->renderRoom($room, $reviews, $form);
|
||||
}
|
||||
|
||||
private function guardVisible(EscapeRoom $room): void
|
||||
{
|
||||
if ($room->isDeleted() || !$room->getStatus()->isPubliclyVisible()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private function renderRoom(EscapeRoom $room, EscapeRoomReviewRepository $reviews, FormInterface $reviewForm): Response
|
||||
{
|
||||
return $this->render('website/escape_room/show.html.twig', [
|
||||
'room' => $room,
|
||||
'reviews' => $reviews->findVisibleForRoom($room),
|
||||
'rating' => $reviews->ratingSummary($room),
|
||||
'reviewForm' => $reviewForm,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Website\Controller;
|
||||
|
||||
use App\Game\Repository\GameRepository;
|
||||
use App\Game\Service\FeedbackService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
@@ -10,17 +12,20 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
final class HomeController extends AbstractController
|
||||
{
|
||||
#[Route(path: '', name: 'website_home')]
|
||||
public function index(): Response
|
||||
public function index(GameRepository $gameRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
'website/home/index.html.twig');
|
||||
return $this->render('website/home/index.html.twig', [
|
||||
'showDevBanner' => !$gameRepository->hasOpenGame(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/briefing', name: 'website_intro')]
|
||||
public function intro(): Response
|
||||
public function intro(FeedbackService $feedbackService, GameRepository $gameRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
'website/home/intro.html.twig');
|
||||
return $this->render('website/home/intro.html.twig', [
|
||||
'feedbackSummary' => $feedbackService->getFeedbackSummary(),
|
||||
'showDevBanner' => !$gameRepository->hasOpenGame(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/looking-for-help', name: 'website_help_wanted')]
|
||||
@@ -30,6 +35,13 @@ final class HomeController extends AbstractController
|
||||
'website/home/help_wanted.html.twig');
|
||||
}
|
||||
|
||||
#[Route(path: '/terms', name: 'website_terms')]
|
||||
public function terms(): Response
|
||||
{
|
||||
return $this->render(
|
||||
'website/home/terms.html.twig');
|
||||
}
|
||||
|
||||
#[Route(path: '/donation/cancel', name: 'website_donation_cancel')]
|
||||
public function donationCancel(): Response
|
||||
{
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Entity;
|
||||
|
||||
use App\Website\Repository\ContactMessageRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ContactMessageRepository::class)]
|
||||
#[ORM\Table(name: 'contact_message')]
|
||||
class ContactMessage
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private ?string $message = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $readAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function setMessage(string $message): static
|
||||
{
|
||||
$this->message = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTimeImmutable $createdAt): static
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReadAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->readAt;
|
||||
}
|
||||
|
||||
public function setReadAt(?\DateTimeImmutable $readAt): static
|
||||
{
|
||||
$this->readAt = $readAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->readAt !== null;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->deletedAt !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Entity;
|
||||
|
||||
use App\Tech\Entity\User;
|
||||
use App\Website\Enum\EscapeRoomSource;
|
||||
use App\Website\Enum\EscapeRoomStatus;
|
||||
use App\Website\Repository\EscapeRoomRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EscapeRoomRepository::class)]
|
||||
#[ORM\Table(name: 'escape_room')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_escape_room_osm', columns: ['osm_type', 'osm_id'])]
|
||||
#[ORM\Index(name: 'idx_escape_room_status', columns: ['status'])]
|
||||
#[ORM\Index(name: 'idx_escape_room_bbox', columns: ['latitude', 'longitude'])]
|
||||
#[ORM\Index(name: 'idx_escape_room_submitted_by', columns: ['submitted_by_id'])]
|
||||
class EscapeRoom
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
/** Operating company / brand, when it differs from the room name. */
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $venue = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 32, nullable: true)]
|
||||
private ?string $houseNumber = null;
|
||||
|
||||
#[ORM\Column(length: 32, nullable: true)]
|
||||
private ?string $postcode = null;
|
||||
|
||||
#[ORM\Column(length: 128, nullable: true)]
|
||||
private ?string $city = null;
|
||||
|
||||
/** ISO 3166-1 alpha-2, upper case. */
|
||||
#[ORM\Column(length: 2, nullable: true)]
|
||||
private ?string $country = null;
|
||||
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
|
||||
private ?string $latitude = null;
|
||||
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
|
||||
private ?string $longitude = null;
|
||||
|
||||
#[ORM\Column(length: 511, nullable: true)]
|
||||
private ?string $website = null;
|
||||
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 16, enumType: EscapeRoomSource::class)]
|
||||
private EscapeRoomSource $source = EscapeRoomSource::ADMIN;
|
||||
|
||||
/** OSM element type: node | way | relation. Set together with osmId for imported rooms. */
|
||||
#[ORM\Column(length: 8, nullable: true)]
|
||||
private ?string $osmType = null;
|
||||
|
||||
#[ORM\Column(type: Types::BIGINT, nullable: true)]
|
||||
private ?string $osmId = null;
|
||||
|
||||
#[ORM\Column(length: 16, enumType: EscapeRoomStatus::class)]
|
||||
private EscapeRoomStatus $status = EscapeRoomStatus::PENDING;
|
||||
|
||||
/** Kept for imported rooms so a re-import never clobbers manual corrections. */
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
private bool $locked = false;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $submittedBy = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null;
|
||||
|
||||
/** @var Collection<int, EscapeRoomReview> */
|
||||
#[ORM\OneToMany(mappedBy: 'escapeRoom', targetEntity: EscapeRoomReview::class, cascade: ['remove'])]
|
||||
private Collection $reviews;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->reviews = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVenue(): ?string
|
||||
{
|
||||
return $this->venue;
|
||||
}
|
||||
|
||||
public function setVenue(?string $venue): static
|
||||
{
|
||||
$this->venue = $venue;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHouseNumber(): ?string
|
||||
{
|
||||
return $this->houseNumber;
|
||||
}
|
||||
|
||||
public function setHouseNumber(?string $houseNumber): static
|
||||
{
|
||||
$this->houseNumber = $houseNumber;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPostcode(): ?string
|
||||
{
|
||||
return $this->postcode;
|
||||
}
|
||||
|
||||
public function setPostcode(?string $postcode): static
|
||||
{
|
||||
$this->postcode = $postcode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountry(): ?string
|
||||
{
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function setCountry(?string $country): static
|
||||
{
|
||||
$this->country = $country ? strtoupper($country) : null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLatitude(): ?string
|
||||
{
|
||||
return $this->latitude;
|
||||
}
|
||||
|
||||
public function setLatitude(string|float|null $latitude): static
|
||||
{
|
||||
$this->latitude = $latitude === null ? null : (string) $latitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLongitude(): ?string
|
||||
{
|
||||
return $this->longitude;
|
||||
}
|
||||
|
||||
public function setLongitude(string|float|null $longitude): static
|
||||
{
|
||||
$this->longitude = $longitude === null ? null : (string) $longitude;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWebsite(): ?string
|
||||
{
|
||||
return $this->website;
|
||||
}
|
||||
|
||||
public function setWebsite(?string $website): static
|
||||
{
|
||||
$this->website = $website;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function setPhone(?string $phone): static
|
||||
{
|
||||
$this->phone = $phone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSource(): EscapeRoomSource
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
|
||||
public function setSource(EscapeRoomSource $source): static
|
||||
{
|
||||
$this->source = $source;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOsmType(): ?string
|
||||
{
|
||||
return $this->osmType;
|
||||
}
|
||||
|
||||
public function setOsmType(?string $osmType): static
|
||||
{
|
||||
$this->osmType = $osmType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOsmId(): ?string
|
||||
{
|
||||
return $this->osmId;
|
||||
}
|
||||
|
||||
public function setOsmId(int|string|null $osmId): static
|
||||
{
|
||||
$this->osmId = $osmId === null ? null : (string) $osmId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): EscapeRoomStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(EscapeRoomStatus $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return $this->locked;
|
||||
}
|
||||
|
||||
public function setLocked(bool $locked): static
|
||||
{
|
||||
$this->locked = $locked;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubmittedBy(): ?User
|
||||
{
|
||||
return $this->submittedBy;
|
||||
}
|
||||
|
||||
public function setSubmittedBy(?User $submittedBy): static
|
||||
{
|
||||
$this->submittedBy = $submittedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function touch(): static
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->deletedAt !== null;
|
||||
}
|
||||
|
||||
/** @return Collection<int, EscapeRoomReview> */
|
||||
public function getReviews(): Collection
|
||||
{
|
||||
return $this->reviews;
|
||||
}
|
||||
|
||||
/** @return EscapeRoomReview[] */
|
||||
public function getVisibleReviews(): array
|
||||
{
|
||||
return $this->reviews
|
||||
->filter(static fn (EscapeRoomReview $r) => !$r->isDeleted())
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getSlug(): string
|
||||
{
|
||||
$slug = strtolower((string) $this->name);
|
||||
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
|
||||
|
||||
return trim($slug, '-') ?: 'room';
|
||||
}
|
||||
|
||||
public function getDisplayAddress(): string
|
||||
{
|
||||
$line = trim(sprintf('%s %s', (string) $this->street, (string) $this->houseNumber));
|
||||
$parts = array_filter([
|
||||
$line !== '' ? $line : null,
|
||||
trim(sprintf('%s %s', (string) $this->postcode, (string) $this->city)) ?: null,
|
||||
$this->country,
|
||||
]);
|
||||
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Entity;
|
||||
|
||||
use App\Tech\Entity\User;
|
||||
use App\Website\Repository\EscapeRoomReviewRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EscapeRoomReviewRepository::class)]
|
||||
#[ORM\Table(name: 'escape_room_review')]
|
||||
#[ORM\Index(name: 'idx_escape_room_review_room', columns: ['escape_room_id'])]
|
||||
#[ORM\Index(name: 'idx_escape_room_review_author', columns: ['author_id'])]
|
||||
class EscapeRoomReview
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: EscapeRoom::class, inversedBy: 'reviews')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?EscapeRoom $escapeRoom = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $author = null;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
#[Assert\Range(min: 1, max: 5)]
|
||||
private int $rating = 0;
|
||||
|
||||
#[ORM\Column(length: 150)]
|
||||
#[Assert\NotBlank(message: 'Give your review a short title')]
|
||||
#[Assert\Length(max: 150)]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
#[Assert\NotBlank(message: 'Write a few words about your experience')]
|
||||
#[Assert\Length(max: 5000)]
|
||||
private ?string $body = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEscapeRoom(): ?EscapeRoom
|
||||
{
|
||||
return $this->escapeRoom;
|
||||
}
|
||||
|
||||
public function setEscapeRoom(?EscapeRoom $escapeRoom): static
|
||||
{
|
||||
$this->escapeRoom = $escapeRoom;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthor(): ?User
|
||||
{
|
||||
return $this->author;
|
||||
}
|
||||
|
||||
public function setAuthor(?User $author): static
|
||||
{
|
||||
$this->author = $author;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRating(): int
|
||||
{
|
||||
return $this->rating;
|
||||
}
|
||||
|
||||
public function setRating(int $rating): static
|
||||
{
|
||||
$this->rating = $rating;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): static
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBody(): ?string
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function setBody(?string $body): static
|
||||
{
|
||||
$this->body = $body;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->deletedAt !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Enum;
|
||||
|
||||
enum EscapeRoomSource: string
|
||||
{
|
||||
case OSM = 'osm';
|
||||
case USER = 'user';
|
||||
case ADMIN = 'admin';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::OSM => 'OpenStreetMap',
|
||||
self::USER => 'Visitor submission',
|
||||
self::ADMIN => 'Added by admin',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Enum;
|
||||
|
||||
enum EscapeRoomStatus: string
|
||||
{
|
||||
/** Submitted by a visitor, awaiting admin moderation. */
|
||||
case PENDING = 'pending';
|
||||
/** Live: shown on the public map and list. */
|
||||
case PUBLISHED = 'published';
|
||||
/** Reviewed by an admin and turned down; kept for de-duplication. */
|
||||
case REJECTED = 'rejected';
|
||||
/** Was public, hidden again by an admin (e.g. room closed down). */
|
||||
case HIDDEN = 'hidden';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PENDING => 'Pending review',
|
||||
self::PUBLISHED => 'Published',
|
||||
self::REJECTED => 'Rejected',
|
||||
self::HIDDEN => 'Hidden',
|
||||
};
|
||||
}
|
||||
|
||||
public function isPubliclyVisible(): bool
|
||||
{
|
||||
return $this === self::PUBLISHED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Website\Form;
|
||||
|
||||
use App\Website\Entity\EscapeRoom;
|
||||
use App\Website\Enum\EscapeRoomSource;
|
||||
use App\Website\Enum\EscapeRoomStatus;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TelType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\Range;
|
||||
|
||||
class AdminEscapeRoomType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('name', TextType::class, ['constraints' => [new NotBlank()]])
|
||||
->add('venue', TextType::class, ['required' => false])
|
||||
->add('street', TextType::class, ['required' => false])
|
||||
->add('houseNumber', TextType::class, ['label' => 'House number', 'required' => false])
|
||||
->add('postcode', TextType::class, ['required' => false])
|
||||
->add('city', TextType::class, ['required' => false])
|
||||
->add('country', CountryType::class, ['placeholder' => '—', 'required' => false])
|
||||
->add('latitude', NumberType::class, [
|
||||
'scale' => 7,
|
||||
'html5' => true,
|
||||
'constraints' => [new NotBlank(), new Range(min: -90, max: 90)],
|
||||
])
|
||||
->add('longitude', NumberType::class, [
|
||||
'scale' => 7,
|
||||
'html5' => true,
|
||||
'constraints' => [new NotBlank(), new Range(min: -180, max: 180)],
|
||||
])
|
||||
->add('website', UrlType::class, ['required' => false, 'default_protocol' => 'https'])
|
||||
->add('phone', TelType::class, ['required' => false])
|
||||
->add('status', EnumType::class, [
|
||||
'class' => EscapeRoomStatus::class,
|
||||
'choice_label' => fn (EscapeRoomStatus $s) => $s->label(),
|
||||
])
|
||||
->add('source', EnumType::class, [
|
||||
'class' => EscapeRoomSource::class,
|
||||
'choice_label' => fn (EscapeRoomSource $s) => $s->label(),
|
||||
])
|
||||
->add('locked', CheckboxType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Locked (protect from OSM re-import)',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => EscapeRoom::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Form;
|
||||
|
||||
use App\Website\Entity\ContactMessage;
|
||||
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
|
||||
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
|
||||
class ContactFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('name', TextType::class, [
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter your name'),
|
||||
new Length(max: 255, maxMessage: 'Your name cannot be longer than {{ limit }} characters'),
|
||||
],
|
||||
])
|
||||
->add('email', EmailType::class, [
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter your email address'),
|
||||
],
|
||||
])
|
||||
->add('message', TextareaType::class, [
|
||||
'attr' => ['rows' => 6],
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter a message'),
|
||||
new Length(max: 5000, maxMessage: 'Your message cannot be longer than {{ limit }} characters'),
|
||||
],
|
||||
])
|
||||
->add('captcha', Recaptcha3Type::class, [
|
||||
'constraints' => new Recaptcha3(),
|
||||
'action_name' => 'contact',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ContactMessage::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Website\Form;
|
||||
|
||||
use App\Website\Entity\EscapeRoomReview;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
|
||||
class EscapeRoomReviewType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('rating', ChoiceType::class, [
|
||||
'placeholder' => 'Choose a rating',
|
||||
'choices' => [
|
||||
'★★★★★ — Outstanding' => 5,
|
||||
'★★★★ — Very good' => 4,
|
||||
'★★★ — Decent' => 3,
|
||||
'★★ — Poor' => 2,
|
||||
'★ — Terrible' => 1,
|
||||
],
|
||||
'constraints' => [new NotBlank(message: 'Pick a rating')],
|
||||
])
|
||||
->add('title', TextType::class, [
|
||||
'attr' => ['maxlength' => 150, 'placeholder' => 'Sum it up in a few words'],
|
||||
])
|
||||
->add('body', TextareaType::class, [
|
||||
'attr' => ['rows' => 5, 'maxlength' => 5000, 'placeholder' => 'What worked, what didn\'t, would you recommend it?'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => EscapeRoomReview::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Website\Form;
|
||||
|
||||
use App\Website\Entity\EscapeRoom;
|
||||
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
|
||||
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TelType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\Range;
|
||||
use Symfony\Component\Validator\Constraints\Regex;
|
||||
|
||||
class SuggestEscapeRoomType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('name', TextType::class, [
|
||||
'label' => 'Room / venue name',
|
||||
'constraints' => [new NotBlank(message: 'Enter the room name'), new Length(max: 255)],
|
||||
])
|
||||
->add('venue', TextType::class, [
|
||||
'label' => 'Operating company (optional)',
|
||||
'required' => false,
|
||||
'constraints' => [new Length(max: 255)],
|
||||
])
|
||||
->add('street', TextType::class, ['required' => false, 'constraints' => [new Length(max: 255)]])
|
||||
->add('houseNumber', TextType::class, ['label' => 'No.', 'required' => false, 'constraints' => [new Length(max: 32)]])
|
||||
->add('postcode', TextType::class, ['required' => false, 'constraints' => [new Length(max: 32)]])
|
||||
->add('city', TextType::class, ['required' => false, 'constraints' => [new Length(max: 128)]])
|
||||
->add('country', CountryType::class, [
|
||||
'placeholder' => 'Select a country',
|
||||
'required' => false,
|
||||
])
|
||||
->add('website', UrlType::class, [
|
||||
'required' => false,
|
||||
'default_protocol' => 'https',
|
||||
'constraints' => [new Length(max: 511)],
|
||||
])
|
||||
->add('phone', TelType::class, ['required' => false, 'constraints' => [new Length(max: 64)]])
|
||||
->add('latitude', HiddenType::class, [
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Drop a pin on the map to set the location'),
|
||||
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid latitude'),
|
||||
new Range(min: -90, max: 90),
|
||||
],
|
||||
])
|
||||
->add('longitude', HiddenType::class, [
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Drop a pin on the map to set the location'),
|
||||
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid longitude'),
|
||||
new Range(min: -180, max: 180),
|
||||
],
|
||||
])
|
||||
->add('captcha', Recaptcha3Type::class, [
|
||||
'mapped' => false,
|
||||
'constraints' => new Recaptcha3(),
|
||||
'action_name' => 'escaperoom_suggest',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => EscapeRoom::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Repository;
|
||||
|
||||
use App\Website\Entity\ContactMessage;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ContactMessage>
|
||||
*/
|
||||
class ContactMessageRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ContactMessage::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ContactMessage[]
|
||||
*/
|
||||
public function findActive(): array
|
||||
{
|
||||
return $this->findBy(['deletedAt' => null], ['createdAt' => 'DESC']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Repository;
|
||||
|
||||
use App\Website\Entity\EscapeRoom;
|
||||
use App\Website\Enum\EscapeRoomStatus;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<EscapeRoom>
|
||||
*/
|
||||
class EscapeRoomRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, EscapeRoom::class);
|
||||
}
|
||||
|
||||
public function save(EscapeRoom $room, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($room);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function findOneByOsm(string $osmType, string $osmId): ?EscapeRoom
|
||||
{
|
||||
return $this->findOneBy(['osmType' => $osmType, 'osmId' => $osmId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight rows for the public map / list. One row per published room with
|
||||
* its aggregated rating. Optionally clipped to a lat/lng bounding box.
|
||||
*
|
||||
* @param array{south: float, west: float, north: float, east: float}|null $bbox
|
||||
*
|
||||
* @return list<array{
|
||||
* id: int, name: string, city: ?string, country: ?string,
|
||||
* lat: float, lng: float, avgRating: ?float, reviewCount: int
|
||||
* }>
|
||||
*/
|
||||
public function findMapData(?array $bbox = null, int $limit = 20000): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->select('r.id, r.name, r.city, r.country, r.latitude, r.longitude')
|
||||
->addSelect('AVG(rev.rating) AS avgRating')
|
||||
->addSelect('COUNT(rev.id) AS reviewCount')
|
||||
->leftJoin('r.reviews', 'rev', 'WITH', 'rev.deletedAt IS NULL')
|
||||
->where('r.deletedAt IS NULL')
|
||||
->andWhere('r.status = :published')
|
||||
->setParameter('published', EscapeRoomStatus::PUBLISHED)
|
||||
->groupBy('r.id')
|
||||
->setMaxResults($limit);
|
||||
|
||||
if ($bbox !== null) {
|
||||
$qb->andWhere('r.latitude BETWEEN :south AND :north')
|
||||
->andWhere('r.longitude BETWEEN :west AND :east')
|
||||
->setParameter('south', $bbox['south'])
|
||||
->setParameter('north', $bbox['north'])
|
||||
->setParameter('west', $bbox['west'])
|
||||
->setParameter('east', $bbox['east']);
|
||||
}
|
||||
|
||||
$rows = $qb->getQuery()->getArrayResult();
|
||||
|
||||
return array_map(static function (array $row): array {
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'name' => (string) $row['name'],
|
||||
'city' => $row['city'],
|
||||
'country' => $row['country'],
|
||||
'lat' => (float) $row['latitude'],
|
||||
'lng' => (float) $row['longitude'],
|
||||
'avgRating' => $row['reviewCount'] > 0 ? round((float) $row['avgRating'], 1) : null,
|
||||
'reviewCount' => (int) $row['reviewCount'],
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
public function countByStatus(EscapeRoomStatus $status): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('r')
|
||||
->select('COUNT(r.id)')
|
||||
->where('r.deletedAt IS NULL')
|
||||
->andWhere('r.status = :status')
|
||||
->setParameter('status', $status)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* All non-deleted rooms, newest first. For the admin list.
|
||||
*
|
||||
* @return EscapeRoom[]
|
||||
*/
|
||||
public function findForAdmin(?EscapeRoomStatus $status = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->where('r.deletedAt IS NULL')
|
||||
->orderBy('r.createdAt', 'DESC');
|
||||
|
||||
if ($status !== null) {
|
||||
$qb->andWhere('r.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible review count per room, in one query, for the admin list.
|
||||
*
|
||||
* @return array<int, int> roomId => count
|
||||
*/
|
||||
public function reviewCounts(): array
|
||||
{
|
||||
$rows = $this->getEntityManager()
|
||||
->createQuery(
|
||||
'SELECT IDENTITY(rev.escapeRoom) AS rid, COUNT(rev.id) AS c '
|
||||
.'FROM App\Website\Entity\EscapeRoomReview rev '
|
||||
.'WHERE rev.deletedAt IS NULL GROUP BY rid'
|
||||
)
|
||||
->getScalarResult();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int) $row['rid']] = (int) $row['c'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Website\Repository;
|
||||
|
||||
use App\Website\Entity\EscapeRoom;
|
||||
use App\Website\Entity\EscapeRoomReview;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<EscapeRoomReview>
|
||||
*/
|
||||
class EscapeRoomReviewRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, EscapeRoomReview::class);
|
||||
}
|
||||
|
||||
public function save(EscapeRoomReview $review, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($review);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EscapeRoomReview[]
|
||||
*/
|
||||
public function findVisibleForRoom(EscapeRoom $room): array
|
||||
{
|
||||
return $this->createQueryBuilder('rev')
|
||||
->where('rev.escapeRoom = :room')
|
||||
->andWhere('rev.deletedAt IS NULL')
|
||||
->setParameter('room', $room)
|
||||
->orderBy('rev.createdAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{avg: ?float, count: int}
|
||||
*/
|
||||
public function ratingSummary(EscapeRoom $room): array
|
||||
{
|
||||
$row = $this->createQueryBuilder('rev')
|
||||
->select('AVG(rev.rating) AS avg, COUNT(rev.id) AS count')
|
||||
->where('rev.escapeRoom = :room')
|
||||
->andWhere('rev.deletedAt IS NULL')
|
||||
->setParameter('room', $room)
|
||||
->getQuery()
|
||||
->getSingleResult();
|
||||
|
||||
$count = (int) $row['count'];
|
||||
|
||||
return [
|
||||
'avg' => $count > 0 ? round((float) $row['avg'], 1) : null,
|
||||
'count' => $count,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent reviews across all rooms, for admin moderation.
|
||||
*
|
||||
* @return EscapeRoomReview[]
|
||||
*/
|
||||
public function findRecent(int $limit = 200): array
|
||||
{
|
||||
return $this->createQueryBuilder('rev')
|
||||
->addSelect('room')
|
||||
->join('rev.escapeRoom', 'room')
|
||||
->where('rev.deletedAt IS NULL')
|
||||
->orderBy('rev.createdAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,40 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* simple-datatables theming to match the admin panel */
|
||||
.datatable-top,
|
||||
.datatable-bottom {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.datatable-input {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.datatable-selector {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.datatable-pagination a,
|
||||
.datatable-pagination button {
|
||||
border-radius: 4px;
|
||||
color: #475569;
|
||||
}
|
||||
.datatable-pagination a:hover,
|
||||
.datatable-pagination button:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.datatable-pagination .datatable-active a,
|
||||
.datatable-pagination .datatable-active button {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-shell {
|
||||
flex-direction: column;
|
||||
@@ -90,6 +124,7 @@
|
||||
<div class="admin-shell">
|
||||
|
||||
{# ── Sidebar ──────────────────────────────────────────────────────── #}
|
||||
{% block admin_sidebar %}
|
||||
<nav class="admin-sidebar">
|
||||
<div class="admin-sidebar-title">Game Admin</div>
|
||||
|
||||
@@ -101,6 +136,8 @@
|
||||
{ route: 'game_admin_users', label: 'Users', icon: '👤' },
|
||||
{ route: 'game_admin_games', label: 'Games', icon: '🎮' },
|
||||
{ route: 'game_admin_sessions', label: 'Sessions', icon: '▶' },
|
||||
{ route: 'game_admin_feedback', label: 'Feedback', icon: '💬' },
|
||||
{ route: 'game_admin_contact', label: 'Contact', icon: '📨' },
|
||||
{ route: 'game_admin_email_log', label: 'Email Log', icon: '✉' },
|
||||
] %}
|
||||
|
||||
@@ -120,11 +157,15 @@
|
||||
</ul>
|
||||
|
||||
<div class="admin-sidebar-footer">
|
||||
<a href="{{ path('website_admin_escaperooms') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none; display: block; margin-bottom: 0.4rem;">
|
||||
Website admin →
|
||||
</a>
|
||||
<a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
|
||||
← Back to game
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{# ── Content ──────────────────────────────────────────────────────── #}
|
||||
<main class="admin-main">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends 'game/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Contact Messages — Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Contact Messages</h1>
|
||||
<span style="color: #64748b; font-size: 0.9rem;">{{ messages|length }} message{{ messages|length != 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<thead>
|
||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Received</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Name</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Email</th>
|
||||
<th data-type="boolean" style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Read</th>
|
||||
<th data-sortable="false" style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in messages %}
|
||||
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||
<td style="padding: 0.75rem 1rem; color: #475569; white-space: nowrap;">{{ entry.createdAt|date('Y-m-d H:i:s') }}</td>
|
||||
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ entry.name }}</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569;">
|
||||
<a href="mailto:{{ entry.email }}" style="color: #3b82f6; text-decoration: none;">{{ entry.email }}</a>
|
||||
</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #16a34a; font-weight: 600;">{{ entry.read ? '✓' : '' }}</td>
|
||||
<td style="padding: 0.75rem 1rem;">
|
||||
<a href="{{ path('game_admin_contact_show', {id: entry.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem; margin-right: 0.75rem;">View</a>
|
||||
<form method="post" action="{{ path('game_admin_contact_delete', {id: entry.id}) }}" style="display: inline;" onsubmit="return confirm('Delete message from {{ entry.name }}?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_contact_' ~ entry.id) }}">
|
||||
<button type="submit" style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0;
|
||||
">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" style="padding: 2rem; text-align: center; color: #94a3b8;">No messages yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends 'game/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Message from {{ message.name }} — Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Message from {{ message.name }}</h1>
|
||||
<a href="{{ path('game_admin_contact') }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">← Back to Contact Messages</a>
|
||||
</div>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 720px;">
|
||||
<dl style="display: grid; grid-template-columns: 140px 1fr; row-gap: 0.75rem; margin: 0 0 1.5rem;">
|
||||
<dt style="color: #64748b; font-weight: 600;">Name</dt>
|
||||
<dd style="margin: 0; color: #0f172a;">{{ message.name }}</dd>
|
||||
|
||||
<dt style="color: #64748b; font-weight: 600;">Email</dt>
|
||||
<dd style="margin: 0;">
|
||||
<a href="mailto:{{ message.email }}" style="color: #3b82f6; text-decoration: none;">{{ message.email }}</a>
|
||||
</dd>
|
||||
|
||||
<dt style="color: #64748b; font-weight: 600;">Received</dt>
|
||||
<dd style="margin: 0; color: #0f172a;">{{ message.createdAt|date('Y-m-d H:i:s') }}</dd>
|
||||
|
||||
<dt style="color: #64748b; font-weight: 600;">Read at</dt>
|
||||
<dd style="margin: 0; color: #0f172a;">
|
||||
{% if message.readAt %}
|
||||
{{ message.readAt|date('Y-m-d H:i:s') }}
|
||||
{% else %}
|
||||
<span style="color: #94a3b8;">Just now</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<hr style="border-color: #f1f5f9; margin-bottom: 1.5rem;">
|
||||
|
||||
<div style="white-space: pre-wrap; color: #0f172a; line-height: 1.6; margin-bottom: 1.5rem;">{{ message.message }}</div>
|
||||
|
||||
<hr style="border-color: #f1f5f9; margin-bottom: 1.5rem;">
|
||||
|
||||
<form method="post" action="{{ path('game_admin_contact_delete', {id: message.id}) }}" onsubmit="return confirm('Delete message from {{ message.name }}?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_contact_' ~ message.id) }}">
|
||||
<button type="submit" style="
|
||||
background: none;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
">Delete Message</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||
<table style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<table class="admin-datatable" style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<thead>
|
||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends 'game/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Feedback — Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Feedback</h1>
|
||||
<span style="color: #64748b; font-size: 0.9rem;">{{ entries|length }} submission{{ entries|length != 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
{% set stats = [
|
||||
{ label: 'Submissions', value: summary.count, color: '#64748b' },
|
||||
{ label: 'Avg. Difficulty', value: summary.avgDifficulty is not null ? summary.avgDifficulty ~ '/10' : '—', color: '#3b82f6' },
|
||||
{ label: 'Avg. Entertainment', value: summary.avgEntertaining is not null ? summary.avgEntertaining ~ '/10' : '—', color: '#8b5cf6' },
|
||||
{ label: 'Avg. Theme', value: summary.avgTheme is not null ? summary.avgTheme ~ '/10' : '—', color: '#10b981' },
|
||||
] %}
|
||||
|
||||
{% for stat in stats %}
|
||||
<div style="
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
border-left: 4px solid {{ stat.color }};
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||
">
|
||||
<div style="font-size: 1.75rem; font-weight: 700; color: {{ stat.color }};">{{ stat.value }}</div>
|
||||
<div style="font-size: 0.8rem; color: #64748b; margin-top: 0.25rem;">{{ stat.label }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<thead>
|
||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Game</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Session</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Player</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Difficulty</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Entertainment</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Theme</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ entry.session.game.name }}</td>
|
||||
<td style="padding: 0.75rem 1rem;">
|
||||
<a href="{{ path('game_admin_view_session', {session: entry.session.id}) }}" style="color: #3b82f6; text-decoration: none;">#{{ entry.session.id }}</a>
|
||||
</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569;">{{ entry.player.user.username }}</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569;">{{ entry.difficulty ?? '—' }}</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569;">{{ entry.entertaining ?? '—' }}</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569;">{{ entry.theme ?? '—' }}</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569; max-width: 320px; white-space: pre-wrap;">{{ entry.text ?? '' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" style="padding: 2rem; text-align: center; color: #94a3b8;">No feedback submitted yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,18 @@
|
||||
{% block admin_body %}
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Games</h1>
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<span style="color: #64748b; font-size: 0.9rem;">{{ games|length }} total</span>
|
||||
<a href="{{ path('game_admin_game_new') }}" style="
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
">+ New Game</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||
@@ -46,7 +57,8 @@
|
||||
</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569;">{{ game.sessions|length }}</td>
|
||||
<td style="padding: 0.75rem 1rem;">
|
||||
<a href="{{ path('game_admin_game_edit', {id: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">Edit</a>
|
||||
<a href="{{ path('game_admin_game_edit', {id: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem; margin-right: 0.75rem;">Edit</a>
|
||||
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">Hints</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends 'game/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}New Game — Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<a href="{{ path('game_admin_games') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Games</a>
|
||||
</div>
|
||||
|
||||
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">New game</h1>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 500px;">
|
||||
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
|
||||
|
||||
<div>
|
||||
{{ form_label(form.name, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.name, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
{{ form_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.numberOfPlayers, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.numberOfPlayers, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
{{ form_errors(form.numberOfPlayers) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.status, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.status, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
{{ form_errors(form.status) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.totalTime, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.totalTime, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
<small style="color: #64748b; font-size: 0.8rem;">In seconds — e.g. 3600 = 1 hour</small>
|
||||
{{ form_errors(form.totalTime) }}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
|
||||
<button type="submit" style="
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
">Create game</button>
|
||||
<a href="{{ path('game_admin_games') }}" style="
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
">Cancel</a>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends 'game/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Edit Hint — {{ game.name }} — Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Hints — {{ game.name }}</a>
|
||||
</div>
|
||||
|
||||
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Edit hint</h1>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 560px;">
|
||||
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
|
||||
|
||||
<div>
|
||||
{{ form_label(form.condition, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.condition, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
{{ form_errors(form.condition) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.thresholdSeconds, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.thresholdSeconds, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
<small style="color: #64748b; font-size: 0.8rem;">Seconds since the game started — e.g. 300 = 5 minutes</small>
|
||||
{{ form_errors(form.thresholdSeconds) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.message, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.message, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
{{ form_errors(form.message) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.sortOrder, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.sortOrder, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
<small style="color: #64748b; font-size: 0.8rem;">Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later</small>
|
||||
{{ form_errors(form.sortOrder) }}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
|
||||
<button type="submit" style="
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
">Save changes</button>
|
||||
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
">Cancel</a>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends 'game/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Hints — {{ game.name }} — Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<a href="{{ path('game_admin_games') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Games</a>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Hints — {{ game.name }}</h1>
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<span style="color: #64748b; font-size: 0.9rem;">{{ hints|length }} total</span>
|
||||
<a href="{{ path('game_admin_hint_new', {game: game.id}) }}" style="
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
">+ New Hint</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="color: #64748b; font-size: 0.85rem; margin-top: -1rem; margin-bottom: 1.5rem;">
|
||||
Hints are checked in order. For each row, if its condition is still unresolved for a player, the
|
||||
highest threshold reached among hints sharing that same condition fires and the check stops there
|
||||
for that pass — later rows are only reached once every condition before them is resolved.
|
||||
</p>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||
<table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<thead>
|
||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Order</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Condition</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Threshold</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Message</th>
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for hint in hints %}
|
||||
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||
<td style="padding: 0.75rem 1rem; color: #94a3b8;">{{ hint.sortOrder }}</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #0f172a;">{{ hint.condition.label }}</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569; white-space: nowrap;">{{ (hint.thresholdSeconds / 60)|round(1, 'floor') }} min</td>
|
||||
<td style="padding: 0.75rem 1rem; color: #475569; max-width: 420px;">{{ hint.message|length > 90 ? hint.message|slice(0, 90) ~ '…' : hint.message }}</td>
|
||||
<td style="padding: 0.75rem 1rem; white-space: nowrap;">
|
||||
<a href="{{ path('game_admin_hint_edit', {game: game.id, id: hint.id}) }}" style="
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.75rem;
|
||||
">Edit</a>
|
||||
<form method="post" action="{{ path('game_admin_hint_delete', {game: game.id, id: hint.id}) }}" style="display: inline;" onsubmit="return confirm('Delete this hint?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_hint_' ~ hint.id) }}">
|
||||
<button type="submit" style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0;
|
||||
">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" style="padding: 2rem; text-align: center; color: #94a3b8;">No hints configured for this game yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends 'game/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}New Hint — {{ game.name }} — Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Hints — {{ game.name }}</a>
|
||||
</div>
|
||||
|
||||
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">New hint</h1>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 560px;">
|
||||
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
|
||||
|
||||
<div>
|
||||
{{ form_label(form.condition, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.condition, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
{{ form_errors(form.condition) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.thresholdSeconds, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.thresholdSeconds, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
<small style="color: #64748b; font-size: 0.8rem;">Seconds since the game started — e.g. 300 = 5 minutes</small>
|
||||
{{ form_errors(form.thresholdSeconds) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.message, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.message, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
{{ form_errors(form.message) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.sortOrder, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
|
||||
{{ form_widget(form.sortOrder, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
|
||||
<small style="color: #64748b; font-size: 0.8rem;">Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later</small>
|
||||
{{ form_errors(form.sortOrder) }}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
|
||||
<button type="submit" style="
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
">Create hint</button>
|
||||
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
">Cancel</a>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||
<table style="width: 100%; min-width: 700px; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<table class="admin-datatable" style="width: 100%; min-width: 700px; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<thead>
|
||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||
<table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<thead>
|
||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||
|
||||
@@ -20,8 +20,15 @@
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('create_session') }}">
|
||||
<select name="game_id" class="form-select mb-3">
|
||||
{% for game in availableGames %}
|
||||
{% set totalTime = null %}
|
||||
{% for setting in game.settings %}
|
||||
{% if setting.name.value == 'totalTime' %}
|
||||
{% set totalTime = setting.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<option value="{{ game.id }}">
|
||||
{{ game.name }} ({{ game.numberOfPlayers }} players)
|
||||
{{ game.name }} ({{ game.numberOfPlayers }} players{% if totalTime %}, {{ (totalTime / 60)|round }} min{% endif %})
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
[{{ game.status.value }}]
|
||||
{% endif %}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<h4>{{ session.game.name }}</h4>
|
||||
<p>Welcome to the game! Please wait for all players to join and signal they are ready to start.</p>
|
||||
|
||||
<p><strong>This game will end when you disable the virus by having its source files deleted from the server.</strong></p>
|
||||
|
||||
<div class="game-info">
|
||||
Please keep the following things in mind:
|
||||
<ul>
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('game_dashboard') }}">{{ 'nav.game'|trans }}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('website_escaperooms') }}">{{ 'nav.escaperooms'|trans }}</a>
|
||||
</li>
|
||||
{% if app.user %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<li class="nav-item">
|
||||
@@ -67,7 +70,11 @@
|
||||
<footer class="site-footer py-4">
|
||||
<div class="container text-center small">
|
||||
<div class="mb-1">
|
||||
<a href="{{ path('website_contact') }}" class="link-light">{{ 'footer.contact'|trans }}</a>
|
||||
·
|
||||
<a href="{{ path('website_help_wanted') }}" class="link-light">{{ 'footer.help_wanted'|trans }}</a>
|
||||
·
|
||||
<a href="{{ path('website_terms') }}" class="link-light">{{ 'footer.terms'|trans }}</a>
|
||||
</div>
|
||||
© {{ "now"|date("Y") }} {{ 'site.name'|trans }}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
{{ form_row(registrationForm.username) }}
|
||||
{{ form_row(registrationForm.plainPassword) }}
|
||||
{{ form_row(registrationForm.agreeTerms) }}
|
||||
<p class="small text-muted mt-n2 mb-3">
|
||||
<a href="{{ path('website_terms') }}" target="_blank" rel="noopener">Read the Terms and Conditions</a>
|
||||
</p>
|
||||
{{ form_row(registrationForm.marketingOptIn) }}
|
||||
{{ form_row(registrationForm.captcha) }}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends 'game/admin/base.html.twig' %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<style>
|
||||
.er-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem 1.25rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
.er-form .er-field-wide { grid-column: 1 / -1; }
|
||||
.er-form label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
color: #374151;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.er-form input[type=text],
|
||||
.er-form input[type=number],
|
||||
.er-form input[type=url],
|
||||
.er-form input[type=tel],
|
||||
.er-form select,
|
||||
.er-form textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.er-form .form-error,
|
||||
.er-form ul { color: #b91c1c; font-size: 0.8rem; margin: 0.25rem 0 0; padding-left: 1rem; }
|
||||
.er-btn {
|
||||
padding: 0.55rem 1.2rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.er-btn--ghost { background: #f1f5f9; color: #475569; }
|
||||
.er-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.er-badge--published { background: #dcfce7; color: #166534; }
|
||||
.er-badge--pending { background: #fef9c3; color: #854d0e; }
|
||||
.er-badge--rejected { background: #fee2e2; color: #991b1b; }
|
||||
.er-badge--hidden { background: #e2e8f0; color: #475569; }
|
||||
.er-actions form { display: inline; }
|
||||
.er-actions button,
|
||||
.er-actions a {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-right: 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.er-actions button.er-danger { color: #ef4444; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_sidebar %}
|
||||
<nav class="admin-sidebar">
|
||||
<div class="admin-sidebar-title">Website Admin</div>
|
||||
|
||||
<ul class="admin-nav-list">
|
||||
{% set current = app.request.attributes.get('_route') %}
|
||||
{% set navItems = [
|
||||
{ route: 'website_admin_escaperooms', label: 'Escape Rooms', icon: '📍' },
|
||||
{ route: 'website_admin_escaperoom_reviews', label: 'Reviews', icon: '⭐' },
|
||||
] %}
|
||||
|
||||
{% for item in navItems %}
|
||||
{% set isActive = current starts with item.route %}
|
||||
<li>
|
||||
<a href="{{ path(item.route) }}" class="admin-nav-link" style="
|
||||
color: {{ isActive ? '#f1f5f9' : '#94a3b8' }};
|
||||
background: {{ isActive ? '#334155' : 'transparent' }};
|
||||
border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }};
|
||||
">
|
||||
<span>{{ item.icon }}</span>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="admin-sidebar-footer">
|
||||
<a href="{{ path('game_admin_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none; display: block; margin-bottom: 0.4rem;">
|
||||
← Game admin
|
||||
</a>
|
||||
<a href="{{ path('website_home') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
|
||||
← Back to site
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends 'website/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}{{ heading }} — Website Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<a href="{{ path('website_admin_escaperooms') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Escape Rooms</a>
|
||||
</div>
|
||||
|
||||
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">{{ heading }}</h1>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem;">
|
||||
{{ form_start(form, {attr: {class: 'er-form'}}) }}
|
||||
<div class="er-field-wide">{{ form_row(form.name) }}</div>
|
||||
<div class="er-field-wide">{{ form_row(form.venue) }}</div>
|
||||
|
||||
{{ form_row(form.street) }}
|
||||
{{ form_row(form.houseNumber) }}
|
||||
{{ form_row(form.postcode) }}
|
||||
{{ form_row(form.city) }}
|
||||
{{ form_row(form.country) }}
|
||||
<div></div>
|
||||
|
||||
{{ form_row(form.latitude) }}
|
||||
{{ form_row(form.longitude) }}
|
||||
|
||||
{{ form_row(form.website) }}
|
||||
{{ form_row(form.phone) }}
|
||||
|
||||
{{ form_row(form.status) }}
|
||||
{{ form_row(form.source) }}
|
||||
|
||||
<div class="er-field-wide">{{ form_row(form.locked) }}</div>
|
||||
|
||||
<div class="er-field-wide" style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
|
||||
<button type="submit" class="er-btn">Save</button>
|
||||
<a href="{{ path('website_admin_escaperooms') }}" class="er-btn er-btn--ghost">Cancel</a>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,93 @@
|
||||
{% extends 'website/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Escape Rooms — Website Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
||||
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Escape Rooms</h1>
|
||||
<a href="{{ path('website_admin_escaperoom_new') }}" class="er-btn">+ New room</a>
|
||||
</div>
|
||||
|
||||
{% set tabs = {
|
||||
'': 'All (' ~ counts.all ~ ')',
|
||||
'pending': 'Pending (' ~ counts.pending ~ ')',
|
||||
'published': 'Published (' ~ counts.published ~ ')',
|
||||
'hidden': 'Hidden (' ~ counts.hidden ~ ')',
|
||||
'rejected': 'Rejected (' ~ counts.rejected ~ ')',
|
||||
} %}
|
||||
<div style="margin-bottom: 1rem; display: flex; gap: 0.4rem; flex-wrap: wrap;">
|
||||
{% for value, label in tabs %}
|
||||
{% set active = (filter is null and value == '') or (filter and filter.value == value) %}
|
||||
<a href="{{ path('website_admin_escaperooms', value ? {status: value} : {}) }}"
|
||||
style="padding: 0.3rem 0.7rem; border-radius: 6px; font-size: 0.82rem; text-decoration: none;
|
||||
background: {{ active ? '#3b82f6' : '#e2e8f0' }}; color: {{ active ? '#fff' : '#475569' }};">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||
<table class="admin-datatable" style="width: 100%; min-width: 900px; border-collapse: collapse; font-size: 0.88rem;">
|
||||
<thead>
|
||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Name</th>
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">City</th>
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Country</th>
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Source</th>
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Status</th>
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Reviews</th>
|
||||
<th data-sortable="false" style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for room in rooms %}
|
||||
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||
<td style="padding: 0.7rem 0.9rem; font-weight: 500; color: #0f172a;">
|
||||
{% if room.status.isPubliclyVisible %}
|
||||
<a href="{{ path('website_escaperoom_show', {id: room.id, slug: room.slug}) }}" target="_blank" rel="noopener" style="color: #0f172a;">{{ room.name }}</a>
|
||||
{% else %}
|
||||
{{ room.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.city }}</td>
|
||||
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.country }}</td>
|
||||
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.source.value }}</td>
|
||||
<td style="padding: 0.7rem 0.9rem;">
|
||||
<span class="er-badge er-badge--{{ room.status.value }}">{{ room.status.label }}</span>
|
||||
</td>
|
||||
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ reviewCounts[room.id]|default(0) }}</td>
|
||||
<td style="padding: 0.7rem 0.9rem;" class="er-actions">
|
||||
<a href="{{ path('website_admin_escaperoom_edit', {id: room.id}) }}">Edit</a>
|
||||
{% if not room.status.isPubliclyVisible %}
|
||||
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'published'}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
|
||||
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
|
||||
<button type="submit">Publish</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'hidden'}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
|
||||
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
|
||||
<button type="submit">Hide</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if room.status.value != 'rejected' %}
|
||||
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'rejected'}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
|
||||
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
|
||||
<button type="submit">Reject</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ path('website_admin_escaperoom_delete', {id: room.id}) }}" onsubmit="return confirm('Delete {{ room.name|e('js') }}?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_room_' ~ room.id) }}">
|
||||
<button type="submit" class="er-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" style="padding: 2rem; text-align: center; color: #94a3b8;">No rooms.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends 'website/admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Reviews — Website Admin{% endblock %}
|
||||
|
||||
{% block admin_body %}
|
||||
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Recent reviews</h1>
|
||||
|
||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||
<table class="admin-datatable" style="width: 100%; min-width: 820px; border-collapse: collapse; font-size: 0.88rem;">
|
||||
<thead>
|
||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Date</th>
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Room</th>
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Rating</th>
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Title</th>
|
||||
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Author</th>
|
||||
<th data-sortable="false" style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for review in reviews %}
|
||||
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||
<td style="padding: 0.7rem 0.9rem; color: #475569; white-space: nowrap;">{{ review.createdAt|date('Y-m-d') }}</td>
|
||||
<td style="padding: 0.7rem 0.9rem; color: #0f172a;">
|
||||
<a href="{{ path('website_admin_escaperoom_edit', {id: review.escapeRoom.id}) }}" style="color: #0f172a;">{{ review.escapeRoom.name }}</a>
|
||||
</td>
|
||||
<td style="padding: 0.7rem 0.9rem;" data-order="{{ review.rating }}">{{ review.rating }} ★</td>
|
||||
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ review.title }}</td>
|
||||
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ review.author ? review.author.username : '—' }}</td>
|
||||
<td style="padding: 0.7rem 0.9rem;" class="er-actions">
|
||||
<form method="post" action="{{ path('website_admin_escaperoom_review_delete', {id: review.id}) }}" onsubmit="return confirm('Remove this review?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_review_' ~ review.id) }}">
|
||||
<button type="submit" class="er-danger">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" style="padding: 2rem; text-align: center; color: #94a3b8;">No reviews yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends 'layout/site.html.twig' %}
|
||||
|
||||
{% block title %}Contact | {{ 'site.name'|trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7">
|
||||
<div class="text-center mb-4">
|
||||
<h1 class="display-6 fw-bold">Contact</h1>
|
||||
<p class="lead text-secondary">Got a question, a bug to report, or just want to say hi?</p>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
{{ form_errors(contactForm) }}
|
||||
|
||||
{{ form_start(contactForm) }}
|
||||
{{ form_row(contactForm.name) }}
|
||||
{{ form_row(contactForm.email) }}
|
||||
{{ form_row(contactForm.message) }}
|
||||
{{ form_row(contactForm.captcha) }}
|
||||
|
||||
<div class="d-grid mt-3">
|
||||
<button type="submit" class="btn btn-primary">Send Message</button>
|
||||
</div>
|
||||
{{ form_end(contactForm) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends 'layout/site.html.twig' %}
|
||||
|
||||
{% block title %}Escape room map | {{ 'site.name'|trans }}{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_link_tags('escaperoom-map') }}
|
||||
<style>
|
||||
#escaperoom-map {
|
||||
height: 70vh;
|
||||
min-height: 420px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
z-index: 0;
|
||||
}
|
||||
.escaperoom-list-wrap {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .07);
|
||||
overflow: hidden;
|
||||
}
|
||||
.escaperoom-list-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: .9rem;
|
||||
}
|
||||
.escaperoom-list-wrap th,
|
||||
.escaperoom-list-wrap td {
|
||||
padding: .6rem .9rem;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% set detailTemplate = path('website_escaperoom_show', {id: 987654321, slug: 'zzslugzz'})|replace({'987654321': '__ID__', 'zzslugzz': '__SLUG__'}) %}
|
||||
|
||||
<div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3">
|
||||
<div>
|
||||
<h1 class="display-6 fw-bold mb-1">Physical escape rooms</h1>
|
||||
<p class="text-secondary mb-0">Zoom in to find rooms near you, then open one to read and leave reviews.</p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div id="escaperoom-status" class="small text-secondary">{{ publishedCount }} rooms on the map</div>
|
||||
<a href="{{ path('website_escaperoom_suggest') }}" class="btn btn-sm btn-outline-primary mt-1">Suggest a room</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="escaperoom-map"
|
||||
data-endpoint="{{ path('website_escaperooms_data') }}"
|
||||
data-detail-template="{{ detailTemplate }}"
|
||||
class="mb-4"></div>
|
||||
|
||||
<div class="escaperoom-list-wrap">
|
||||
<table id="escaperoom-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>City</th>
|
||||
<th>Country</th>
|
||||
<th>Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="text-center text-secondary py-4">Loading rooms…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags('escaperoom-map') }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,122 @@
|
||||
{% extends 'layout/site.html.twig' %}
|
||||
|
||||
{% block title %}{{ room.name }} | {{ 'site.name'|trans }}{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_link_tags('escaperoom-detail') }}
|
||||
<style>
|
||||
#escaperoom-detail-map {
|
||||
height: 260px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb small">
|
||||
<li class="breadcrumb-item"><a href="{{ path('website_escaperooms') }}">Escape room map</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ room.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<h1 class="h3 fw-bold mb-1">{{ room.name }}</h1>
|
||||
{% if room.venue %}<p class="text-secondary mb-2">{{ room.venue }}</p>{% endif %}
|
||||
|
||||
<p class="mb-1">
|
||||
{% if rating.count > 0 %}
|
||||
<span class="fw-semibold">{{ rating.avg }} ★</span>
|
||||
<span class="text-secondary">· {{ rating.count }} review{{ rating.count != 1 ? 's' : '' }}</span>
|
||||
{% else %}
|
||||
<span class="text-secondary">No reviews yet</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if room.displayAddress %}
|
||||
<p class="mb-1">{{ room.displayAddress }}</p>
|
||||
{% endif %}
|
||||
<p class="mb-3 small">
|
||||
{% if room.website %}<a href="{{ room.website }}" target="_blank" rel="noopener nofollow">Website</a> · {% endif %}
|
||||
{% if room.phone %}{{ room.phone }} · {% endif %}
|
||||
<a href="https://www.openstreetmap.org/?mlat={{ room.latitude }}&mlon={{ room.longitude }}#map=17/{{ room.latitude }}/{{ room.longitude }}"
|
||||
target="_blank" rel="noopener">Open in OpenStreetMap</a>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 class="h5 mb-3">Reviews</h2>
|
||||
|
||||
{% if app.user %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h3 class="h6 mb-3">Leave a review</h3>
|
||||
{{ form_start(reviewForm, {action: path('website_escaperoom_review', {id: room.id})}) }}
|
||||
<div class="mb-2">
|
||||
{{ form_label(reviewForm.rating) }}
|
||||
{{ form_widget(reviewForm.rating, {attr: {class: 'form-select'}}) }}
|
||||
{{ form_errors(reviewForm.rating) }}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
{{ form_label(reviewForm.title) }}
|
||||
{{ form_widget(reviewForm.title, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(reviewForm.title) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_label(reviewForm.body) }}
|
||||
{{ form_widget(reviewForm.body, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(reviewForm.body) }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post review</button>
|
||||
{{ form_end(reviewForm) }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="small"><a href="{{ path('app_login') }}">Log in</a> to leave a review.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for review in reviews %}
|
||||
<article class="mb-3 pb-3 border-bottom">
|
||||
<div class="d-flex justify-content-between">
|
||||
<strong>{{ review.title }}</strong>
|
||||
<span>
|
||||
{%- for i in 1..5 -%}
|
||||
<span class="{{ i <= review.rating ? 'text-warning' : 'text-secondary' }}">★</span>
|
||||
{%- endfor -%}
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-secondary mb-1">
|
||||
{{ review.author ? review.author.username : 'Anonymous' }} · {{ review.createdAt|date('Y-m-d') }}
|
||||
</div>
|
||||
<p class="mb-0">{{ review.body|nl2br }}</p>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="text-secondary">Be the first to review this room.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<div id="escaperoom-detail-map"
|
||||
data-lat="{{ room.latitude }}"
|
||||
data-lng="{{ room.longitude }}"
|
||||
class="mb-3"></div>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body small">
|
||||
<div class="text-secondary">Coordinates</div>
|
||||
<div class="mb-2">{{ room.latitude }}, {{ room.longitude }}</div>
|
||||
<div class="text-secondary">Listing source</div>
|
||||
<div>{{ room.source.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags('escaperoom-detail') }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends 'layout/site.html.twig' %}
|
||||
|
||||
{% block title %}Suggest an escape room | {{ 'site.name'|trans }}{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_link_tags('escaperoom-detail') }}
|
||||
<style>
|
||||
#escaperoom-suggest-map {
|
||||
height: 340px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb small">
|
||||
<li class="breadcrumb-item"><a href="{{ path('website_escaperooms') }}">Escape room map</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Suggest a room</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="h3 fw-bold mb-1">Suggest an escape room</h1>
|
||||
<p class="text-secondary">Know a physical escape room that isn't on the map? Add it here — an admin will review it before it goes live.</p>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
{{ form_errors(form) }}
|
||||
{{ form_start(form) }}
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.name) }}
|
||||
{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.name) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.venue) }}
|
||||
{{ form_widget(form.venue, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.venue) }}
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-9 mb-3">
|
||||
{{ form_label(form.street) }}
|
||||
{{ form_widget(form.street, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
<div class="col-3 mb-3">
|
||||
{{ form_label(form.houseNumber) }}
|
||||
{{ form_widget(form.houseNumber, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-4 mb-3">
|
||||
{{ form_label(form.postcode) }}
|
||||
{{ form_widget(form.postcode, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
<div class="col-8 mb-3">
|
||||
{{ form_label(form.city) }}
|
||||
{{ form_widget(form.city, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.country) }}
|
||||
{{ form_widget(form.country, {attr: {class: 'form-select'}}) }}
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-6 mb-3">
|
||||
{{ form_label(form.website) }}
|
||||
{{ form_widget(form.website, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
<div class="col-sm-6 mb-3">
|
||||
{{ form_label(form.phone) }}
|
||||
{{ form_widget(form.phone, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label">Location <span class="text-secondary fw-normal">— click the map to drop a pin</span></label>
|
||||
<div id="escaperoom-suggest-map" class="mb-2"></div>
|
||||
{{ form_widget(form.latitude) }}
|
||||
{{ form_widget(form.longitude) }}
|
||||
{{ form_errors(form.latitude) }}
|
||||
{{ form_errors(form.longitude) }}
|
||||
|
||||
<div class="mt-3">
|
||||
{{ form_row(form.captcha) }}
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-3">
|
||||
<button type="submit" class="btn btn-primary">Submit for review</button>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags('escaperoom-detail') }}
|
||||
{% endblock %}
|
||||
@@ -44,6 +44,40 @@
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, .35);
|
||||
pointer-events: none;
|
||||
}
|
||||
.how-it-works-kicker {
|
||||
letter-spacing: .1em;
|
||||
color: var(--brand-teal-dark, #1c5a70);
|
||||
}
|
||||
.how-step-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(47, 143, 174, .12);
|
||||
color: var(--brand-teal-dark, #1c5a70);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.how-step-connector {
|
||||
position: relative;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.how-step-connector:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: calc(50% + 44px);
|
||||
right: calc(-50% + 44px);
|
||||
height: 2px;
|
||||
background: rgba(47, 143, 174, .25);
|
||||
}
|
||||
}
|
||||
.world-map-strip {
|
||||
background: rgba(47, 143, 174, .06);
|
||||
border: 1px solid rgba(47, 143, 174, .2);
|
||||
border-radius: .75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -66,14 +100,69 @@
|
||||
<div class="col-lg-5 text-center">
|
||||
<div class="dev-watermark-wrap">
|
||||
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 260px;">
|
||||
{% if showDevBanner %}
|
||||
<span class="dev-watermark-badge">In Development</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-4">
|
||||
<img src="{{ asset('images/logo.png') }}" alt="{{ 'site.name'|trans }}" height="100" class="mb-3">
|
||||
<p class="col-lg-8 mx-auto text-secondary">{{ 'home.description'|trans }}</p>
|
||||
<div class="mb-5">
|
||||
<div class="text-center mb-4">
|
||||
<span class="text-uppercase small fw-semibold how-it-works-kicker">How it works</span>
|
||||
<h2 class="fw-bold">One team — even if you're not in the same room</h2>
|
||||
<p class="col-lg-8 mx-auto text-secondary">
|
||||
Every game on EscapePage is built to be played online, together. You don't need to travel,
|
||||
book a room, or even hop on a call — everyone plays from their own screen, and the
|
||||
game has a built-in chat to keep your team talking.
|
||||
</p>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-3 col-6 how-step-connector">
|
||||
<div class="text-center h-100 p-3">
|
||||
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-people-fill"></i></div>
|
||||
<h3 class="h6 fw-bold">1. Invite your team</h3>
|
||||
<p class="small text-secondary mb-0">
|
||||
Get two or three friends to join the same game — no matter where in the
|
||||
world they are.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6 how-step-connector">
|
||||
<div class="text-center h-100 p-3">
|
||||
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-display"></i></div>
|
||||
<h3 class="h6 fw-bold">2. Everyone logs in</h3>
|
||||
<p class="small text-secondary mb-0">
|
||||
Each player opens the game in their own browser, from wherever they are. No app,
|
||||
no download, no travel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6 how-step-connector">
|
||||
<div class="text-center h-100 p-3">
|
||||
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-chat-dots-fill"></i></div>
|
||||
<h3 class="h6 fw-bold">3. Chat in the game</h3>
|
||||
<p class="small text-secondary mb-0">
|
||||
Each of you sees different clues. Use the built-in chat to share what you find and
|
||||
connect the dots — no separate call needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6 how-step-connector">
|
||||
<div class="text-center h-100 p-3">
|
||||
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-hourglass-split"></i></div>
|
||||
<h3 class="h6 fw-bold">4. Beat the clock</h3>
|
||||
<p class="small text-secondary mb-0">
|
||||
Work together in real time to crack it before the timer runs out — you win
|
||||
or lose as one team.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="world-map-strip text-center p-3 p-md-4 mt-4">
|
||||
<i class="bi bi-globe-americas me-2" style="color: var(--brand-teal-dark, #1c5a70);"></i>
|
||||
<span class="text-secondary">Amsterdam, Auckland, or anywhere in between — as long as you're online, you can play together.</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
color: var(--brand-teal-light, #6fb8d1);
|
||||
padding-right: .1em;
|
||||
}
|
||||
.briefing-feedback {
|
||||
background: rgba(111, 184, 209, .08);
|
||||
border: 1px solid #1c5a70;
|
||||
border-radius: .5rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.briefing-feedback-stat {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #6fb8d1;
|
||||
}
|
||||
.dev-watermark-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -70,7 +81,9 @@
|
||||
<div class="text-center mb-4">
|
||||
<div class="dev-watermark-wrap">
|
||||
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 220px;">
|
||||
{% if showDevBanner %}
|
||||
<span class="dev-watermark-badge">In Development</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,6 +114,34 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if feedbackSummary.count > 0 %}
|
||||
<div class="briefing-feedback mb-4">
|
||||
<div class="text-uppercase small mb-3" style="letter-spacing: .1em; color: #6fb8d1;">
|
||||
Field Reports from {{ feedbackSummary.count }} Agent{{ feedbackSummary.count != 1 ? 's' : '' }}
|
||||
</div>
|
||||
<div class="row text-center g-3">
|
||||
{% if feedbackSummary.avgDifficulty is not null %}
|
||||
<div class="col-4">
|
||||
<div class="briefing-feedback-stat">{{ feedbackSummary.avgDifficulty }}/10</div>
|
||||
<div class="small" style="color: #9fc7d6;">Difficulty</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if feedbackSummary.avgEntertaining is not null %}
|
||||
<div class="col-4">
|
||||
<div class="briefing-feedback-stat">{{ feedbackSummary.avgEntertaining }}/10</div>
|
||||
<div class="small" style="color: #9fc7d6;">Entertainment</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if feedbackSummary.avgTheme is not null %}
|
||||
<div class="col-4">
|
||||
<div class="briefing-feedback-stat">{{ feedbackSummary.avgTheme }}/10</div>
|
||||
<div class="small" style="color: #9fc7d6;">Theme</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr class="my-4" style="border-color: #1c5a70;">
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends 'layout/site.html.twig' %}
|
||||
|
||||
{% block title %}Terms and Conditions | {{ 'site.name'|trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-9">
|
||||
<div class="mb-4">
|
||||
<h1 class="display-6 fw-bold">Terms and Conditions</h1>
|
||||
<p class="text-secondary">Last updated: 29 August 2026</p>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
Welcome to EscapePage. These Terms and Conditions ("Terms") govern your use of this
|
||||
website and the online escape-room game(s) offered on it (together, the "Service"). By
|
||||
creating an account or otherwise using the Service, you agree to these Terms.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">1. The Service</h2>
|
||||
<p>
|
||||
EscapePage is a hobby project offering collaborative, browser-based escape-room
|
||||
experiences. The Service is under active development: games, features, and content may
|
||||
change, be added, or be removed at any time, without notice.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">2. Accounts</h2>
|
||||
<p>
|
||||
You need an account to play. You are responsible for keeping your login details
|
||||
confidential and for all activity under your account. You must provide accurate
|
||||
information when registering, and you must be at least 16 years old, or the minimum age
|
||||
required in your country to consent to online services without parental approval,
|
||||
whichever is higher.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">3. Acceptable Use</h2>
|
||||
<p>You agree not to:</p>
|
||||
<ul>
|
||||
<li>attempt to cheat, exploit bugs, or bypass the intended puzzle mechanics in a way that spoils the game for other players;</li>
|
||||
<li>harass, abuse, or send unlawful or offensive content to other players or the operator;</li>
|
||||
<li>use automated tools to scrape, overload, or attack the Service;</li>
|
||||
<li>use the Service for any unlawful purpose.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Accounts that break these rules may be suspended or removed at the operator's discretion.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">4. Feedback and Submitted Content</h2>
|
||||
<p>
|
||||
If you submit feedback, ratings, or written comments through the Service, you grant the
|
||||
operator a non-exclusive, royalty-free right to use, store, and display that content
|
||||
(for example, as aggregate feedback scores or anonymised testimonials) in connection with
|
||||
the Service.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">5. Donations</h2>
|
||||
<p>
|
||||
The Service may offer an optional way to support its development through a third-party
|
||||
payment provider (currently PayPal). Donations are voluntary, are not required to play,
|
||||
do not unlock any in-game advantage, and are non-refundable except where required by law.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">6. Availability and "As Is" Service</h2>
|
||||
<p>
|
||||
The Service is provided free of charge, "as is" and "as available," without warranties of
|
||||
any kind, express or implied. As a small, self-hosted hobby project, uptime, data
|
||||
retention, and long-term availability are not guaranteed.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">7. Intellectual Property</h2>
|
||||
<p>
|
||||
The game stories, puzzles, artwork, and source code are the property of the operator or
|
||||
used under licence, except where third-party or open-source components state otherwise.
|
||||
You may not copy, redistribute, or republish game content without permission.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">8. Limitation of Liability</h2>
|
||||
<p>
|
||||
To the fullest extent permitted by law, the operator is not liable for any indirect,
|
||||
incidental, or consequential damages arising from your use of, or inability to use, the
|
||||
Service.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">9. Changes to These Terms</h2>
|
||||
<p>
|
||||
These Terms may be updated from time to time; the "Last updated" date above will reflect
|
||||
the most recent change. Continuing to use the Service after a change means you accept the
|
||||
updated Terms.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">10. Governing Law</h2>
|
||||
<p>
|
||||
These Terms are governed by the laws of the Netherlands, without regard to its conflict
|
||||
of law principles.
|
||||
</p>
|
||||
|
||||
<h2 class="h5 mt-4">11. Contact</h2>
|
||||
<p class="mb-0">
|
||||
Questions about these Terms are welcome. Please reach out to the operator through the
|
||||
contact details available on this site.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,6 +2,7 @@
|
||||
site.name: EscapePage
|
||||
nav.home: Home
|
||||
nav.game: Game
|
||||
nav.escaperooms: Rooms
|
||||
nav.admin: Admin
|
||||
nav.profile: Profile
|
||||
nav.login: Login
|
||||
@@ -9,12 +10,13 @@ nav.register: Register
|
||||
nav.logout: Logout
|
||||
link.enter_game: Enter the Game Area
|
||||
link.back_to_website: Back to Website
|
||||
footer.contact: Contact
|
||||
footer.help_wanted: Looking for Help
|
||||
footer.terms: Terms and Conditions
|
||||
|
||||
# Home page
|
||||
home.title: "Welcome | EscapePage"
|
||||
home.h1: "Welcome to EscapePage"
|
||||
home.description: "This is the public website. Minimal interaction, information about the game, and links."
|
||||
|
||||
# Game hub
|
||||
game.title: "Game Hub | EscapePage"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
site.name: EscapePage
|
||||
nav.home: Start
|
||||
nav.game: Spel
|
||||
nav.escaperooms: Rooms
|
||||
nav.admin: Beheer
|
||||
nav.profile: Profiel
|
||||
nav.login: Inloggen
|
||||
@@ -9,12 +10,13 @@ nav.register: Registreren
|
||||
nav.logout: Uitloggen
|
||||
link.enter_game: Ga naar het Speelgedeelte
|
||||
link.back_to_website: Terug naar Website
|
||||
footer.contact: Contact
|
||||
footer.help_wanted: Op zoek naar hulp
|
||||
footer.terms: Algemene Voorwaarden
|
||||
|
||||
# Home pagina
|
||||
home.title: "Welkom | %{site}%"
|
||||
home.h1: "Welkom bij %{site}%"
|
||||
home.description: "Dit is de publieke website. Beperkte interactie, informatie over het spel en links."
|
||||
|
||||
# Spel hub
|
||||
game.title: "Spel Hub | %{site}%"
|
||||
|
||||
@@ -15,6 +15,8 @@ Encore
|
||||
.addEntry('app', './assets/app.js')
|
||||
.addEntry('game1', './assets/game1.js')
|
||||
.addEntry('pregame', './assets/pregame.js')
|
||||
.addEntry('escaperoom-map', './assets/escaperoom-map.js')
|
||||
.addEntry('escaperoom-detail', './assets/escaperoom-detail.js')
|
||||
// split entry chunks for better caching
|
||||
.splitEntryChunks()
|
||||
// will require an extra script tag for runtime.js
|
||||
|
||||
Reference in New Issue
Block a user