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 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, "",
+30 -5
View File
@@ -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>
+2
View File
@@ -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
+1 -1
View File
@@ -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."