Initial commit

This commit is contained in:
Fabio Bonetti
2026-02-11 14:57:12 +01:00
commit e56123f314
9 changed files with 1001 additions and 0 deletions

174
README.md Normal file
View File

@@ -0,0 +1,174 @@
# EU Media Guide CMS
CMS basato su Grav per la gestione dei contenuti multilingua della media guide.
## Struttura del Progetto
```
grav-cms-mediaguide/
├── docker-compose.yml # Config Docker per Coolify
├── config/
│ ├── system.yaml # Config sistema (24 lingue EU)
│ └── site.yaml # Config sito
├── blueprints/
│ └── station.yaml # Blueprint per le stazioni/moduli
├── pages/
│ └── stations/
│ └── test-module/ # Modulo di test
├── plugins/
│ └── json-export/ # Plugin per export JSON + API
└── README.md
```
## Deploy su Coolify
### 1. Crea Repository Git
```bash
cd grav-cms-mediaguide
git init
git add .
git commit -m "Initial CMS setup"
git remote add origin <your-repo-url>
git push -u origin main
```
### 2. Deploy su Coolify
1. In Coolify, crea un nuovo servizio **Docker Compose**
2. Collega il repository Git
3. Imposta la porta pubblica su `8080`
4. Deploy!
### 3. Setup Iniziale Grav
Dopo il primo deploy, accedi al container e crea l'utente admin:
```bash
# Accedi al container
docker exec -it mediaguide-cms bash
# Crea utente admin
bin/plugin login new-user
```
### 4. Installa Plugin Admin
```bash
bin/gpm install admin
```
## API Endpoints
Il plugin `json-export` espone questi endpoint:
| Endpoint | Descrizione |
|----------|-------------|
| `GET /api/stations?lang=en` | Lista tutte le stazioni |
| `GET /api/station/{id}?lang=en` | Contenuto singola stazione |
| `GET /api/translations?lang=en` | Traduzioni UI |
| `GET /api/export` | Esporta tutto in file JSON |
### Esempio Risposta `/api/station/test-welcome?lang=en`
```json
{
"id": "test-welcome",
"type": "intro",
"language": "en",
"title": "Welcome to the European Parliament",
"description": "Your interactive guide to European democracy",
"components": [
{
"id": "welcome-heading",
"type": "heading",
"level": "h1",
"content": "Welcome to the European Parliament"
},
{
"id": "intro-text",
"type": "text",
"content": "<p>Welcome to the heart of European democracy...</p>"
},
{
"id": "welcome-video",
"type": "video",
"src": "/media/videos/welcome-en.mp4",
"poster": "/media/images/video-poster.jpg",
"caption": "A brief introduction to the European Parliament"
}
]
}
```
## Lingue Supportate (Ordine Protocollo UE)
| Codice | Lingua |
|--------|--------|
| bg | български |
| es | español |
| cs | čeština |
| da | dansk |
| de | Deutsch |
| et | eesti |
| el | ελληνικά |
| en | English |
| fr | français |
| ga | Gaeilge |
| hr | hrvatski |
| it | italiano |
| lv | latviešu |
| lt | lietuvių |
| hu | magyar |
| mt | Malti |
| nl | Nederlands |
| pl | polski |
| pt | português |
| ro | română |
| sk | slovenčina |
| sl | slovenščina |
| fi | suomi |
| sv | svenska |
## Gestione Contenuti
### Creare una Nuova Stazione
1. Accedi all'admin panel: `http://your-domain:8080/admin`
2. Vai su **Pages****Add****Station**
3. Compila i campi multilingua
4. Aggiungi i componenti (heading, text, video, audio, tabs, slideshow)
5. Salva
### Struttura File Markdown
Ogni stazione è un file `.md` con frontmatter YAML:
```yaml
---
title: Station Title
station_id: unique-id
station_type: exhibit
active: true
order: 1
title_translations:
en: English Title
fr: Titre français
components:
- type: heading
id: heading-1
level: h1
content:
en: English Heading
fr: Titre français
---
```
## Prossimi Passi
1. [ ] Deploy del CMS su Coolify
2. [ ] Configurare l'admin panel
3. [ ] Creare il container React per l'app
4. [ ] Collegare l'app agli endpoint API del CMS

