4 Commits

Author SHA1 Message Date
5f815d8a54 fix(back-end) url construction for media 2026-03-10 12:42:26 +01:00
printcalc-ci
42e0e75d70 style: apply prettier formatting 2026-03-10 10:15:56 +00:00
c24e27a9db Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m4s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 26s
# Conflicts:
#	frontend/src/app/features/shop/shop-page.component.scss
2026-03-10 11:15:32 +01:00
3d12ae4da4 feat(front-end): ui improvements and alligment 2026-03-10 11:15:25 +01:00
10 changed files with 158 additions and 19 deletions

View File

@@ -32,7 +32,7 @@ Crea un database PostgreSQL chiamato `printcalc`. Lo schema viene gestito dal pr
Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. Per il media service pubblico puoi configurare anche: Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. Per il media service pubblico puoi configurare anche:
- `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`) - `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`)
- `MEDIA_PUBLIC_BASE_URL` per gli URL assoluti restituiti dalle API admin, ad esempio `https://example.com/media` - `SHOP_STORAGE_ROOT` per la root `storage_shop` usata dal backend per i modelli dei prodotti shop
- `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` - `MEDIA_FFMPEG_PATH` per il binario `ffmpeg`
- `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine - `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine
@@ -64,12 +64,13 @@ I prezzi non sono più gestiti tramite variabili d'ambiente fisse ma tramite tab
* `/frontend`: Applicazione Angular. * `/frontend`: Applicazione Angular.
* `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer. * `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer.
* `/storage_media`: Originali e varianti media pubbliche/private su filesystem. * `/storage_media`: Originali e varianti media pubbliche/private su filesystem.
* `/storage_shop`: Modelli e file prodotti dello shop.
## Media pubblici ## Media pubblici
Il backend salva sempre l'originale in `storage_media/original/` e precomputa le varianti pubbliche in `storage_media/public/`. La cartella `storage_media/private/` è predisposta per asset non pubblici. Il backend salva sempre l'originale in `storage_media/original/` e precomputa le varianti pubbliche in `storage_media/public/`. La cartella `storage_media/private/` è predisposta per asset non pubblici.
Nel deploy Docker il volume media atteso è `/mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media`. Nel deploy Docker i volumi attesi sono `/mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media` e `/mnt/cache/appdata/print-calculator/${ENV}/storage_shop:/app/storage_shop`.
Nginx non deve passare dal backend per i file pubblici. Configurazione attesa: Nginx non deve passare dal backend per i file pubblici. Configurazione attesa:
@@ -106,7 +107,7 @@ Operativamente:
Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno). Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno).
### FFmpeg e media pubblici ### FFmpeg e media pubblici
Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `MEDIA_PUBLIC_BASE_URL` corrisponda al `location /media/` esposto da Nginx e che il volume `storage_media` sia montato correttamente. Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `APP_FRONTEND_BASE_URL` punti al dominio corretto, che `location /media/` sia esposto da Nginx e che il volume `storage_media` sia montato correttamente.
### Database connection ### Database connection
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.

View File

@@ -18,15 +18,15 @@ public class MediaStorageService {
private final Path originalRootLocation; private final Path originalRootLocation;
private final Path publicRootLocation; private final Path publicRootLocation;
private final Path privateRootLocation; private final Path privateRootLocation;
private final String publicBaseUrl; private final String frontendBaseUrl;
public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot, public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot,
@Value("${media.public.base-url:http://localhost:8080/media}") String publicBaseUrl) { @Value("${app.frontend.base-url:${APP_FRONTEND_BASE_URL:http://localhost:8080}}") String frontendBaseUrl) {
this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize(); this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize();
this.originalRootLocation = normalizedRootLocation.resolve("original").normalize(); this.originalRootLocation = normalizedRootLocation.resolve("original").normalize();
this.publicRootLocation = normalizedRootLocation.resolve("public").normalize(); this.publicRootLocation = normalizedRootLocation.resolve("public").normalize();
this.privateRootLocation = normalizedRootLocation.resolve("private").normalize(); this.privateRootLocation = normalizedRootLocation.resolve("private").normalize();
this.publicBaseUrl = publicBaseUrl; this.frontendBaseUrl = frontendBaseUrl;
init(); init();
} }
@@ -73,11 +73,12 @@ public class MediaStorageService {
if (storageKey == null || storageKey.isBlank()) { if (storageKey == null || storageKey.isBlank()) {
return null; return null;
} }
String mediaBaseUrl = buildMediaBaseUrl();
String normalizedKey = storageKey.startsWith("/") ? storageKey.substring(1) : storageKey; String normalizedKey = storageKey.startsWith("/") ? storageKey.substring(1) : storageKey;
if (publicBaseUrl.endsWith("/")) { if (mediaBaseUrl.endsWith("/")) {
return publicBaseUrl + normalizedKey; return mediaBaseUrl + normalizedKey;
} }
return publicBaseUrl + "/" + normalizedKey; return mediaBaseUrl + "/" + normalizedKey;
} }
private void copy(Path source, Path destination) throws IOException { private void copy(Path source, Path destination) throws IOException {
@@ -127,4 +128,15 @@ public class MediaStorageService {
} }
return visibility.trim().toUpperCase(Locale.ROOT); return visibility.trim().toUpperCase(Locale.ROOT);
} }
private String buildMediaBaseUrl() {
String normalized = frontendBaseUrl != null ? frontendBaseUrl.trim() : "";
if (normalized.isBlank()) {
normalized = "http://localhost:4200";
}
if (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized + "/media";
}
} }

