Compare commits
4 Commits
b9e6916dfe
...
feat/shop
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f815d8a54 | |||
|
|
42e0e75d70 | ||
| c24e27a9db | |||
| 3d12ae4da4 |
@@ -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.
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user