Buforowanie danych w SvelteKit

Buforowanie danych w SvelteKit

My poprzedni post był szerokim przeglądem SvelteKit, w którym zobaczyliśmy, jakie to wspaniałe narzędzie do tworzenia stron internetowych. Ten post rozwidli to, co tam zrobiliśmy i zagłębi się w ulubiony temat każdego programisty: buforowanie. Więc pamiętaj, aby przeczytać mój ostatni post, jeśli jeszcze tego nie zrobiłeś. Kod do tego wpisu jest dostępny na GitHub, jak również demo na żywo.

Ten post dotyczy przetwarzania danych. Dodamy podstawowe funkcje wyszukiwania, które zmodyfikują ciąg zapytania strony (przy użyciu wbudowanych funkcji SvelteKit) i ponownie uruchomią moduł ładujący strony. Ale zamiast po prostu ponownie przeszukiwać naszą (wyimaginowaną) bazę danych, dodamy trochę pamięci podręcznej, więc ponowne przeszukanie wcześniejszych wyszukiwań (lub użycie przycisku Wstecz) szybko pokaże wcześniej pobrane dane z pamięci podręcznej. Przyjrzymy się, jak kontrolować długość czasu ważności danych w pamięci podręcznej i, co ważniejsze, jak ręcznie unieważnić wszystkie wartości w pamięci podręcznej. Jako wisienkę na torcie przyjrzymy się, jak możemy ręcznie zaktualizować dane na bieżącym ekranie, po stronie klienta, po mutacji, jednocześnie czyszcząc pamięć podręczną.

To będzie dłuższy, trudniejszy post niż większość tego, co zwykle piszę, ponieważ poruszamy trudniejsze tematy. Ten post zasadniczo pokaże, jak zaimplementować wspólne funkcje popularnych narzędzi do obsługi danych, takich jak Reaguj-zapytanie; ale zamiast pobierać zewnętrzną bibliotekę, będziemy używać tylko platformy internetowej i funkcji SvelteKit.

Niestety, funkcje platformy internetowej są na nieco niższym poziomie, więc będziemy wykonywać nieco więcej pracy, niż byliście do tego przyzwyczajeni. Zaletą jest to, że nie będziemy potrzebować żadnych zewnętrznych bibliotek, co pomoże utrzymać niewielkie rozmiary pakietów. Proszę, nie używaj podejść, które ci pokażę, chyba że masz ku temu dobry powód. W pamięci podręcznej łatwo popełnić błąd, a jak zobaczysz, kod aplikacji jest nieco skomplikowany. Miejmy nadzieję, że Twój magazyn danych jest szybki, a interfejs użytkownika jest w porządku, dzięki czemu SvelteKit może zawsze żądać danych, których potrzebuje dla dowolnej strony. Jeśli tak, zostaw to w spokoju. Ciesz się prostotą. Ale ten post pokaże ci kilka sztuczek, kiedy to przestanie mieć miejsce.

Mówiąc o reagowaniu na zapytanie, to właśnie został wydany dla Svelte'a! Jeśli więc polegasz na ręcznych technikach buforowania dużo, sprawdź ten projekt i zobacz, czy może pomóc.

Konfigurowanie

Zanim zaczniemy, wprowadźmy kilka małych zmian kod, który mieliśmy wcześniej. To da nam wymówkę, aby zobaczyć inne funkcje SvelteKit i, co ważniejsze, przygotuje nas na sukces.

Najpierw przenieśmy nasze ładowanie danych z naszego modułu ładującego +page.server.js do Trasa API. Stworzymy +server.js plik w routes/api/todos, a następnie dodaj a GET funkcjonować. Oznacza to, że będziemy teraz mogli pobierać (używając domyślnego czasownika GET) do pliku /api/todos ścieżka. Dodamy ten sam kod ładowania danych, co poprzednio.

import { json } from "@sveltejs/kit";
import { getTodos } from "$lib/data/todoData"; export async function GET({ url, setHeaders, request }) { const search = url.searchParams.get("search") || ""; const todos = await getTodos(search); return json(todos);
}