View File

@@ -9,5 +9,4 @@ admin.session.ttl-minutes=480
# Local media storage served by a local static server on port 8081. # Local media storage served by a local static server on port 8081.
media.storage.root=/Users/joe/IdeaProjects/print-calculator/storage_media media.storage.root=/Users/joe/IdeaProjects/print-calculator/storage_media
media.public.base-url=http://localhost:8081
media.ffmpeg.path=ffmpeg media.ffmpeg.path=ffmpeg

View File

@@ -28,7 +28,6 @@ clamav.enabled=${CLAMAV_ENABLED:false}
# Media configuration # Media configuration
media.storage.root=${MEDIA_STORAGE_ROOT:storage_media} media.storage.root=${MEDIA_STORAGE_ROOT:storage_media}
media.public.base-url=${MEDIA_PUBLIC_BASE_URL:http://localhost:8080/media}
media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg} media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg}
media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400} media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400}
shop.model.max-file-size-bytes=${SHOP_MODEL_MAX_FILE_SIZE_BYTES:104857600} shop.model.max-file-size-bytes=${SHOP_MODEL_MAX_FILE_SIZE_BYTES:104857600}

View File

@@ -87,7 +87,7 @@ class AdminMediaControllerServiceTest {
storageRoot = tempDir.resolve("storage_media"); storageRoot = tempDir.resolve("storage_media");
MediaStorageService mediaStorageService = new MediaStorageService( MediaStorageService mediaStorageService = new MediaStorageService(
storageRoot.toString(), storageRoot.toString(),
"https://cdn.example/media" "https://cdn.example"
); );
service = new AdminMediaControllerService( service = new AdminMediaControllerService(

View File

@@ -41,7 +41,7 @@ class PublicMediaQueryServiceTest {
void setUp() { void setUp() {
MediaStorageService mediaStorageService = new MediaStorageService( MediaStorageService mediaStorageService = new MediaStorageService(
tempDir.resolve("storage_media").toString(), tempDir.resolve("storage_media").toString(),
"https://cdn.example/media" "https://cdn.example"
); );
service = new PublicMediaQueryService(mediaUsageRepository, mediaVariantRepository, mediaStorageService); service = new PublicMediaQueryService(mediaUsageRepository, mediaVariantRepository, mediaStorageService);
} }

View File

@@ -32,7 +32,7 @@ services:
- TEMP_DIR=/app/temp - TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}
- MEDIA_PUBLIC_BASE_URL=${MEDIA_PUBLIC_BASE_URL:-http://localhost:8080/media} - SHOP_STORAGE_ROOT=${SHOP_STORAGE_ROOT:-/app/storage_shop}
- MEDIA_FFMPEG_PATH=${MEDIA_FFMPEG_PATH:-ffmpeg} - MEDIA_FFMPEG_PATH=${MEDIA_FFMPEG_PATH:-ffmpeg}
- MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:-26214400} - MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:-26214400}
restart: always restart: always
@@ -47,6 +47,7 @@ services:
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders - /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders
- /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests - /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests
- /mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media - /mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media
- /mnt/cache/appdata/print-calculator/${ENV}/storage_shop:/app/storage_shop
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View File

@@ -14,6 +14,13 @@
.section-header h2 { .section-header h2 {
margin: 0; margin: 0;
} }
.media-panel-header {
margin-bottom: var(--space-3);
}
.group-header {
margin-bottom: var(--space-3);
}
.header-copy { .header-copy {
display: grid; display: grid;

View File

@@ -40,12 +40,20 @@ label {
input { input {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-sm);
padding: var(--space-2); padding: var(--space-2);
background: var(--color-bg-card);
color: var(--color-text);
font: inherit; font: inherit;
font-size: 1rem; font-size: 1rem;
} }
input:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: var(--focus-ring);
}
button { button {
border: 0; border: 0;
border-radius: var(--radius-md); border-radius: var(--radius-md);

View File

@@ -97,24 +97,136 @@
min-width: 0; min-width: 0;
} }
:host ::ng-deep .content .ui-form-control { :host ::ng-deep .content .ui-form-control,
:host
::ng-deep
.content
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
:host ::ng-deep .content select,
:host ::ng-deep .content textarea {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2); padding: var(--space-2);
background: var(--color-bg-card);
color: var(--color-text);
font: inherit;
font-size: 1rem; font-size: 1rem;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
} }
:host ::ng-deep .content .ui-form-control:focus,
:host
::ng-deep
.content
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):focus,
:host ::ng-deep .content select:focus,
:host ::ng-deep .content textarea:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: var(--focus-ring);
}
:host ::ng-deep .content .ui-form-control:disabled,
:host
::ng-deep
.content
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):disabled,
:host ::ng-deep .content select:disabled,
:host ::ng-deep .content textarea:disabled {
background: var(--color-surface-muted);
cursor: not-allowed;
}
:host ::ng-deep .content select,
:host ::ng-deep .content select.ui-form-control { :host ::ng-deep .content select.ui-form-control {
background-position: right 0.75rem center; appearance: auto;
padding-right: 2.35rem; background-image: none;
background-position: initial;
background-size: initial;
padding-right: var(--space-2);
} }
:host ::ng-deep .content .ui-form-caption { :host ::ng-deep .content .ui-form-caption,
:host ::ng-deep .content .form-field > span,
:host ::ng-deep .content .toolbar-field > span,
:host ::ng-deep .content .form-grid label > span,
:host ::ng-deep .content .status-editor label,
:host ::ng-deep .content .status-editor-field label {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-muted);
} }
:host ::ng-deep .content button,
:host ::ng-deep .content .ui-button { :host ::ng-deep .content .ui-button {
border: 0;
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
background: var(--color-brand);
color: var(--color-neutral-900);
font: inherit;
font-size: 1rem; font-size: 1rem;
font-weight: 600;
line-height: 1.2; line-height: 1.2;
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
opacity 0.2s ease;
}
:host ::ng-deep .content button:hover:not(:disabled),
:host ::ng-deep .content .ui-button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
:host ::ng-deep .content button:disabled,
:host ::ng-deep .content .ui-button:disabled {
opacity: 0.65;
cursor: default;
}
:host ::ng-deep .content .ui-button--ghost,
:host ::ng-deep .content .ghost,
:host ::ng-deep .content .btn-secondary,
:host ::ng-deep .content .panel-toggle {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
color: var(--color-text);
}
:host ::ng-deep .content .ui-button--ghost:hover:not(:disabled),
:host ::ng-deep .content .ghost:hover:not(:disabled),
:host ::ng-deep .content .btn-secondary:hover:not(:disabled),
:host ::ng-deep .content .panel-toggle:hover:not(:disabled) {
background: var(--color-surface-muted);
}
:host ::ng-deep .content .ui-button--danger,
:host ::ng-deep .content .btn-delete {
background: var(--color-danger-500);
color: #ffffff;
}
:host ::ng-deep .content .ui-button--danger:hover:not(:disabled),
:host ::ng-deep .content .btn-delete:hover:not(:disabled) {
background: #dc2626;
}
:host ::ng-deep .content .ui-button--ghost-danger {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
color: var(--color-text);
}
:host ::ng-deep .content .ui-button--ghost-danger:hover:not(:disabled) {
background: #fff0f0;
border-color: #d9534f;
} }
:host ::ng-deep .content .ui-checkbox { :host ::ng-deep .content .ui-checkbox {