Otevrena stranka sekala prehravani videa

Pri otevrenem portalu zacalo vedle nej sekat prehravani videa, po zavreni bylo
hned dobre. Neslo o smycku v kodu: zivy prenos udalosti je SSE, jedno spojeni na
celou aplikaci, a setTimeout je v nem jen na odstup pri znovupripojeni. Byly to
tri veci.

Rozmazavani plne barvy. `.glass`, podklad skoro kazdeho panelu, mel
backdrop-filter blur(14px). Jenze pod kartami je plna barva (bg-ink-950 na
dashboardu), takze se rozmazavala jednolita plocha: zadny rozdil videt nebyl
a kazda karta si za to drzela vlastni rozmazanou vrstvu ve skladaci. V kodu je
glass na 58 mistech, na jedne obrazovce jich je snadno dvacet. Rozmazani zustava
na peti mistech, kde pod nim neco opravdu projizdi: lepici hlavicky, podklad
modalu, prekryv menu na mobilu.

Nekonecne animace nad velkymi rozmazanymi plochami. Prihlaseni a verejny web
mely zare o velikosti 26 az 34 rem s blur(120px), kterym se donekonecna
animovala pruhlednost - to znamena prepocitavat obrovskou vrstvu sedesatkrat za
sekundu, i kdyz se na strance nic nedeje. Zare zustaly, animace zmizela.
Pribylo prefers-reduced-motion.

Kazda udalost prekreslila cely dashboard. EventStreamProvider mel v tomtez
kontextu stav spojeni, odber i seznam poslednich udalosti. Seznam se meni pri
kazde udalosti, takze se menila cela hodnota kontextu a prekreslil se kazdy, kdo
si jen zaridil odber - tedy vsechny dotazy s refetchOn na strance, i kdyz o ten
typ udalosti nestaly. Pri behu automatizace se cely dashboard prekresloval
nekolikrat za sekundu, a s nim dvacet rozmazanych vrstev. Kontext je proto
rozdeleny: useEventStream da stav a odber, useEventLog seznam udalosti.

Filtr sekci nefiltroval. Seznam ticketu sklada adresu dotazu v useMemo, ale
v zavislostech chybely groupId, typeId, tag a stage - adresa se po prepnuti
sekce neprepocitala a data se nenacetla znovu. Driv to nevadilo, byly to filtry
nastavitelne jen odkazem, ktere se po nacteni nemenily. Se zalozkou "Vsechny
tickety" a jejim vyberem sekce je to chyba, ktera je videt.