Następnie weźmy program ładujący strony, który mieliśmy i po prostu zmieńmy nazwę pliku z +page.server.js do +page.js (lub .ts jeśli zbudowałeś rusztowanie w swoim projekcie, aby używać TypeScript). To zmienia nasz program ładujący na „uniwersalny” moduł ładujący, a nie moduł ładujący serwer. Dokumentacja SvelteKit wyjaśnić różnicę, ale uniwersalny program ładujący działa zarówno na serwerze, jak i na kliencie. Zaletą dla nas jest to, że fetch wywołanie naszego nowego punktu końcowego będzie działać bezpośrednio z naszej przeglądarki (po początkowym załadowaniu), używając natywnej przeglądarki fetch funkcjonować. Za chwilę dodamy standardowe buforowanie HTTP, ale na razie wszystko, co zrobimy, to wywołanie punktu końcowego.

export async function load({ fetch, url, setHeaders }) { const search = url.searchParams.get("search") || ""; const resp = await fetch(`/api/todos?search=${encodeURIComponent(search)}`); const todos = await resp.json(); return { todos, };
}

Teraz dodajmy prosty formularz do naszego /list strona:

<div class="search-form"> <form action="/pl/list"> <label>Search</label> <input autofocus name="search" /> </form>
</div>

Tak, formularze mogą być kierowane bezpośrednio do naszych normalnych programów ładujących strony. Teraz możemy dodać wyszukiwane hasło w polu wyszukiwania, kliknij Wchodzę, a do ciągu zapytania w adresie URL zostanie dodany termin „wyszukiwania”, co spowoduje ponowne uruchomienie programu ładującego i wyszukanie elementów do zrobienia.

Formularz wyszukiwania
Buforowanie danych w SvelteKit

Zwiększmy też opóźnienie w naszym todoData.js plik w /lib/data. Ułatwi to sprawdzenie, kiedy dane są i nie są buforowane podczas pracy nad tym postem.

export const wait = async amount => new Promise(res => setTimeout(res, amount ?? 500));

Pamiętaj, pełny kod tego postu to wszystko na GitHubie, jeśli chcesz się do niego odwołać.

Podstawowe buforowanie

Zacznijmy od dodania buforowania do naszego /api/todos punkt końcowy. Wrócimy do naszego +server.js plik i dodaj nasz pierwszy nagłówek kontroli pamięci podręcznej.

setHeaders({ "cache-control": "max-age=60",
});

… co sprawi, że cała funkcja będzie wyglądać tak:

export async function GET({ url, setHeaders, request }) { const search = url.searchParams.get("search") || ""; setHeaders({ "cache-control": "max-age=60", }); const todos = await getTodos(search); return json(todos);
}

Wkrótce przyjrzymy się ręcznemu unieważnianiu, ale ta funkcja mówi tylko o buforowaniu tych wywołań API przez 60 sekund. Ustaw to na cokolwiek chceszi w zależności od przypadku użycia, stale-while-revalidate może też warto się zainteresować.

I tak po prostu, nasze zapytania są buforowane.

Pamięć podręczna w DevTools.
Buforowanie danych w SvelteKit

Note Upewnij się, że usuń zaznaczenie pole wyboru, które wyłącza buforowanie w narzędziach deweloperskich.

Pamiętaj, że jeśli początkową nawigacją w aplikacji jest strona z listą, te wyniki wyszukiwania zostaną zapisane wewnętrznie w pamięci podręcznej SvelteKit, więc nie spodziewaj się niczego zobaczyć w DevTools po powrocie do tego wyszukiwania.

Co jest buforowane i gdzie

Nasze pierwsze, renderowane przez serwer ładowanie naszej aplikacji (zakładając, że zaczynamy od /list page) zostaną pobrane na serwer. SvelteKit dokona serializacji i prześle te dane do naszego klienta. Co więcej, będzie obserwował Cache-Control nagłówek w odpowiedzi i będzie wiedział, aby użyć tych danych z pamięci podręcznej dla tego wywołania punktu końcowego w oknie pamięci podręcznej (które ustawiliśmy na 60 sekund w przykładzie).

Po tym początkowym załadowaniu, gdy zaczniesz szukać na stronie, powinieneś zobaczyć żądania sieciowe z przeglądarki do /api/todos lista. Gdy szukasz rzeczy, których już szukałeś (w ciągu ostatnich 60 sekund), odpowiedzi powinny ładować się natychmiast, ponieważ są przechowywane w pamięci podręcznej.

