Grafika strony glownej z biblioteki mediow, naglowek w panelu
Deploy PROD / deploy (push) Successful in 26s
Deploy PROD / deploy (push) Successful in 26s
GRAFIKA JAKO USTAWIENIE, NIE PLIK W REPOZYTORIUM
Nowe `ACADEMY_HERO_IMAGE_MEDIA_ID` (typ MEDIA) wskazuje zdjecie obok naglowka
strony glownej. Zdjecie jest materialem konkretnego klienta, zwykle z jego
logo, a model wdrozenia zabrania danych klienta w obrazie Docker: wbudowane
w kod pojawiloby sie na stronie kazdej kolejnej instancji. Ta sama regula co
przy logo i ikonie karty.
Plik idzie przez `/api/public/media/{id}`, jak obrazki w tresci strony. Pusta
albo nieliczbowa wartosc daje domyslny panel z motywem wodnym zamiast adresu
donikad: zepsuty obrazek w najbardziej widocznym miejscu strony wyglada gorzej
niz motyw domyslny.
Ramka dostaje proporcje zdjecia (16:9) zamiast sztywnej wysokosci: przy innym
ksztalcie `object-cover` obcinal brzegi, a tam zwykle stoi logo.
NAGLOWEK STRONY GLOWNEJ W PANELU
`ACADEMY_HERO_EYEBROW`, `_TITLE` i `_TEXT` istnialy w backendzie od poczatku,
ale nie bylo ich w panelu: tytul strony dalo sie zmienic wylacznie zapytaniem
do bazy. Ustawienia -> Strona glowna ma teraz karte z tymi polami i grafika.
ODMIANA LICZNIKOW
Etykiety licznikow mialy na sztywno forme dla "wielu", wiec na zywej stronie
widnialo "1 kursantow". Licznik jest pierwsza rzecza, ktora widzi gosc.
Uzyta istniejaca funkcja `plural` z `lib/dates.ts`, teraz wyeksportowana,
zamiast czwartej kopii tej samej funkcji.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,8 @@ public final class PublicAcademyDtos {
|
|||||||
String heroEyebrow,
|
String heroEyebrow,
|
||||||
String heroTitle,
|
String heroTitle,
|
||||||
String heroText,
|
String heroText,
|
||||||
|
/** Adres grafiki obok naglowka albo null, gdy strona ma domyslny motyw. */
|
||||||
|
String heroImageUrl,
|
||||||
String expertsTitle,
|
String expertsTitle,
|
||||||
String expertsText,
|
String expertsText,
|
||||||
String reviewsTitle,
|
String reviewsTitle,
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ public class PublicAcademyService {
|
|||||||
settings.get(SettingKey.ACADEMY_HERO_EYEBROW),
|
settings.get(SettingKey.ACADEMY_HERO_EYEBROW),
|
||||||
settings.get(SettingKey.ACADEMY_HERO_TITLE),
|
settings.get(SettingKey.ACADEMY_HERO_TITLE),
|
||||||
settings.get(SettingKey.ACADEMY_HERO_TEXT),
|
settings.get(SettingKey.ACADEMY_HERO_TEXT),
|
||||||
|
heroImageUrl(),
|
||||||
settings.get(SettingKey.ACADEMY_EXPERTS_TITLE),
|
settings.get(SettingKey.ACADEMY_EXPERTS_TITLE),
|
||||||
settings.get(SettingKey.ACADEMY_EXPERTS_TEXT),
|
settings.get(SettingKey.ACADEMY_EXPERTS_TEXT),
|
||||||
settings.get(SettingKey.ACADEMY_REVIEWS_TITLE),
|
settings.get(SettingKey.ACADEMY_REVIEWS_TITLE),
|
||||||
@@ -137,6 +138,23 @@ public class PublicAcademyService {
|
|||||||
sections.getOrDefault(PageSectionType.FAQ, List.of()));
|
sections.getOrDefault(PageSectionType.FAQ, List.of()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adres grafiki obok naglowka.
|
||||||
|
*
|
||||||
|
* Plik idzie przez `/api/public/media/{id}`, jak obrazki w tresci strony:
|
||||||
|
* klucz obiektu w MinIO nie opuszcza serwera, a typ pochodzi z bazy.
|
||||||
|
* Wartosc, ktora nie jest liczba, daje null zamiast adresu donikad:
|
||||||
|
* zepsuty obrazek w najbardziej widocznym miejscu strony wyglada gorzej
|
||||||
|
* niz domyslny motyw.
|
||||||
|
*/
|
||||||
|
private String heroImageUrl() {
|
||||||
|
String id = settings.get(SettingKey.ACADEMY_HERO_IMAGE_MEDIA_ID);
|
||||||
|
if (id == null || !id.trim().matches("\\d+")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return "/api/public/media/" + id.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liczby w naglowku.
|
* Liczby w naglowku.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -413,6 +413,20 @@ public enum SettingKey {
|
|||||||
"Kursy online i otwarte webinary z ekspertami. Jedno konto, certyfikaty, "
|
"Kursy online i otwarte webinary z ekspertami. Jedno konto, certyfikaty, "
|
||||||
+ "dostęp z dowolnego urządzenia.",
|
+ "dostęp z dowolnego urządzenia.",
|
||||||
"Zdanie pod nagłówkiem strony głównej Akademii"),
|
"Zdanie pod nagłówkiem strony głównej Akademii"),
|
||||||
|
/**
|
||||||
|
* Grafika obok naglowka strony glownej Akademii.
|
||||||
|
*
|
||||||
|
* Identyfikator pliku z biblioteki mediow, nie adres i nie plik w repozytorium.
|
||||||
|
* Zdjecie jest zwykle materialem konkretnego klienta (z jego logo), a model
|
||||||
|
* wdrozenia zabrania danych klienta w obrazie Docker: wbudowane w kod
|
||||||
|
* pojawiloby sie na stronie kazdej kolejnej instancji. Ta sama regula co przy
|
||||||
|
* logo i ikonie karty.
|
||||||
|
*
|
||||||
|
* Puste oznacza domyslny panel z motywem wodnym, co jest poprawnym stanem.
|
||||||
|
*/
|
||||||
|
ACADEMY_HERO_IMAGE_MEDIA_ID(SettingType.MEDIA, "",
|
||||||
|
"Grafika obok nagłówka strony głównej Akademii. Puste pokazuje domyślny motyw.",
|
||||||
|
Requirement.OPTIONAL_EMPTY),
|
||||||
ACADEMY_EXPERTS_TITLE(SettingType.STRING, "Uczysz się od praktyków",
|
ACADEMY_EXPERTS_TITLE(SettingType.STRING, "Uczysz się od praktyków",
|
||||||
"Nagłówek sekcji z prowadzącymi. Same osoby dodaje się w zakładce Strona główna."),
|
"Nagłówek sekcji z prowadzącymi. Same osoby dodaje się w zakładce Strona główna."),
|
||||||
ACADEMY_EXPERTS_TEXT(SettingType.STRING, "",
|
ACADEMY_EXPERTS_TEXT(SettingType.STRING, "",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { publicClient } from '../api/publicClient'
|
|||||||
import type { AcademyHome, SectionItem } from '../api/types'
|
import type { AcademyHome, SectionItem } from '../api/types'
|
||||||
import { Icon } from '../components/Icon'
|
import { Icon } from '../components/Icon'
|
||||||
import { Button, ErrorState, Spinner } from '../components/ui'
|
import { Button, ErrorState, Spinner } from '../components/ui'
|
||||||
|
import { plural } from '../lib/dates'
|
||||||
import { useDocumentMeta } from '../lib/useDocumentMeta'
|
import { useDocumentMeta } from '../lib/useDocumentMeta'
|
||||||
import { AcademyLayout } from './AcademyLayout'
|
import { AcademyLayout } from './AcademyLayout'
|
||||||
import { CourseCard, formatPrice, mediaUrl } from './AcademyBits'
|
import { CourseCard, formatPrice, mediaUrl } from './AcademyBits'
|
||||||
@@ -90,12 +91,18 @@ export function AcademyHomePage() {
|
|||||||
* przypadkowa, a przy zerze zdanych bylaby zniechecajaca. Wroci, gdy bedzie
|
* przypadkowa, a przy zerze zdanych bylaby zniechecajaca. Wroci, gdy bedzie
|
||||||
* co uśredniać.
|
* co uśredniać.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* Etykiety odmieniane po liczbie. Stala forma "kursantow" dawala na zywej
|
||||||
|
* stronie "1 kursantow": licznik jest pierwsza rzecza, ktora widzi gosc,
|
||||||
|
* i blad gramatyczny w tym miejscu podwaza wiarygodnosc calej strony.
|
||||||
|
*/
|
||||||
|
const s = data.stats
|
||||||
const visibleStats = [
|
const visibleStats = [
|
||||||
{ value: data.stats.hours, label: 'godzin materiału' },
|
{ value: s.hours, label: plural(s.hours, 'godzina materiału', 'godziny materiału', 'godzin materiału') },
|
||||||
{ value: data.stats.students, label: 'kursantów' },
|
{ value: s.students, label: plural(s.students, 'kursant', 'kursantów', 'kursantów') },
|
||||||
{ value: data.stats.completed, label: 'ukończonych szkoleń' },
|
{ value: s.completed, label: plural(s.completed, 'ukończone szkolenie', 'ukończone szkolenia', 'ukończonych szkoleń') },
|
||||||
{ value: data.stats.courses, label: 'kursów online' },
|
{ value: s.courses, label: plural(s.courses, 'kurs online', 'kursy online', 'kursów online') },
|
||||||
].filter((s) => s.value > 0)
|
].filter((stat) => stat.value > 0)
|
||||||
|
|
||||||
/** Kursy platne do sekcji cennika. Brak platnych ukrywa cala sekcje. */
|
/** Kursy platne do sekcji cennika. Brak platnych ukrywa cala sekcje. */
|
||||||
const paidCourses = data.courses.filter((c) => !c.free)
|
const paidCourses = data.courses.filter((c) => !c.free)
|
||||||
@@ -159,6 +166,23 @@ export function AcademyHomePage() {
|
|||||||
jedna pod druga, a panel schodzi pod tresc: `order` zostawiamy
|
jedna pod druga, a panel schodzi pod tresc: `order` zostawiamy
|
||||||
domyslny, bo tresc jest wazniejsza od ozdoby.
|
domyslny, bo tresc jest wazniejsza od ozdoby.
|
||||||
*/}
|
*/}
|
||||||
|
{data.heroImageUrl ? (
|
||||||
|
/*
|
||||||
|
* Grafika z ustawien (biblioteka mediow), a nie z repozytorium:
|
||||||
|
* to material konkretnego klienta. Proporcje brane z samego
|
||||||
|
* zdjecia (16:9), a nie sztywna wysokosc, bo `object-cover` przy
|
||||||
|
* innym ksztalcie ramki obcinal brzegi, gdzie zwykle stoi logo.
|
||||||
|
* Opis pusty: zdjecie jest ozdoba, tresc niesie naglowek obok.
|
||||||
|
*/
|
||||||
|
<div className="relative hidden overflow-hidden rounded-xl shadow-lg lg:block">
|
||||||
|
<img
|
||||||
|
src={data.heroImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="aspect-video h-auto w-full object-cover"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="relative hidden h-72 overflow-hidden rounded-xl bg-gradient-hydro shadow-lg lg:block lg:h-96"
|
className="relative hidden h-72 overflow-hidden rounded-xl bg-gradient-hydro shadow-lg lg:block lg:h-96"
|
||||||
@@ -171,6 +195,7 @@ export function AcademyHomePage() {
|
|||||||
<Icon name="droplet" size={34} className="text-white" />
|
<Icon name="droplet" size={34} className="text-white" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Band>
|
</Band>
|
||||||
|
|
||||||
|
|||||||
@@ -1014,6 +1014,8 @@ export type AcademyHome = {
|
|||||||
heroEyebrow: string
|
heroEyebrow: string
|
||||||
heroTitle: string
|
heroTitle: string
|
||||||
heroText: string
|
heroText: string
|
||||||
|
/** Adres grafiki obok naglowka; null oznacza domyslny motyw wodny. */
|
||||||
|
heroImageUrl: string | null
|
||||||
expertsTitle: string
|
expertsTitle: string
|
||||||
expertsText: string
|
expertsText: string
|
||||||
reviewsTitle: string
|
reviewsTitle: string
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function formatDuration(startIso: string, endIso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Odmiana rzeczownika po liczbie, wedlug regul polskich. */
|
/** Odmiana rzeczownika po liczbie, wedlug regul polskich. */
|
||||||
function plural(count: number, one: string, few: string, many: string): string {
|
export function plural(count: number, one: string, few: string, many: string): string {
|
||||||
if (count === 1) return one
|
if (count === 1) return one
|
||||||
const lastTwo = count % 100
|
const lastTwo = count % 100
|
||||||
const last = count % 10
|
const last = count % 10
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from '../../components/ui'
|
} from '../../components/ui'
|
||||||
import { useConfirm } from '../../components/Confirm'
|
import { useConfirm } from '../../components/Confirm'
|
||||||
import { useToasts } from '../../components/Toasts'
|
import { useToasts } from '../../components/Toasts'
|
||||||
|
import { SettingsPane } from './SettingsPane'
|
||||||
|
import { SettingsSection } from './SettingsSection'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tresc sekcji statycznych strony glownej Akademii.
|
* Tresc sekcji statycznych strony glownej Akademii.
|
||||||
@@ -69,12 +71,40 @@ const SECTIONS: {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Naglowek strony glownej: pojedyncze wartosci z `app_settings`.
|
||||||
|
*
|
||||||
|
* Istnialy w backendzie od poczatku, ale nie bylo ich w panelu, wiec tytul
|
||||||
|
* i zdanie pod nim dalo sie zmienic wylacznie zapytaniem do bazy. Grafika to
|
||||||
|
* identyfikator z biblioteki mediow, bo zdjecie jest materialem klienta i nie
|
||||||
|
* moze trafic do repozytorium.
|
||||||
|
*/
|
||||||
|
const HERO_KEYS = [
|
||||||
|
'ACADEMY_HERO_EYEBROW',
|
||||||
|
'ACADEMY_HERO_TITLE',
|
||||||
|
'ACADEMY_HERO_TEXT',
|
||||||
|
'ACADEMY_HERO_IMAGE_MEDIA_ID',
|
||||||
|
]
|
||||||
|
|
||||||
export function HomePageSettingsPage() {
|
export function HomePageSettingsPage() {
|
||||||
const [active, setActive] = useState<PageSectionType>('EXPERT')
|
const [active, setActive] = useState<PageSectionType>('EXPERT')
|
||||||
const section = SECTIONS.find((s) => s.id === active)!
|
const section = SECTIONS.find((s) => s.id === active)!
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<SettingsPane>
|
||||||
|
{({ settings, onSave, saving }) => (
|
||||||
|
<SettingsSection
|
||||||
|
title="Nagłówek strony głównej"
|
||||||
|
description="Tytuł, zdanie pod nim i grafika obok. Pusta grafika pokazuje domyślny motyw."
|
||||||
|
settings={settings}
|
||||||
|
keys={HERO_KEYS}
|
||||||
|
onSave={onSave}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsPane>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="Strona główna Akademii"
|
title="Strona główna Akademii"
|
||||||
description="Treść sekcji widocznych dla kursantów. Zmiany są widoczne od razu, bez wdrożenia."
|
description="Treść sekcji widocznych dla kursantów. Zmiany są widoczne od razu, bez wdrożenia."
|
||||||
|
|||||||
Reference in New Issue
Block a user