Overeno: groupId=grp_servicedesk vrati jeden ticket, groupId=none zbyle dva.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-08 15:23:43 +02:00
co-authored by Claude Opus 5
parent 9f4b8c0596
commit 0c405ea55a
8 changed files with 144 additions and 16 deletions
@@ -3,10 +3,16 @@ import type { ReactNode } from 'react';
import { connectEventStream, type StreamStatus } from '@/lib/eventStream';
import type { DashboardEvent, DashboardEventType } from '@/types/events';
/**
* Stav spojeni a odber. **Bez seznamu udalosti.**
*
* Seznam se meni pri kazde udalosti a kdyz byl v teze hodnote kontextu, prekreslil
* se pri kazde udalosti kazdy, kdo si jen zaridil odber - tedy vsechny dotazy
* s `refetchOn` na cele strance, i kdyz o ten typ udalosti nestaly. Pri behu
* automatizace to znamenalo prekreslovani celeho dashboardu nekolikrat za sekundu.
*/
interface EventStreamState {
status: StreamStatus;
/** Poslednich par udalosti, nejnovejsi prvni. */
events: DashboardEvent[];
/** Prihlaseni k odberu. Vraci funkci pro odhlaseni. */
subscribe: (listener: (event: DashboardEvent) => void) => () => void;
}
@@ -15,13 +21,15 @@ const noop = () => () => {};
const EventStreamContext = createContext<EventStreamState>({
status: 'closed',
events: [],
subscribe: () => {
console.warn('[stream] subscribe mimo EventStreamProvider, udalosti nedorazi');
return noop();
},
});
/** Poslednich par udalosti, nejnovejsi prvni. Zvlast, protoze se meni porad. */
const EventLogContext = createContext<DashboardEvent[]>([]);
const MAX_KEPT = 30;
/**
@@ -59,18 +67,29 @@ export function EventStreamProvider({ children }: { children: ReactNode }) {
};
}, []);
const value = useMemo<EventStreamState>(
() => ({ status, events, subscribe }),
[status, events, subscribe],
);
const value = useMemo<EventStreamState>(() => ({ status, subscribe }), [status, subscribe]);
return <EventStreamContext.Provider value={value}>{children}</EventStreamContext.Provider>;
return (
<EventStreamContext.Provider value={value}>
<EventLogContext.Provider value={events}>{children}</EventLogContext.Provider>
</EventStreamContext.Provider>
);
}
export function useEventStream(): EventStreamState {
return useContext(EventStreamContext);
}
/**
* Posledni udalosti. Bere si je jen ten, kdo je opravdu vypisuje.
*
* Kdo chce jen reagovat na udalost, pouzije `useEventListener` - ten se
* prekresluje jen kdyz sam neco zmeni, ne pri kazde udalosti na strance.
*/
export function useEventLog(): DashboardEvent[] {
return useContext(EventLogContext);
}
/** Zavola callback jen pri udalostech uvedenych typu. */
export function useEventListener(
types: DashboardEventType[],
+2 -2
View File
@@ -1,7 +1,7 @@
import { AlarmClock, Bell, CheckCircle2, LifeBuoy, UserCheck, Webhook, Workflow, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { useEventLog } from '@/components/dashboard/EventStreamProvider';
import { cn } from '@/lib/cn';
import type { DashboardEvent, DashboardEventType } from '@/types/events';
@@ -27,7 +27,7 @@ const style: Record<DashboardEventType, { icon: LucideIcon; tone: string }> = {
/** Bubliny o tom, co se prave stalo. Bez nich by zive zmeny nebyly poznat. */
export function EventToasts() {
const { events } = useEventStream();
const events = useEventLog();
const [visible, setVisible] = useState<DashboardEvent[]>([]);
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
+1 -1
View File
@@ -14,7 +14,7 @@ export function CallToAction() {
<div className="relative overflow-hidden rounded-3xl border border-brand-400/25 bg-gradient-to-br from-brand-500/12 via-ink-800/60 to-accent-500/12 px-6 py-14 text-center sm:px-14">
<div
aria-hidden
className="animate-pulse-slow pointer-events-none absolute -top-24 left-1/2 size-96 -translate-x-1/2 rounded-full bg-brand-500/20 blur-[100px]"
className="pointer-events-none absolute -top-24 left-1/2 size-96 -translate-x-1/2 rounded-full bg-brand-500/20 blur-[100px]"
/>
<h2 className="relative text-3xl font-bold text-white sm:text-4xl">
{t('cta.titleFirst')} <span className="text-gradient">{t('cta.titleAccent')}</span>
+28 -2
View File
@@ -174,11 +174,21 @@
Vlastni utility. Pouzivat jen pro veci, ktere se opakuji vsude.
------------------------------------------------------------------- */
/* Sklenena karta - zaklad vetsiny panelu na webu i v dashboardu */
/*
* Sklenena karta - zaklad vetsiny panelu na webu i v dashboardu.
*
* **Bez `backdrop-filter`.** Bylo tu `blur(14px)`, jenze pod kartami je plna
* barva (`bg-ink-950` na dashboardu), takze se rozmazavala jednolita plocha:
* zadny rozdil videt nebyl a kazda karta si za to drzela vlastni rozmazanou
* vrstvu ve skladaci. Pri padesati kartach a zivem streamu udalosti to
* vytizilo GPU natolik, ze vedle toho sekalo prehravani videa.
*
* Rozmazani zustava tam, kde neco opravdu prekryva: lepici hlavicka
* a podklady modalu maji svoje `backdrop-blur-*` primo u sebe.
*/
@utility glass {
background-color: --alpha(var(--color-ink-800) / 70%);
border: 1px solid --alpha(var(--color-ink-600) / 70%);
backdrop-filter: blur(14px);
}
/*
@@ -209,3 +219,19 @@
@utility mask-fade-b {
mask-image: linear-gradient(to bottom, black, transparent);
}
/*
* Kdo si v systemu vypnul animace, nema je dostat ani tady.
*
* Nekonecne animace jsou to jedine, co na klidne strance porad zamestnava
* skladac - a prave ty jsou tady zbytne.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+2 -2
View File
@@ -85,8 +85,8 @@ export default function Login() {
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-900 px-5 py-14">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-grid opacity-25" />
<div className="animate-pulse-slow absolute -top-32 left-1/2 size-[34rem] -translate-x-1/2 rounded-full bg-brand-500/16 blur-[120px]" />
<div className="animate-pulse-slow absolute -bottom-40 right-0 size-[26rem] rounded-full bg-accent-500/14 blur-[120px]" />
<div className="absolute -top-32 left-1/2 size-[34rem] -translate-x-1/2 rounded-full bg-brand-500/16 blur-[120px]" />
<div className="absolute -bottom-40 right-0 size-[26rem] rounded-full bg-accent-500/14 blur-[120px]" />
</div>
<div className="w-full max-w-md">
+4 -1
View File
@@ -121,7 +121,10 @@ export default function Tickets() {
if (channel) params.set('channel', channel);
const search = params.toString();
return `/api/dashboard/tickets${search ? `?${search}` : ''}`;
}, [scope, tenantId, assignee, status, channel]);
// Vsechno, co se do adresy dosazuje, musi byt i tady. Chybel `groupId`
// a spol., takze prepnuti sekce adresu neprepocitalo a seznam se nenacetl
// znovu - filtr vypadal, ze nefunguje.
}, [scope, tenantId, assignee, status, channel, typeId, tag, stage, groupId]);
const workloadPath = useMemo(() => {
const params = new URLSearchParams();