Szczególnie fajne w tym podejściu jest to, że ponieważ jest to buforowanie za pośrednictwem natywnej pamięci podręcznej przeglądarki, wywołania te mogą (w zależności od tego, jak zarządzasz pomijaniem pamięci podręcznej, o którym będziemy myśleć) nadal buforować, nawet jeśli przeładujesz stronę (w przeciwieństwie do początkowe obciążenie po stronie serwera, które zawsze wywołuje punkt końcowy świeży, nawet jeśli zrobił to w ciągu ostatnich 60 sekund).

Oczywiście dane mogą się zmienić w dowolnym momencie, więc potrzebujemy sposobu na ręczne wyczyszczenie tej pamięci podręcznej, co przyjrzymy się dalej.

Unieważnienie pamięci podręcznej

W tej chwili dane będą przechowywane w pamięci podręcznej przez 60 sekund. Bez względu na wszystko, po minucie świeże dane zostaną pobrane z naszego magazynu danych. Możesz potrzebować krótszego lub dłuższego okresu, ale co się stanie, jeśli zmutujesz niektóre dane i będziesz chciał natychmiast wyczyścić pamięć podręczną, aby następne zapytanie było aktualne? Rozwiążemy ten problem, dodając wartość pomijania zapytań do adresu URL, który wysyłamy do naszego new /todos punkt końcowy.

Zapiszmy tę wartość pomijania pamięci podręcznej w pliku cookie. Tę wartość można ustawić na serwerze, ale nadal odczytywać na kliencie. Spójrzmy na przykładowy kod.

Możemy stworzyć +layout.server.js plik w samym katalogu głównym naszego routes teczka. Będzie działać podczas uruchamiania aplikacji i jest idealnym miejscem do ustawienia początkowej wartości pliku cookie.

export function load({ cookies, isDataRequest }) { const initialRequest = !isDataRequest; const cacheValue = initialRequest ? +new Date() : cookies.get("todos-cache"); if (initialRequest) { cookies.set("todos-cache", cacheValue, { path: "/", httpOnly: false }); } return { todosCacheBust: cacheValue, };
}

Być może zauważyłeś isDataRequest wartość. Pamiętaj, że układy będą uruchamiane ponownie przy każdym wywołaniu kodu klienta invalidate()lub za każdym razem, gdy uruchomimy akcję serwera (zakładając, że nie wyłączymy domyślnego zachowania). isDataRequest wskazuje te ponowne uruchomienia, więc ustawiamy plik cookie tylko wtedy, gdy tak jest false; w przeciwnym razie wysyłamy to, co już tam jest.

Połączenia httpOnly: false ważna jest też flaga. Dzięki temu nasz kod klienta może odczytać te wartości plików cookie document.cookie. Normalnie byłoby to zagrożeniem dla bezpieczeństwa, ale w naszym przypadku są to liczby bez znaczenia, które pozwalają nam buforować lub usuwać dane z pamięci podręcznej.

Odczytywanie wartości pamięci podręcznej

Nasza uniwersalna ładowarka nazywana jest naszą /todos punkt końcowy. To działa na serwerze lub kliencie i musimy odczytać tę wartość pamięci podręcznej, którą właśnie skonfigurowaliśmy, bez względu na to, gdzie się znajdujemy. Jest to stosunkowo łatwe, jeśli jesteśmy na serwerze: możemy zadzwonić await parent() aby uzyskać dane z układów nadrzędnych. Ale na kliencie będziemy musieli użyć trochę kodu brutto do przeanalizowania document.cookie:

export function getCookieLookup() { if (typeof document !== "object") { return {}; } return document.cookie.split("; ").reduce((lookup, v) => { const parts = v.split("="); lookup[parts[0]] = parts[1]; return lookup; }, {});
} const getCurrentCookieValue = name => { const cookies = getCookieLookup(); return cookies[name] ?? "";
};

Na szczęście potrzebujemy go tylko raz.

Wysyłanie wartości pamięci podręcznej

Ale teraz musimy wysłać tę wartość dla nas /todos punkt końcowy.

import { getCurrentCookieValue } from "$lib/util/cookieUtils"; export async function load({ fetch, parent, url, setHeaders }) { const parentData = await parent(); const cacheBust = getCurrentCookieValue("todos-cache") || parentData.todosCacheBust; const search = url.searchParams.get("search") || ""; const resp = await fetch(`/api/todos?search=${encodeURIComponent(search)}&cache=${cacheBust}`); const todos = await resp.json(); return { todos, };
}