181
blueprints/station.yaml Normal file
View File

@@ -0,0 +1,181 @@
# Station Blueprint
# Defines a content module/station for the media guide
# /user/blueprints/station.yaml
title: Station
'@extends':
type: default
context: blueprints://pages
form:
fields:
tabs:
type: tabs
active: 1
fields:
# Station Info Tab
station_info:
type: tab
title: Station Info
fields:
header.station_id:
type: text
label: Station ID
help: Unique identifier for this station (used in QR codes)
validate:
required: true
pattern: '^[a-z0-9-]+$'
header.station_type:
type: select
label: Station Type
options:
intro: Introduction
exhibit: Exhibit
information: Information Point
navigation: Navigation
default: exhibit
header.exhibition_ref:
type: text
label: Exhibition Reference
help: Reference to parent exhibition
header.active:
type: toggle
label: Active
highlight: 1
default: 1
options:
1: Yes
0: No
header.order:
type: number
label: Display Order
default: 0
# Multilingual Content Tab
content_tab:
type: tab
title: Content
fields:
header.title:
type: multilang
label: Title
languages: true
header.description:
type: multilang
label: Short Description
type: textarea
languages: true
# Components Tab
components_tab:
type: tab
title: Components
fields:
header.components:
type: list
label: Content Components
style: vertical
btnLabel: Add Component
collapsed: true
fields:
.type:
type: select
label: Component Type
options:
heading: Heading
text: Text Block
video: Video Player
audio: Audio Player
tabs: Tabbed Content
slideshow: Slideshow
.id:
type: text
label: Component ID
help: Unique ID for this component
# Heading specific
.level:
type: select
label: Heading Level
options:
h1: H1
h2: H2
h3: H3
# Text/Heading content (multilingual)
.content:
type: editor
label: Content
# Video specific
.video_src:
type: filepicker
label: Video File
folder: 'user://media/videos'
accept:
- video/*
.video_poster:
type: filepicker
label: Video Poster
folder: 'user://media/images'
accept:
- image/*
# Audio specific
.audio_src:
type: filepicker
label: Audio File
folder: 'user://media/audio'
accept:
- audio/*
.caption:
type: text
label: Caption
# Tabs specific
.tabs:
type: list
label: Tabs
fields:
.title:
type: text
label: Tab Title
.content:
type: editor
label: Tab Content
# Slideshow specific
.slides:
type: list
label: Slides
fields:
.image:
type: filepicker
label: Slide Image
folder: 'user://media/images'
accept:
- image/*
.caption:
type: text
label: Slide Caption
# Media Tab
media_tab:
type: tab
title: Media Files
fields:
header.media_folder:
type: text
label: Media Folder
help: Folder containing media files for this station
placeholder: /media/station-id

27
config/site.yaml Normal file
View File

@@ -0,0 +1,27 @@
# Grav Site Configuration
# /user/config/site.yaml
title: EU Media Guide CMS
default_lang: en
author:
name: Admin
email: admin@example.com
metadata:
description: Multilingual Media Guide Content Management System
taxonomies:
- category
- tag
- exhibition
- station_type
# Custom fields for the media guide
mediaguide:
exhibitions: []
station_types:
- intro
- exhibit
- information
- navigation

73
config/system.yaml Normal file
View File

@@ -0,0 +1,73 @@
# Grav System Configuration
# /user/config/system.yaml
languages:
supported:
# EU Protocol Order (alphabetical by native name)
- bg # български (Bulgarian)
- es # español (Spanish)
- cs # čeština (Czech)
- da # dansk (Danish)
- de # Deutsch (German)
- et # eesti (Estonian)
- el # ελληνικά (Greek)
- en # English
- fr # français (French)
- ga # Gaeilge (Irish)
- hr # hrvatski (Croatian)
- it # italiano (Italian)
- lv # latviešu (Latvian)
- lt # lietuvių (Lithuanian)
- hu # magyar (Hungarian)
- mt # Malti (Maltese)
- nl # Nederlands (Dutch)
- pl # polski (Polish)
- pt # português (Portuguese)
- ro # română (Romanian)
- sk # slovenčina (Slovak)
- sl # slovenščina (Slovenian)
- fi # suomi (Finnish)
- sv # svenska (Swedish)
default_lang: en
include_default_lang: true
translations: true
translations_fallback: true
session_store_active: false
http_accept_language: true
override_locale: false
pages:
theme: quark
markdown:
extra: true
types:
- txt
- xml
- html
- htm
- json
- rss
- atom
cache:
enabled: false # Disable during development
check:
method: file
driver: auto
prefix: 'g'
lifetime: 604800
debugger:
enabled: false
shutdown:
close_connection: true
twig:
cache: false # Disable during development
debug: true
auto_reload: true
autoescape: false
assets:
css_pipeline: false
js_pipeline: false

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
version: '3.8'
services:
grav-cms:
image: getgrav/grav:latest
container_name: mediaguide-cms
restart: unless-stopped
ports:
- "8080:80"
volumes:
# Persistent storage for all Grav user content
- grav_user:/var/www/html/user
# Custom blueprints (mounted from host)
- ./blueprints:/var/www/html/user/blueprints
# Custom plugins config
- ./config:/var/www/html/user/config
environment:
- GRAV_MULTISITE=false
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
volumes:
grav_user:
driver: local

View File

@@ -0,0 +1,145 @@
---
title: Test Module - Welcome
station_id: test-welcome
station_type: intro
exhibition_ref: main-exhibition
active: true
order: 1
# Multilingual titles
title_translations:
en: Welcome to the European Parliament
fr: Bienvenue au Parlement européen
de: Willkommen im Europäischen Parlament
nl: Welkom bij het Europees Parlement
it: Benvenuti al Parlamento europeo
es: Bienvenidos al Parlamento Europeo
# Multilingual descriptions
description_translations:
en: Your interactive guide to European democracy
fr: Votre guide interactif de la démocratie européenne
de: Ihr interaktiver Führer zur europäischen Demokratie
nl: Uw interactieve gids voor Europese democratie
it: La tua guida interattiva alla democrazia europea
es: Su guía interactiva de la democracia europea
# Content components
components:
- type: heading
id: welcome-heading
level: h1
content:
en: Welcome to the European Parliament
fr: Bienvenue au Parlement européen
de: Willkommen im Europäischen Parlament
nl: Welkom bij het Europees Parlement
- type: text
id: intro-text
content:
en: |
<p>Welcome to the heart of European democracy. This interactive guide
will help you navigate through the European Parliament building and
learn about its institutions.</p>
<p>Use the menu to select your preferred language and scan QR codes
throughout the building to access specific information.</p>
fr: |
<p>Bienvenue au cœur de la démocratie européenne. Ce guide interactif
vous aidera à naviguer dans le bâtiment du Parlement européen et à
découvrir ses institutions.</p>
de: |
<p>Willkommen im Herzen der europäischen Demokratie. Dieser interaktive
Führer hilft Ihnen bei der Navigation durch das Gebäude des Europäischen
Parlaments.</p>
nl: |
<p>Welkom in het hart van de Europese democratie. Deze interactieve gids
helpt u door het gebouw van het Europees Parlement te navigeren.</p>
- type: video
id: welcome-video
src:
en: /media/videos/welcome-en.mp4
fr: /media/videos/welcome-fr.mp4
de: /media/videos/welcome-de.mp4
poster: /media/images/video-poster.jpg
caption:
en: A brief introduction to the European Parliament
fr: Une brève introduction au Parlement européen
de: Eine kurze Einführung in das Europäische Parlament
- type: audio
id: welcome-audio
src:
en: /media/audio/welcome-en.mp3
fr: /media/audio/welcome-fr.mp3
de: /media/audio/welcome-de.mp3
caption:
en: Audio welcome message
fr: Message d'accueil audio
de: Audio-Willkommensnachricht
- type: tabs
id: info-tabs
tabs:
- title:
en: Information
fr: Informations
de: Information
nl: Informatie
content:
en: |
<p>The European Parliament is one of the EU's law-making bodies.
It is directly elected by EU voters every 5 years.</p>
fr: |
<p>Le Parlement européen est l'un des organes législatifs de l'UE.
Il est élu directement par les citoyens européens tous les 5 ans.</p>
de: |
<p>Das Europäische Parlament ist eines der gesetzgebenden Organe der EU.
Es wird alle 5 Jahre direkt von den EU-Bürgern gewählt.</p>
- title:
en: Opening Hours
fr: Horaires d'ouverture
de: Öffnungszeiten
nl: Openingstijden
content:
en: |
<p><strong>Monday-Friday:</strong> 9:00 - 18:00</p>
<p><strong>Saturday:</strong> 10:00 - 18:00</p>
<p><strong>Sunday:</strong> Closed</p>
fr: |
<p><strong>Lundi-Vendredi:</strong> 9h00 - 18h00</p>
<p><strong>Samedi:</strong> 10h00 - 18h00</p>
<p><strong>Dimanche:</strong> Fermé</p>
- title:
en: Services
fr: Services
de: Dienstleistungen
nl: Diensten
content:
en: |
<p>Guided Tours, Information Desk, Cafeteria, Gift Shop, Accessibility Services</p>
fr: |
<p>Visites guidées, Bureau d'information, Cafétéria, Boutique, Services d'accessibilité</p>
- type: slideshow
id: building-slideshow
slides:
- image: /media/images/exterior.jpg
caption:
en: Exterior of the European Parliament building
fr: Extérieur du bâtiment du Parlement européen
de: Außenansicht des Europäischen Parlaments
- image: /media/images/hemicycle.jpg
caption:
en: The Hemicycle where plenary sessions take place
fr: L'hémicycle où se déroulent les sessions plénières
de: Das Plenum, in dem die Plenarsitzungen stattfinden
- image: /media/images/parliamentarium.jpg
caption:
en: The Parliamentarium visitors center
fr: Le centre des visiteurs Parlamentarium
de: Das Besucherzentrum Parlamentarium
---
This is the main content body for the test module.

View File

@@ -0,0 +1,10 @@
name: JSON Export
version: 1.0.0
description: Exports Grav content to JSON format for the React media guide app
icon: file-code
author:
name: Media Guide Team
homepage: https://github.com/your-org/mediaguide
keywords: json, export, api, multilingual
bugs: https://github.com/your-org/mediaguide/issues
license: MIT

View File

@@ -0,0 +1,340 @@
<?php
namespace Grav\Plugin;
use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;
use Grav\Common\Page\Page;
use Grav\Common\Grav;
/**
* JSON Export Plugin
*
* Exports content from Grav CMS to JSON format for the React media guide app
*/
class JsonExportPlugin extends Plugin
{
public static function getSubscribedEvents()
{
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0]
];
}
public function onPluginsInitialized()
{
// Check if we're in admin context
if ($this->isAdmin()) {
$this->enable([
'onAdminTaskExecute' => ['onAdminTaskExecute', 0]
]);
}
// Enable API endpoint
$this->enable([
'onPagesInitialized' => ['onPagesInitialized', 0]
]);
}
public function onPagesInitialized()
{
$uri = $this->grav['uri'];
// API endpoints
if (strpos($uri->path(), '/api/') === 0) {
$this->handleApiRequest($uri);
}
}
private function handleApiRequest($uri)
{
$path = $uri->path();
$lang = $uri->param('lang') ?? 'en';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
// GET /api/stations - List all stations
if ($path === '/api/stations') {
echo json_encode($this->getStationsList($lang), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
}
// GET /api/station/{id} - Get single station
if (preg_match('#^/api/station/([a-z0-9-]+)$#', $path, $matches)) {
$stationId = $matches[1];
$station = $this->getStation($stationId, $lang);
if ($station) {
echo json_encode($station, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
} else {
http_response_code(404);
echo json_encode(['error' => 'Station not found']);
}
exit;
}
// GET /api/translations - Get UI translations
if ($path === '/api/translations') {
echo json_encode($this->getTranslations($lang), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
}
// GET /api/export - Export all content to JSON files
if ($path === '/api/export') {
$result = $this->exportAllToJson();
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
}
}
private function getStationsList($lang)
{
$pages = $this->grav['pages'];
$stations = [];
foreach ($pages->all() as $page) {
if ($page->template() === 'station' && $page->header()->active) {
$header = $page->header();
$stations[] = [
'id' => $header->station_id ?? $page->slug(),
'type' => $header->station_type ?? 'exhibit',
'title' => $this->getTranslatedField($header->title_translations ?? [], $lang),
'description' => $this->getTranslatedField($header->description_translations ?? [], $lang),
'order' => $header->order ?? 0,
'active' => $header->active ?? true
];
}
}
// Sort by order
usort($stations, fn($a, $b) => $a['order'] <=> $b['order']);
return $stations;
}
private function getStation($stationId, $lang)
{
$pages = $this->grav['pages'];
foreach ($pages->all() as $page) {
if ($page->template() === 'station') {
$header = $page->header();
$id = $header->station_id ?? $page->slug();
if ($id === $stationId) {
return $this->formatStationForLang($page, $lang);
}
}
}
return null;
}
private function formatStationForLang($page, $lang)
{
$header = $page->header();
$station = [
'id' => $header->station_id ?? $page->slug(),
'type' => $header->station_type ?? 'exhibit',
'language' => $lang,
'title' => $this->getTranslatedField($header->title_translations ?? [], $lang),
'description' => $this->getTranslatedField($header->description_translations ?? [], $lang),
'components' => []
];
// Process components
if (isset($header->components) && is_array($header->components)) {
foreach ($header->components as $component) {
$station['components'][] = $this->formatComponent($component, $lang);
}
}
return $station;
}
private function formatComponent($component, $lang)
{
$formatted = [
'id' => $component['id'] ?? uniqid(),
'type' => $component['type'] ?? 'text'
];
switch ($component['type']) {
case 'heading':
$formatted['level'] = $component['level'] ?? 'h2';
$formatted['content'] = $this->getTranslatedField($component['content'] ?? [], $lang);
break;
case 'text':
$formatted['content'] = $this->getTranslatedField($component['content'] ?? [], $lang);
break;
case 'video':
$formatted['src'] = $this->getTranslatedField($component['src'] ?? [], $lang);
$formatted['poster'] = $component['poster'] ?? null;
$formatted['caption'] = $this->getTranslatedField($component['caption'] ?? [], $lang);
break;
case 'audio':
$formatted['src'] = $this->getTranslatedField($component['src'] ?? [], $lang);
$formatted['caption'] = $this->getTranslatedField($component['caption'] ?? [], $lang);
break;
case 'tabs':
$formatted['tabs'] = [];
if (isset($component['tabs']) && is_array($component['tabs'])) {
foreach ($component['tabs'] as $tab) {
$formatted['tabs'][] = [
'title' => $this->getTranslatedField($tab['title'] ?? [], $lang),
'content' => $this->getTranslatedField($tab['content'] ?? [], $lang)
];
}
}
break;
case 'slideshow':
$formatted['slides'] = [];
if (isset($component['slides']) && is_array($component['slides'])) {
foreach ($component['slides'] as $slide) {
$formatted['slides'][] = [
'image' => $slide['image'] ?? '',
'caption' => $this->getTranslatedField($slide['caption'] ?? [], $lang)
];
}
}
break;
}
return $formatted;
}
private function getTranslatedField($field, $lang, $fallback = 'en')
{
if (is_string($field)) {
return $field;
}
if (is_array($field)) {
return $field[$lang] ?? $field[$fallback] ?? reset($field) ?: '';
}
return '';
}
private function getTranslations($lang)
{
// UI translations - these should be stored in config or separate files
$translations = [
'en' => [
'appTitle' => 'EU Media Guide',
'scanQR' => 'Scan QR',
'selectLanguage' => 'Select Language',
'contentLibrary' => 'Content Library',
'noContent' => 'Scan a QR code or select content from the menu',
'errorLoading' => 'Failed to load content. Please try again.',
'playAudio' => 'Play Audio',
'pauseAudio' => 'Pause Audio',
'next' => 'Next',
'previous' => 'Previous',
'closeMenu' => 'Close Menu',
'openMenu' => 'Open Menu',
'loading' => 'Loading...'
],
'fr' => [
'appTitle' => 'Guide Média UE',
'scanQR' => 'Scanner QR',
'selectLanguage' => 'Choisir la langue',
'contentLibrary' => 'Bibliothèque de contenu',
'noContent' => 'Scannez un code QR ou sélectionnez du contenu dans le menu',
'errorLoading' => 'Échec du chargement. Veuillez réessayer.',
'playAudio' => 'Lire l\'audio',
'pauseAudio' => 'Pause audio',
'next' => 'Suivant',
'previous' => 'Précédent',
'closeMenu' => 'Fermer le menu',
'openMenu' => 'Ouvrir le menu',
'loading' => 'Chargement...'
],
'de' => [
'appTitle' => 'EU Media Guide',
'scanQR' => 'QR scannen',
'selectLanguage' => 'Sprache wählen',
'contentLibrary' => 'Inhaltsbibliothek',
'noContent' => 'Scannen Sie einen QR-Code oder wählen Sie Inhalte aus dem Menü',
'errorLoading' => 'Laden fehlgeschlagen. Bitte versuchen Sie es erneut.',
'playAudio' => 'Audio abspielen',
'pauseAudio' => 'Audio pausieren',
'next' => 'Weiter',
'previous' => 'Zurück',
'closeMenu' => 'Menü schließen',
'openMenu' => 'Menü öffnen',
'loading' => 'Laden...'
],
'nl' => [
'appTitle' => 'EU Media Gids',
'scanQR' => 'QR scannen',
'selectLanguage' => 'Taal selecteren',
'contentLibrary' => 'Inhoudsbibliotheek',
'noContent' => 'Scan een QR-code of selecteer inhoud uit het menu',
'errorLoading' => 'Laden mislukt. Probeer het opnieuw.',
'playAudio' => 'Audio afspelen',
'pauseAudio' => 'Audio pauzeren',
'next' => 'Volgende',
'previous' => 'Vorige',
'closeMenu' => 'Menu sluiten',
'openMenu' => 'Menu openen',
'loading' => 'Laden...'
]
// Add more languages as needed
];
return $translations[$lang] ?? $translations['en'];
}
private function exportAllToJson()
{
$exportPath = $this->grav['locator']->findResource('user://') . '/export/';
if (!is_dir($exportPath)) {
mkdir($exportPath, 0755, true);
}
$languages = $this->grav['config']->get('system.languages.supported', ['en']);
$exported = [];
foreach ($languages as $lang) {
// Export stations
$stations = $this->getStationsList($lang);
file_put_contents(
$exportPath . "stations.{$lang}.json",
json_encode($stations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
// Export each station content
foreach ($stations as $station) {
$content = $this->getStation($station['id'], $lang);
if ($content) {
file_put_contents(
$exportPath . "{$station['id']}.{$lang}.json",
json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
}
}
// Export translations
$translations = $this->getTranslations($lang);
file_put_contents(
$exportPath . "translations.{$lang}.json",
json_encode($translations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
$exported[] = $lang;
}
return [
'success' => true,
'exported_languages' => $exported,
'path' => $exportPath
];
}
}

View File

@@ -0,0 +1,24 @@
name: JSON Export
version: 1.0.0
description: Exports Grav content to JSON format for the React media guide app
icon: file-code
author:
name: Media Guide Team
homepage: https://github.com/your-org/mediaguide
keywords: json, export, api, multilingual
bugs: https://github.com/your-org/mediaguide/issues
license: MIT
form:
validation: strict
fields:
enabled:
type: toggle
label: Plugin status
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool