diff --git a/e2e/blog.spec.ts b/e2e/blog.spec.ts index b9fdb75..ee46c97 100644 --- a/e2e/blog.spec.ts +++ b/e2e/blog.spec.ts @@ -4,6 +4,9 @@ import { visiblePosts } from "../src/content/blog/.generated/posts"; import { blogArticlePath } from "../src/content/blog/model"; import { blogLabels } from "../src/content/blog/labels"; const first = visiblePosts[0]; +const architectureSlug = + "building-mobile-and-web-clients-around-one-appwrite-backend"; +const introSlug = "why-i-built-car-brain"; const articlePaths = visiblePosts.flatMap((p) => (["en", "pl"] as const).map((locale) => ({ path: blogArticlePath(p.slug, locale), @@ -76,11 +79,11 @@ for (const skin of ["standard", "terminal"] as const) const paths = [ "/blog/", "/blog/pl/", - ...(first - ? (["en", "pl"] as const).map((locale) => - blogArticlePath(first.slug, locale) - ) - : []), + ...visiblePosts.flatMap((post) => + (["en", "pl"] as const).map((locale) => + blogArticlePath(post.slug, locale) + ) + ), ]; const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); @@ -94,7 +97,20 @@ for (const skin of ["standard", "terminal"] as const) expect((await new AxeBuilder({ page }).analyze()).violations).toEqual( [] ); - if (path.includes(first?.slug ?? "__none__")) { + if (path.includes(introSlug)) { + await expect(page.locator(".product-screens img")).toHaveCount(3); + await expect(page.locator(".product-screens-row")).toHaveCSS( + "overflow-x", + "auto" + ); + const stack = page.locator(".mobile-stack"); + await expect(stack.getByRole("img")).toBeVisible(); + await expect(stack.getByRole("region")).toHaveCSS( + "overflow-x", + "auto" + ); + } + if (path.includes(architectureSlug)) { // Diagram geometry must survive missing component CSS. for (const selector of [".client-architecture", ".auth-sequence"]) { const diagram = page.locator(selector); @@ -144,8 +160,14 @@ test("blog discovery and keyboard navigation reach the index and both editions", })), ]; for (const { query, path } of destinations) { - await page.getByRole("button", { name: "Open command palette" }).click(); - await page.getByRole("combobox", { name: "Search commands" }).fill(query); + await page + .getByRole("button", { + name: /Open command palette|Otwórz paletę poleceń/, + }) + .click(); + await page + .getByRole("combobox", { name: /Search commands|Szukaj poleceń/ }) + .fill(query); await page.keyboard.press("Enter"); await expect(page).toHaveURL(new RegExp(path + "$")); } @@ -173,7 +195,7 @@ for (const [skin, theme, width] of [ await page.setViewportSize({ width, height: 1000 }); await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto( - `${blogArticlePath(first.slug, width < 800 ? "pl" : "en")}?skin=${skin}&theme=${theme}` + `${blogArticlePath(architectureSlug, width < 800 ? "pl" : "en")}?skin=${skin}&theme=${theme}` ); await page.screenshot({ path: `test-results/review-${skin}-${theme}-${width}-header.png`, @@ -190,7 +212,7 @@ test("article diagrams keep their geometry without external stylesheets", async }) => { test.skip(!first, "No article in this build"); await page.setViewportSize({ width: 390, height: 900 }); - await page.goto(blogArticlePath(first.slug, "pl")); + await page.goto(blogArticlePath(architectureSlug, "pl")); await page.evaluate(() => { for (const sheet of Array.from(document.styleSheets)) sheet.disabled = true; }); diff --git a/public/blog/why-i-built-car-brain/refuels.jpg b/public/blog/why-i-built-car-brain/refuels.jpg new file mode 100644 index 0000000..ad01ad4 Binary files /dev/null and b/public/blog/why-i-built-car-brain/refuels.jpg differ diff --git a/public/blog/why-i-built-car-brain/services.jpg b/public/blog/why-i-built-car-brain/services.jpg new file mode 100644 index 0000000..35d3d13 Binary files /dev/null and b/public/blog/why-i-built-car-brain/services.jpg differ diff --git a/public/blog/why-i-built-car-brain/settings.jpg b/public/blog/why-i-built-car-brain/settings.jpg new file mode 100644 index 0000000..169dcd6 Binary files /dev/null and b/public/blog/why-i-built-car-brain/settings.jpg differ diff --git a/src/components/Blog/Blog.test.tsx b/src/components/Blog/Blog.test.tsx index 3e9cb0e..8390706 100644 --- a/src/components/Blog/Blog.test.tsx +++ b/src/components/Blog/Blog.test.tsx @@ -2,6 +2,8 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { expect, test } from "vitest"; import { ClientArchitecture } from "./ClientArchitecture"; +import { MobileStack } from "./MobileStack"; +import { ProductScreens } from "./ProductScreens"; import { BlogList, BlogLanguages, LatestWriting } from "./BlogList"; import { BlogIndex, blogIndexMetadata } from "./BlogIndex"; import { visiblePosts } from "@/content/blog/.generated/posts"; @@ -17,6 +19,15 @@ test("language switch keeps the same article", () => { expect.stringMatching(/^\/blog\/en\/example\/?$/) ); }); +test("the title link does not wrap the language switch", () => { + const post = visiblePosts[0]; + const { container } = render(); + const item = container.querySelector(".blog-list-item"); + expect(item).toHaveClass("card"); + const title = screen.getByRole("link", { name: post.editions.en.title }); + expect(title.closest(".blog-languages")).toBeNull(); + expect(item?.querySelector(".blog-languages a")).toBeTruthy(); +}); test("empty article list has no placeholder cards", () => { const { container } = render(); expect(container.querySelectorAll("li")).toHaveLength(0); @@ -54,6 +65,18 @@ test("article metadata preserves independent canonicals and prevents draft index expect(m.robots).toMatchObject({ index: false, follow: false }); }); +test("mobile stack and sample screens stay in the article", () => { + const { container } = render( + <> + + + > + ); + expect( + screen.getByRole("img", { name: /Car Brain mobile stack/ }) + ).toBeVisible(); + expect(container.querySelectorAll(".product-screens img")).toHaveLength(3); +}); for (const locale of ["en", "pl"] as const) { test(`architecture exposes its boundaries and planned data path (${locale})`, () => { const { container } = render(); diff --git a/src/components/Blog/BlogList.tsx b/src/components/Blog/BlogList.tsx index 8f8a0de..5003f6c 100644 --- a/src/components/Blog/BlogList.tsx +++ b/src/components/Blog/BlogList.tsx @@ -12,15 +12,18 @@ import { SectionBand } from "@/components/SectionBand/SectionBand"; export function BlogLanguages({ locale, slug, + label, }: { locale: BlogLocale; slug?: string; + label?: string; }) { return ( {(["en", "pl"] as const).map((lang) => ( @@ -51,7 +54,7 @@ export function BlogList({ return ( {posts.map((post) => ( - + {post.publishedAt ? ( @@ -64,14 +67,21 @@ export function BlogList({ {post.readingMinutes[locale]} {t.minute} - + {post.editions[locale].title} {post.editions[locale].description} - + ))} diff --git a/src/components/Blog/MobileStack.tsx b/src/components/Blog/MobileStack.tsx new file mode 100644 index 0000000..15fc873 --- /dev/null +++ b/src/components/Blog/MobileStack.tsx @@ -0,0 +1,140 @@ +import { useId } from "react"; +import type { BlogLocale } from "@/content/blog/types"; + +const ink = "var(--text, #1c282c)"; +const muted = "var(--text-secondary, #52646a)"; +const accent = "var(--accent, #0f6b75)"; +const surface = "var(--surface, #ffffff)"; +const soft = "var(--accent-soft, #e2efee)"; +const border = "var(--separator, #d4deda)"; + +export function MobileStack({ locale = "en" }: { locale?: BlogLocale }) { + const pl = locale === "pl"; + const id = useId(); + const title = pl ? "Stos mobilny Car Brain" : "Car Brain mobile stack"; + const description = pl + ? "Aplikacja Expo na iOS i Androida rozmawia z Appwrite: logowanie, tabele, pliki i funkcje. W oryginalnej aplikacji nie ma osobnego serwera API." + : "The Expo app on iOS and Android talks to Appwrite for auth, tables, files and functions. There is no separate API server in the original app."; + return ( + + + + {title} + {description} + + + + + + + + + {pl ? "Aplikacja Expo" : "Expo app"} + + + iOS {pl ? "i" : "and"} Android + + + + SDK + + + + Appwrite + + + {pl + ? "Logowanie · tabele · pliki · funkcje" + : "Auth · tables · files · functions"} + + + {pl ? "Bez mojego serwera API" : "No API server of my own"} + + + + + {pl + ? "Telefon rozmawia z Appwrite bezpośrednio. Na małym ekranie przewiń schemat w poziomie." + : "The phone talks to Appwrite directly. On a narrow screen, scroll the diagram horizontally."} + + + ); +} diff --git a/src/components/Blog/ProductScreens.tsx b/src/components/Blog/ProductScreens.tsx new file mode 100644 index 0000000..99aaa0a --- /dev/null +++ b/src/components/Blog/ProductScreens.tsx @@ -0,0 +1,77 @@ +import type { BlogLocale } from "@/content/blog/types"; + +const shots = { + en: [ + { + src: "/blog/why-i-built-car-brain/refuels.jpg", + alt: "Refuel records in Car Brain", + caption: "Refuels, with search and a running cost summary.", + }, + { + src: "/blog/why-i-built-car-brain/services.jpg", + alt: "Service records in Car Brain", + caption: + "Service history, split into what is done and what is coming up.", + }, + { + src: "/blog/why-i-built-car-brain/settings.jpg", + alt: "Theme settings in Car Brain, including night driving", + caption: "Themes, including the red night-driving mode.", + }, + ], + pl: [ + { + src: "/blog/why-i-built-car-brain/refuels.jpg", + alt: "Tankowania w Car Brain", + caption: "Lista tankowań z wyszukiwaniem i podsumowaniem kosztów.", + }, + { + src: "/blog/why-i-built-car-brain/services.jpg", + alt: "Serwis w Car Brain", + caption: + "Historia serwisu: to, co już zrobione, i to, co dopiero nadchodzi.", + }, + { + src: "/blog/why-i-built-car-brain/settings.jpg", + alt: "Ustawienia motywu w Car Brain, w tym jazda nocą", + caption: "Motywy, w tym czerwony tryb jazdy nocą.", + }, + ], +} as const; + +export function ProductScreens({ locale = "en" }: { locale?: BlogLocale }) { + const pl = locale === "pl"; + return ( + + + {shots[locale].map((shot) => ( + + + {/* A plain img: the site is a static export with no image optimiser. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + + + {shot.caption} + + ))} + + + ); +} diff --git a/src/components/Blog/blog.css b/src/components/Blog/blog.css index d826b97..f2dee77 100644 --- a/src/components/Blog/blog.css +++ b/src/components/Blog/blog.css @@ -57,30 +57,92 @@ border-color: var(--accent); } .blog-list { + display: flex; + flex-direction: column; + gap: 16px; margin: 0; padding: 0; list-style: none; } .blog-list-item { - padding: var(--space-6) 0; - border-top: 1px solid var(--separator); + position: relative; + display: flex; + flex-direction: column; + gap: 8px; max-width: 80ch; + padding: 20px; + transition: border-color 150ms ease; +} +.blog-list-item:hover, +.blog-list-item:focus-within { + border-color: var(--accent); } .blog-list-item :is(h2, h3) { font-family: var(--font-display); font-size: clamp(24px, 3vw, 32px); line-height: 1.25; max-width: 38ch; - margin: 8px 0 12px; + margin: 0; } -.blog-list-item :is(h2, h3) a { +.blog-list-link, +.blog-list-link:hover, +.blog-list-item:hover .blog-list-link, +.blog-list-item:focus-within .blog-list-link { color: var(--text); text-decoration: none; } -.blog-list-item :is(h2, h3) a:hover { +.blog-list-link::after { + content: ""; + position: absolute; + inset: 0; +} +.blog-list-item:hover .blog-list-link, +.blog-list-item:focus-within .blog-list-link { color: var(--accent); - text-decoration: underline; - text-underline-offset: 4px; + text-decoration: none; +} +.blog-list-item .blog-languages { + position: relative; + z-index: 1; + margin-top: 8px; +} +.blog-list-item .blog-meta, +.blog-list-item .blog-description { + margin: 0; +} +.product-screens { + margin: 32px 0; + min-width: 0; +} +.product-screens-row { + display: flex; + gap: 12px; + overflow-x: auto; + min-width: 0; + padding-bottom: 4px; +} +.product-screens-shot { + flex: 1 1 180px; + min-width: 160px; + max-width: 220px; +} +.product-screens .phone-tile { + width: 100%; +} +.product-screens .phone-shot { + width: calc(100% - 24px); + height: auto; +} +.product-screens-shot p { + margin: 8px 0 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.45; +} +@media (prefers-reduced-motion: reduce) { + .blog-list-item { + transition: none; + } } .blog-prose { width: 100%; diff --git a/src/content/blog/articles/building-mobile-and-web-clients-around-one-appwrite-backend/en.mdx b/src/content/blog/articles/building-mobile-and-web-clients-around-one-appwrite-backend/en.mdx index b7f1341..d699b0c 100644 --- a/src/content/blog/articles/building-mobile-and-web-clients-around-one-appwrite-backend/en.mdx +++ b/src/content/blog/articles/building-mobile-and-web-clients-around-one-appwrite-backend/en.mdx @@ -1,5 +1,7 @@ import { AuthSequence } from "@/components/Blog/ClientArchitecture"; +The introduction is [Why I built Car Brain, and why this stack](/blog/en/why-i-built-car-brain/). + **One backend removes a second source of truth. It does not remove distributed state.** That is the architectural tension behind adding a web client to Car Brain, my React Native vehicle-management application. The target is three platforms—iOS, Android and Web—with two client implementations sharing Appwrite. The interesting decisions concern boundaries: where sessions live, who owns freshness, and how much logic to share before “reuse” becomes a second project with its own roadmap. diff --git a/src/content/blog/articles/building-mobile-and-web-clients-around-one-appwrite-backend/pl.mdx b/src/content/blog/articles/building-mobile-and-web-clients-around-one-appwrite-backend/pl.mdx index e0e0ccb..e75625a 100644 --- a/src/content/blog/articles/building-mobile-and-web-clients-around-one-appwrite-backend/pl.mdx +++ b/src/content/blog/articles/building-mobile-and-web-clients-around-one-appwrite-backend/pl.mdx @@ -1,5 +1,7 @@ import { AuthSequence } from "@/components/Blog/ClientArchitecture"; +Wprowadzenie jest tutaj: [Dlaczego zbudowałem Car Brain i dlaczego taki stos](/blog/pl/why-i-built-car-brain/). + **Jeden backend usuwa drugie źródło prawdy. Nie usuwa rozproszonego stanu.** Wokół tego napięcia projektuję rozszerzenie Car Brain, mojej aplikacji React Native do zarządzania informacjami o samochodzie, o klienta webowego. Docelowo trzy platformy — iOS, Android i Web — korzystają z dwóch implementacji klienta i jednego Appwrite. Najciekawsze decyzje dotyczą granic: gdzie żyje sesja, kto odpowiada za świeżość danych i ile logiki współdzielić, zanim „reuse” stanie się drugim projektem z własną roadmapą. diff --git a/src/content/blog/articles/why-i-built-car-brain/en.mdx b/src/content/blog/articles/why-i-built-car-brain/en.mdx new file mode 100644 index 0000000..c99d56f --- /dev/null +++ b/src/content/blog/articles/why-i-built-car-brain/en.mdx @@ -0,0 +1,56 @@ +import { MobileStack } from "@/components/Blog/MobileStack"; +import { ProductScreens } from "@/components/Blog/ProductScreens"; + +**Car Brain is the app I built so a car's paperwork would live in one place.** + +Fuel, service, costs and receipts were scattered. A spreadsheet is a poor filing cabinet for photos. I also wanted to learn mobile by shipping a whole product, not by finishing a tutorial. Car Brain is that product: React Native for iOS and Android, built in 2024–2025, which I am bringing back now. + +## What I chose, and the bill + +### React Native, through Expo + +I already knew React and TypeScript. A second language was a worse deal than one codebase for two phones. + +**Gain:** iOS and Android from the same project, in tools I use every day. **Cost:** a dev client, native projects, and the reminder that the two platforms still disagree. **I would revisit it** if a feature needed a native interface I could only reach by fighting the abstraction. + +### Appwrite, instead of a server I would have to babysit + +I wanted sign-in, structured records, file storage and a small place for server code. Appwrite looked simpler than writing that myself, and it is open source. Firebase and Supabase were the other names in my head. I never wrote the comparison down, so this is a preference, not a leaderboard. + + + +**Gain:** email, Google and Apple sign-in, tables, a bucket for photos and receipts, and functions. The mobile SDK talks to Appwrite directly. **Cost:** product rules live on the device or inside a function, and the schema lives in Appwrite's console as well as in the repo. Those two copies can drift. **I would revisit it** if the domain logic outgrew functions and deserved a backend I actually operate. + +For a mobile product, and if you already write React, I would pick this pair again. Expo is how I would reach both stores without a second language. Appwrite is the piece I would recommend most plainly: sign-in, a database, files, and a function for anything that must not ship inside the app, without a server to run. I would not call it better than Firebase or Supabase in general. I would start with it again for an app like this. + +One scar from that setup is a separate story. An agent put a third-party API key in an `EXPO_PUBLIC_` variable, which ships inside the app binary. I moved the call the same day. The warning stands on its own; this piece is about the product. + +### TanStack Query for server state + +Data that comes from Appwrite goes through TanStack Query. The session, language and theme stay in React Context. Preferences that belong to the device stay in AsyncStorage. I did not add Redux or Zustand. The first plan had been Context and AsyncStorage alone, which meant writing caching and invalidation by hand. + +**Gain:** a cache with an explicit way to mark it stale and refetch. **Cost:** two homes for state, and dashboard numbers computed on the phone from cached rows instead of stored a second time. **I would revisit a global store** only if those homes started disagreeing. + +### Maestro, after Detox stayed a name in a plan + +The early notes named Detox or Appium for end-to-end tests. Neither was added. The repository runs Maestro flows against a development build, aimed at `testID`s. + +**Gain:** readable YAML and no extra native test harness inside the app. **Cost:** a local CLI, a booted simulator, and flows that are not what CI runs. The decision record does not say why Maestro won, so I will not invent a reason after the fact. **I would revisit it** when those flows need to run unattended. + +## What is actually in the app + +The mobile source covers several vehicles on one account, photos, VIN auto-fill, insurance and inspection dates, refuels and service records with receipt photos, reminders, and fuel and cost analytics. The interface is English and Polish, with themes that include a night-driving mode. + +The public page is [car-brain.com/en](https://car-brain.com/en). That is the landing page. It is not a store listing. + + + +The numbers and the car on these screens are sample data. + +Not in this product: subscriptions, a shared family account, push notifications, calendar sync, or OCR on receipts. + +**Development snapshot — 6 October 2026:** the feature list comes from the repository and its decision records. These screens are existing design captures. I did not re-run the app while writing this. Car Brain is coming back to the stores. This article does not claim the listing is live. + +## What happened when the web client showed up + +A second screen did not turn Car Brain into one app. It added a Next.js client beside React Native, on the same Appwrite project, with the browser session held on the server. That boundary is the next architecture piece: [Building mobile and web clients around one Appwrite backend](/blog/en/building-mobile-and-web-clients-around-one-appwrite-backend/). diff --git a/src/content/blog/articles/why-i-built-car-brain/pl.mdx b/src/content/blog/articles/why-i-built-car-brain/pl.mdx new file mode 100644 index 0000000..7563706 --- /dev/null +++ b/src/content/blog/articles/why-i-built-car-brain/pl.mdx @@ -0,0 +1,56 @@ +import { MobileStack } from "@/components/Blog/MobileStack"; +import { ProductScreens } from "@/components/Blog/ProductScreens"; + +**Car Brain to aplikacja, którą zbudowałem po to, żeby papiery samochodu były w jednym miejscu.** + +Paliwo, serwis, koszty i paragony miałem porozrzucane. Arkusz kalkulacyjny słabo nadaje się do trzymania zdjęć. Chciałem też nauczyć się aplikacji mobilnych na prawdziwym produkcie, a nie na kolejnym tutorialu. Car Brain jest właśnie tą aplikacją: React Native na iOS i Androida. Zbudowałem ją w latach 2024–2025 i teraz do niej wracam. + +## Co wybrałem i ile mnie to kosztuje + +### React Native przez Expo + +Reacta i TypeScript już znałem. Uczenie się drugiego języka byłoby gorszym pomysłem niż jedna baza kodu na dwa telefony. + +**Zysk:** iOS i Android z jednego projektu, w narzędziach, których używam na co dzień. **Koszt:** klient deweloperski, projekty natywne i przypomnienie, że obie platformy nadal mają własne zdanie. **Zmieniłbym tę decyzję,** gdyby jakaś funkcja wymagała natywnego ekranu, a Expo tylko stało na drodze. + +### Appwrite zamiast serwera, który musiałbym sam utrzymywać + +Chciałem logowanie, dane o aucie, pliki i trochę kodu po stronie serwera. Appwrite wyglądał prościej niż składanie tego od zera i jest open source. Firebase i Supabase też brałem pod uwagę. Porównania nie zapisałem, więc to moja preferencja, nie ranking. + + + +**Zysk:** logowanie e-mailem, kontem Google i Apple, tabele, miejsce na zdjęcia i paragony oraz funkcje. Mobilne SDK rozmawia z Appwrite bezpośrednio. **Koszt:** reguły produktu siedzą albo w telefonie, albo w funkcji, a schemat jest i w konsoli Appwrite, i w repozytorium. Te dwie kopie potrafią się rozjechać. **Zmieniłbym tę decyzję,** gdyby logika produktu przestała mieścić się w funkcjach i potrzebowała backendu, który naprawdę utrzymuję. + +Skoro i tak piszę w Reakcie, do takiej aplikacji wziąłbym tę parę jeszcze raz. Expo jest sposobem na oba sklepy bez drugiego języka. Appwrite polecam wprost: logowanie, baza, pliki i funkcja na wszystko, czego nie wolno wkładać do aplikacji. Nie trzeba do tego stawiać własnego serwera. Nie mówię, że jest lepszy od Firebase albo Supabase w każdej sytuacji. Przy takiej aplikacji zacząłbym od niego znowu. + +Została z tego też jedna wpadka. O niej jest osobny tekst. Agent zapisał klucz do API auto.dev w zmiennej `EXPO_PUBLIC_`. Taki prefiks wkłada wartość do pliku aplikacji. Wywołanie przeniosłem tego samego dnia. Tutaj piszę o produkcie. + +### TanStack Query dla danych z serwera + +Dane z Appwrite idą przez TanStack Query. Sesja, język i motyw zostają w React Context. Ustawienia telefonu zostają w AsyncStorage. Nie dodałem Reduxa ani Zustand. Pierwszy plan zakładał sam Context i AsyncStorage, czyli cache i odświeżanie pisane ręcznie. + +**Zysk:** cache, w którym widać, kiedy dane są już stare, i jest sposób, żeby pobrać je ponownie. **Koszt:** stan jest w dwóch miejscach, a liczby na ekranie głównym liczę w telefonie z wierszy w cache, zamiast zapisywać je drugi raz. **Wspólny store wziąłbym** dopiero wtedy, gdy te dwa miejsca zaczęłyby sobie przeczyć. + +### Maestro, kiedy Detox został tylko nazwą w planie + +Wczesne notatki do testów E2E wymieniały Detox albo Appium. Żadnego z nich nie dodałem. Repozytorium odpala scenariusze Maestro na buildzie deweloperskim i szuka elementów po `testID`. + +**Zysk:** czytelny YAML, bez dokładania natywnej biblioteki testowej do aplikacji. **Koszt:** lokalne CLI, włączony symulator i scenariusze, których CI nie odpala. W zapisie decyzji nie ma, dlaczego wygrało Maestro, więc nie dopiszę powodu po fakcie. **Zmieniłbym ten wybór,** gdyby te testy miały lecieć same, bez ręcznego odpalania. + +## Co naprawdę jest w aplikacji + +W aplikacji jest kilka aut na jednym koncie, zdjęcia, uzupełnianie VIN, daty ubezpieczenia i przeglądu, tankowania i wpisy serwisowe ze zdjęciami paragonów, przypomnienia oraz zestawienia spalania i kosztów. Interfejs jest po angielsku i po polsku, a wśród motywów jest tryb jazdy nocą. + +Publiczna strona po polsku jest na [car-brain.com/pl](https://car-brain.com/pl). To landing page, nie ogłoszenie, że aplikacja jest w sklepie. + + + +Liczby i auto na tych ekranach to dane przykładowe. + +Nie ma tu subskrypcji, wspólnego konta rodzinnego, powiadomień push, synchronizacji z kalendarzem ani OCR paragonów. + +**Stan na 6 października 2026:** lista funkcji pochodzi z repozytorium i zapisanych decyzji. Te ekrany to zdjęcia zrobione wcześniej do portfolio. Dane na nich są przykładowe. Przy pisaniu nie uruchamiałem aplikacji. Car Brain wraca do sklepów. Nie piszę, że jest już w sklepie. + +## Co się stało, gdy doszedł klient webowy + +Sam ekran w przeglądarce nie zrobił z Car Brain jednej aplikacji. Doszedł klient Next.js obok React Native, na tym samym projekcie Appwrite, a sesja przeglądarki zostaje na serwerze. O tej granicy jest kolejny tekst: [Aplikacja mobilna i webowa ze wspólnym backendem Appwrite](/blog/pl/building-mobile-and-web-clients-around-one-appwrite-backend/). diff --git a/src/content/blog/catalog.ts b/src/content/blog/catalog.ts index 4c5f44e..ebeb07b 100644 --- a/src/content/blog/catalog.ts +++ b/src/content/blog/catalog.ts @@ -2,6 +2,27 @@ import type { BlogPost } from "./types"; // Approved bilingual editions. Dates describe the intended release; update if deployment moves. export const blogPosts: BlogPost[] = [ + { + slug: "why-i-built-car-brain", + published: true, + editions: { + en: { + title: "Why I built Car Brain, and why this stack", + description: + "Why Car Brain exists, and why I would pick React Native, Expo and Appwrite again for a mobile app like this.", + topics: ["Car Brain", "Expo", "Appwrite"], + body: "why-i-built-car-brain/en.mdx", + }, + pl: { + title: "Dlaczego zbudowałem Car Brain i dlaczego taki stos", + description: + "Dlaczego powstał Car Brain i dlaczego przy takiej aplikacji mobilnej wybrałbym znowu React Native, Expo i Appwrite.", + topics: ["Car Brain", "Expo", "Appwrite"], + body: "why-i-built-car-brain/pl.mdx", + }, + }, + publishedAt: "2026-10-06", + }, { slug: "building-mobile-and-web-clients-around-one-appwrite-backend", published: true, diff --git a/src/design/skin-terminal.css b/src/design/skin-terminal.css index ea277ca..6c5c7d0 100644 --- a/src/design/skin-terminal.css +++ b/src/design/skin-terminal.css @@ -1641,11 +1641,26 @@ html.skin-terminal.dark-theme .hero-portrait { font-family: var(--font-mono); letter-spacing: -0.035em; } +:where(html.skin-terminal) .blog-list-item { + transition: + border-color 200ms ease, + box-shadow 200ms ease; +} +:where(html.skin-terminal) .blog-list-item:hover, +:where(html.skin-terminal) .blog-list-item:focus-within { + border-color: var(--accent); + box-shadow: var(--glow); +} :where(html.skin-terminal) .blog-languages a, :where(html.skin-terminal) .blog-notice, :where(html.skin-terminal) .prose pre { border-radius: 2px; } +@media (prefers-reduced-motion: reduce) { + :where(html.skin-terminal) .blog-list-item { + transition: none; + } +} :where(html.skin-terminal) .blog-notice { background: var(--surface); border: 1px solid var(--accent); diff --git a/src/i18n/routes.ts b/src/i18n/routes.ts index 799cd41..bfa6778 100644 --- a/src/i18n/routes.ts +++ b/src/i18n/routes.ts @@ -71,6 +71,12 @@ export const headingPairs: Record> = { "Tests and delivery": "Testy i wdrożenia", Performance: "Wydajność", }, + "article/why-i-built-car-brain": { + "What I chose, and the bill": "Co wybrałem i ile mnie to kosztuje", + "What is actually in the app": "Co naprawdę jest w aplikacji", + "What happened when the web client showed up": + "Co się stało, gdy doszedł klient webowy", + }, "article/building-mobile-and-web-clients-around-one-appwrite-backend": { "The system boundary: shared services, separate clients": "Granica systemu: wspólne usługi, osobne klienty",
{post.publishedAt ? ( @@ -64,14 +67,21 @@ export function BlogList({ {post.readingMinutes[locale]} {t.minute}
{post.editions[locale].description}
{shot.caption}