getCurrentCookieValue('todos-cache') sprawdza, czy jesteśmy na kliencie (poprzez sprawdzenie typu dokumentu) i nic nie zwraca, jeśli tak, w którym momencie wiemy, że jesteśmy na serwerze. Następnie używa wartości z naszego układu.

Niszczenie pamięci podręcznej

Ale w jaki sposób czy faktycznie aktualizujemy tę wartość pomijania pamięci podręcznej, kiedy jest to konieczne? Ponieważ jest przechowywany w pliku cookie, możemy wywołać go w ten sposób z dowolnej akcji serwera:

cookies.set("todos-cache", cacheValue, { path: "/", httpOnly: false });

Implementacja

Stąd wszystko jest z górki; wykonaliśmy ciężką pracę. Omówiliśmy różne potrzebne nam prymitywy platformy internetowej, a także dokąd się udają. A teraz zabawmy się i napiszmy kod aplikacji, aby połączyć to wszystko w całość.

Z powodów, które staną się jasne za chwilę, zacznijmy od dodania funkcji edycji do naszego /list strona. Dodamy ten drugi wiersz tabeli dla każdego zadania:

import { enhance } from "$app/forms";
<tr> <td colspan="4"> <form use:enhance method="post" action="?/editTodo"> <input name="id" value="{t.id}" type="hidden" /> <input name="title" value="{t.title}" /> <button>Save</button> </form> </td>
</tr>

I oczywiście będziemy musieli dodać akcję formularza dla naszego /list strona. Akcje mogą tylko wchodzić .server stron, więc dodamy a +page.server.js w naszym /list teczka. (Tak +page.server.js plik może współistnieć obok pliku +page.js plik.)

import { getTodo, updateTodo, wait } from "$lib/data/todoData"; export const actions = { async editTodo({ request, cookies }) { const formData = await request.formData(); const id = formData.get("id"); const newTitle = formData.get("title"); await wait(250); updateTodo(id, newTitle); cookies.set("todos-cache", +new Date(), { path: "/", httpOnly: false }); },
};

Przechwytujemy dane z formularza, wymuszamy opóźnienie, aktualizujemy listę rzeczy do zrobienia, a następnie, co najważniejsze, czyścimy plik cookie dotyczący pomijania pamięci podręcznej.

Dajmy temu szansę. Odśwież stronę, a następnie edytuj jedną z rzeczy do zrobienia. Po chwili powinieneś zobaczyć aktualizację wartości tabeli. Jeśli spojrzysz na kartę Sieć w DevToold, zobaczysz pobieranie do /todos punkt końcowy, który zwraca nowe dane. Prosty i działa domyślnie.

Zapisywanie danych
Buforowanie danych w SvelteKit

Natychmiastowe aktualizacje

Co zrobić, jeśli chcemy uniknąć tego pobierania, które ma miejsce po zaktualizowaniu naszego elementu do zrobienia, i zamiast tego zaktualizować zmodyfikowany element bezpośrednio na ekranie?

To nie jest tylko kwestia wydajności. Jeśli wyszukasz słowo „opublikuj”, a następnie usuniesz słowo „opublikuj” z dowolnej pozycji do zrobienia na liście, po wprowadzeniu zmian znikną one z listy, ponieważ nie będą już widoczne w wynikach wyszukiwania na tej stronie. Możesz ulepszyć UX za pomocą gustownej animacji dla ekscytujących zadań do wykonania, ale powiedzmy, że chcieliśmy nie ponownie uruchom funkcję ładowania tej strony, ale nadal wyczyść pamięć podręczną i zaktualizuj zmodyfikowane zadanie, aby użytkownik mógł zobaczyć edycję. SvelteKit to umożliwia — zobaczmy jak!

Najpierw wprowadźmy jedną małą zmianę w naszej ładowarce. Zamiast zwracać nasze rzeczy do zrobienia, zwróćmy zapisywalny magazyn zawierające nasze zadania.

return { todos: writable(todos),
};

Wcześniej uzyskiwaliśmy dostęp do naszych zadań na stronie data prop, którego nie posiadamy i którego nie możemy aktualizować. Ale Svelte pozwala nam zwrócić nasze dane we własnym sklepie (zakładając, że używamy uniwersalnego modułu ładującego, którym jesteśmy). Musimy tylko wprowadzić jeszcze jedną poprawkę do naszego /list strona.

