Grafika strony glownej z biblioteki mediow, naglowek w panelu
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:
2026-09-28 07:43:55 +02:00
parent 698425bbe3
commit 4f21af5f80
7 changed files with 97 additions and 6 deletions
@@ -215,6 +215,8 @@ public final class PublicAcademyDtos {
String heroEyebrow,
String heroTitle,
String heroText,
/** Adres grafiki obok naglowka albo null, gdy strona ma domyslny motyw. */
String heroImageUrl,
String expertsTitle,
String expertsText,
String reviewsTitle,
@@ -122,6 +122,7 @@ public class PublicAcademyService {
settings.get(SettingKey.ACADEMY_HERO_EYEBROW),
settings.get(SettingKey.ACADEMY_HERO_TITLE),
settings.get(SettingKey.ACADEMY_HERO_TEXT),
heroImageUrl(),
settings.get(SettingKey.ACADEMY_EXPERTS_TITLE),
settings.get(SettingKey.ACADEMY_EXPERTS_TEXT),
settings.get(SettingKey.ACADEMY_REVIEWS_TITLE),
@@ -137,6 +138,23 @@ public class PublicAcademyService {
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.
*
@@ -413,6 +413,20 @@ public enum SettingKey {
"Kursy online i otwarte webinary z ekspertami. Jedno konto, certyfikaty, "
+ "dostęp z dowolnego urządzenia.",
"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",
"Nagłówek sekcji z prowadzącymi. Same osoby dodaje się w zakładce Strona główna."),
ACADEMY_EXPERTS_TEXT(SettingType.STRING, "",
+30 -5
View File
@@ -5,6 +5,7 @@ import { publicClient } from '../api/publicClient'
import type { AcademyHome, SectionItem } from '../api/types'
import { Icon } from '../components/Icon'
import { Button, ErrorState, Spinner } from '../components/ui'
import { plural } from '../lib/dates'
import { useDocumentMeta } from '../lib/useDocumentMeta'
import { AcademyLayout } from './AcademyLayout'
import { CourseCard, formatPrice, mediaUrl } from './AcademyBits'
@@ -90,12 +91,18 @@ export function AcademyHomePage() {
* przypadkowa, a przy zerze zdanych bylaby zniechecajaca. Wroci, gdy bedzie
* 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 = [
{ value: data.stats.hours, label: 'godzin materiału' },
{ value: data.stats.students, label: 'kursantów' },
{ value: data.stats.completed, label: 'ukończonych szkoleń' },
{ value: data.stats.courses, label: 'kursów online' },
].filter((s) => s.value > 0)
{ value: s.hours, label: plural(s.hours, 'godzina materiału', 'godziny materiału', 'godzin materiału') },
{ value: s.students, label: plural(s.students, 'kursant', 'kursantów', 'kursantów') },
{ value: s.completed, label: plural(s.completed, 'ukończone szkolenie', 'ukończone szkolenia', 'ukończonych szkoleń') },
{ value: s.courses, label: plural(s.courses, 'kurs online', 'kursy online', 'kursów online') },
].filter((stat) => stat.value > 0)
/** Kursy platne do sekcji cennika. Brak platnych ukrywa cala sekcje. */
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
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
aria-hidden="true"
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" />
</span>
</div>
)}
</div>
</Band>
+2
View File
@@ -1014,6 +1014,8 @@ export type AcademyHome = {
heroEyebrow: string
heroTitle: string
heroText: string
/** Adres grafiki obok naglowka; null oznacza domyslny motyw wodny. */
heroImageUrl: string | null
expertsTitle: string
expertsText: string
reviewsTitle: string
+1 -1
View File
@@ -171,7 +171,7 @@ export function formatDuration(startIso: string, endIso: string): string {
}
/** 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
const lastTwo = count % 100
const last = count % 10
@@ -19,6 +19,8 @@ import {
} from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { SettingsPane } from './SettingsPane'
import { SettingsSection } from './SettingsSection'
/**
* 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() {
const [active, setActive] = useState<PageSectionType>('EXPERT')
const section = SECTIONS.find((s) => s.id === active)!
return (
<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
title="Strona główna Akademii"
description="Treść sekcji widocznych dla kursantów. Zmiany są widoczne od razu, bez wdrożenia."