Zamiast tego:

{#each todos as t}

…od tego czasu musimy to zrobić todos jest teraz sklepem.:

{#each $todos as t}

Teraz nasze dane ładują się jak poprzednio. Lecz odkąd todos jest zapisywalnym sklepem, możemy go aktualizować.

Najpierw nadajmy funkcję naszemu use:enhance atrybut:

<form use:enhance={executeSave} on:submit={runInvalidate} method="post" action="?/editTodo"
>

Zostanie to uruchomione przed przesłaniem. Napiszmy to dalej:

function executeSave({ data }) { const id = data.get("id"); const title = data.get("title"); return async () => { todos.update(list => list.map(todo => { if (todo.id == id) { return Object.assign({}, todo, { title }); } else { return todo; } }) ); };
}

Ta funkcja zapewnia data obiekt z naszymi danymi formularza. My powrót funkcja asynchroniczna, która zostanie uruchomiona po nasza edycja jest zakończona. Dokumenty wyjaśnić to wszystko, ale robiąc to, wyłączyliśmy domyślną obsługę formularzy SvelteKit, która spowodowałaby ponowne uruchomienie naszego modułu ładującego. To jest dokładnie to, czego chcemy! (Możemy łatwo przywrócić to domyślne zachowanie, jak wyjaśniają dokumenty).

Teraz dzwonimy update na nasze todos array, ponieważ jest to sklep. I to jest to. Po edycji zadania nasze zmiany pojawiają się natychmiast, a pamięć podręczna zostaje wyczyszczona (tak jak poprzednio, ponieważ ustawiliśmy nową wartość pliku cookie w naszym editTodo akcja formularza). Jeśli więc wyszukamy, a następnie wrócimy do tej strony, otrzymamy nowe dane z naszego programu ładującego, który poprawnie wykluczy wszelkie zaktualizowane elementy do zrobienia, które zostały zaktualizowane.

Kod do natychmiastowych aktualizacji jest dostępny na GitHubie.

Kopać głębiej

Możemy ustawić pliki cookie w dowolnej funkcji ładowania serwera (lub akcji serwera), nie tylko w układzie głównym. Tak więc, jeśli niektóre dane są używane tylko pod jednym układem, a nawet jedną stroną, możesz tam ustawić tę wartość pliku cookie. Co więcej, jeśli jesteś nie robiąc sztuczkę, którą właśnie pokazałem, ręcznie aktualizując dane na ekranie, i zamiast tego chcesz, aby twój program ładujący uruchomił się ponownie po mutacji, zawsze możesz ustawić nową wartość pliku cookie bezpośrednio w tej funkcji ładowania bez sprawdzania przeciwko isDataRequest. Zostanie ustawiony początkowo, a następnie za każdym razem, gdy uruchomisz akcję serwera, układ strony automatycznie unieważni i ponownie wywoła twój moduł ładujący, ponownie ustawiając łańcuch pominięcia pamięci podręcznej przed wywołaniem uniwersalnego modułu ładującego.

Pisanie funkcji przeładowania

Podsumujmy, budując ostatnią funkcję: przycisk przeładowania. Dajmy użytkownikom przycisk, który wyczyści pamięć podręczną, a następnie ponownie załaduje bieżące zapytanie.

Dodamy prostą akcję formularza:

async reloadTodos({ cookies }) { cookies.set('todos-cache', +new Date(), { path: '/', httpOnly: false });
},

W prawdziwym projekcie prawdopodobnie nie skopiowałbyś/wkleiłbyś tego samego kodu, aby ustawić ten sam plik cookie w ten sam sposób w wielu miejscach, ale w tym poście zoptymalizujemy pod kątem prostoty i czytelności.

Teraz utwórzmy formularz do opublikowania w nim:

<form method="POST" action="?/reloadTodos" use:enhance> <button>Reload todos</button>
</form>

To działa!

Interfejs użytkownika po przeładowaniu.
Buforowanie danych w SvelteKit

Moglibyśmy to zakończyć i przejść dalej, ale poprawmy trochę to rozwiązanie. W szczególności przekażmy informację zwrotną na stronie, aby poinformować użytkownika o trwającym przeładowaniu. Ponadto domyślnie działania SvelteKit są unieważniane wszystko. Każdy układ, strona itp. w hierarchii bieżącej strony ładowałby się ponownie. Mogą istnieć dane, które są ładowane raz w układzie głównym, których nie musimy unieważniać ani ponownie ładować.

Więc skupmy się trochę na rzeczy i przeładuj nasze zadania tylko wtedy, gdy wywołujemy tę funkcję.

Najpierw przekażmy funkcję do ulepszenia:

<form method="POST" action="?/reloadTodos" use:enhance={reloadTodos}>
import { enhance } from "$app/forms";
import { invalidate } from "$app/navigation"; let reloading = false;
const reloadTodos = () => { reloading = true; return async () => { invalidate("reload:todos").then(() => { reloading = false; }); };
};

Ustawiamy nowy reloading zmienna do true na początek tej akcji. A następnie, aby zastąpić domyślne zachowanie unieważniania wszystkiego, zwracamy an async funkcjonować. Ta funkcja zostanie uruchomiona, gdy akcja naszego serwera zostanie zakończona (która właśnie ustawi nowy plik cookie).

Bez tego async funkcja została zwrócona, SvelteKit unieważniłby wszystko. Ponieważ udostępniamy tę funkcję, niczego nie unieważni, więc to od nas zależy, co ma przeładować. Robimy to z invalidate funkcjonować. Nazywamy to wartością reload:todos. Ta funkcja zwraca obietnicę, która jest rozwiązywana po zakończeniu unieważniania, w którym to momencie ustalamy reloading z powrotem false.

Na koniec musimy zsynchronizować nasz moduł ładujący z tym nowym reload:todos wartość unieważnienia. Robimy to w naszej ładowarce z depends funkcjonować:

export async function load({ fetch, url, setHeaders, depends }) { depends('reload:todos'); // rest is the same

I to jest to. depends i invalidate to niezwykle przydatne funkcje. Fajne jest to invalidate nie przyjmuje tylko arbitralnych wartości, które dostarczamy, tak jak my to robiliśmy. Możemy również podać adres URL, który SvelteKit będzie śledzić, i unieważnić wszystkie programy ładujące zależne od tego adresu URL. W tym celu, jeśli zastanawiasz się, czy możemy pominąć połączenie do depends i unieważnić nasze /api/todos Endpoint całkowicie, możesz, ale musisz zapewnić dokładny URL, w tym search termin (i nasza wartość pamięci podręcznej). Możesz więc albo połączyć adres URL dla bieżącego wyszukiwania, albo dopasować nazwę ścieżki, tak jak poniżej:

invalidate(url => url.pathname == "/api/todos");

Osobiście znajduję rozwiązanie, które wykorzystuje depends bardziej wyraziste i proste. Ale zobacz dokumenty aby uzyskać więcej informacji, oczywiście, i zdecyduj sam.

Jeśli chcesz zobaczyć przycisk przeładowania w akcji, kod do niego jest w ten oddział repo.

Pożegnalne myśli

To był długi post, ale mam nadzieję, że nie przytłaczający. Zagłębiliśmy się w różne sposoby buforowania danych podczas korzystania z SvelteKit. Wiele z tego było po prostu kwestią użycia prymitywów platformy internetowej w celu dodania poprawnej pamięci podręcznej i wartości plików cookie, których znajomość będzie ci służyć ogólnie w tworzeniu stron internetowych, nie tylko w SvelteKit.

Co więcej, jest to coś, co absolutnie nie trzeba cały czas. Prawdopodobnie powinieneś sięgać po tego rodzaju zaawansowane funkcje tylko wtedy, gdy właściwie ich potrzebują. Jeśli Twój magazyn danych obsługuje dane szybko i wydajnie, a Ty nie masz do czynienia z żadnymi problemami ze skalowaniem, nie ma sensu zwiększać kodu aplikacji niepotrzebną złożonością, robiąc rzeczy, o których tutaj mówiliśmy.

Jak zawsze pisz jasny, czysty, prosty kod i optymalizuj go w razie potrzeby. Celem tego posta było dostarczenie Ci tych narzędzi optymalizacyjnych, kiedy naprawdę ich potrzebujesz. Mam nadzieję, że ci się podobało!

Znak czasu:

Więcej z Sztuczki CSS