Backend architecture

Inside Ganje: a React new-tab dashboard, Django sync and the road to the Chrome Web Store

Ganje turns the new tab in Chrome and Firefox into a calm desk over your wallpaper. The widget grid, twelve looks, workspaces, etag-based sync with Django, freemium plans, and what the Chrome Web Store asked for.

Ganje, React, Chrome and Firefox

Ganje (گنجه) is an extension that replaces the new tab page in Chrome and Firefox with a dashboard you arrange yourself: a clock, weather, a Persian calendar, to-dos, notes, currency and crypto prices, and shortcuts. Here is how it is built, and what getting it into the Chrome Web Store asked for.

The idea: a calm desk over your wallpaper

We open a new tab dozens of times a day and look at it for a few seconds, so it shouldn't fight for attention. The first design rule is that the wallpaper is the canvas, and everything on it is translucent glass rather than an opaque panel. A handful of tokens in index.css, such as --glass-bg and --glass-border, have light and dark values, and every modal, menu and widget uses them. Each surface carries its own tint, so text stays legible whatever the wallpaper.

The stack and the grid

It is React 18 and TypeScript, bundled by Vite, styled with Tailwind, with state in zustand. The Persian calendar is built on date-fns-jalali, and each month's holidays come from Ganje's server. Persian is the default language, and every surface mirrors with dir.

Widgets sit on react-grid-layout. Each is a WidgetItem: an id, x, y, w, h, a type and a free-form settings object; there are thirteen types, from CLOCK to PORTFOLIO. Positions are always stored on a 24-column base grid and scaled only for display on narrower screens. Writes happen only in onDragStop and onResizeStop, in edit mode; onLayoutChange only logs, because the library also fires it on mount and on breakpoint changes, and writing there used to scramble saved positions.

Dragging had to feel precise: a widget you drag over moves aside only as far as needed, snaps back the moment you pass it, and the gap you leave is never auto-filled. The library's own pushing is cumulative, so on every drag event I rebuild the layout from a snapshot taken when the drag began. That only worked with these props:

<ResponsiveGridLayout
    width={gridWidth}
    layouts={layouts}
    cols={COLS}
    rowHeight={80}
    isDraggable={isEditMode}
    compactType={null}
    preventCollision={false}
    allowOverlap={true}
>

Without allowOverlap, the library overwrote my computed positions in the same event, and nothing moved until you let go.

Twelve looks

A look in Ganje is a full skin, not a palette. classic and apple are standalone shells; the other ten, from Poster and Terminal to Kashi, Notebook, Retro and Starship, are built on a shared layer called viewkit, each with its own folder and stylesheet. Terminal has a real command line; Kashi shows a verse of Hafez each day. The chosen look lives in settings (uiVariant), apart from workspaces and widgets.

Workspaces and local storage

Each workspace has its own name, icon, background and widgets. Ganje works fully without an account: the zustand stores persist to localStorage. Two real bugs came up. A large uploaded wallpaper could fill the quota and throw QuotaExceededError in the middle of an action; a guarded storage adapter now catches it. And an old new-tab page would silently overwrite changes made in other tabs; listening for the storage event and rehydrating the store fixed it.

Sync with Django

The backend is a Django REST Framework project that serves the site, the payment page and the extension's API. The Workspace model keeps widgets in a JSONField, since every widget shapes its settings differently. At first, PUT /workspaces replaced the whole set, and a device holding stale data could wipe another device's newer work. Now the set has an etag (a SHA-256 of the key-sorted JSON), and every PUT sends the etag of the last version the device saw:

with transaction.atomic():
    lock_workspaces_of(request.user)
    current, current_etag = workspaces_with_etag(
        get_workspaces_for_user(request.user)
    )
    if base != current_etag:
        return Response(
            {"error": "workspaces_conflict", "code": "WORKSPACES_CONFLICT",
             "workspaces": current, "etag": current_etag},
            status=status.HTTP_409_CONFLICT,
        )

On the client, syncStore marks local changes as pending and sends them after a short debounce; on a 409, a modal lets the user keep their version or take the server's. The comparison is semantic, because PostgreSQL's jsonb reorders keys. Authentication is an Authorization: Bearer header only; I dropped the cookie because CORS accepted any extension origin, so another extension could have ridden it. A personal GitHub token is stripped before every PUT, and widget data is fetched through Ganje's server, so the extension ships no API keys.

Freemium plans

The free plan allows one workspace and ten links of your own (built-in links don't count) and shows one small sponsored message with no tracking code. The monthly through yearly plans differ only in length and price: no caps, no sponsored message, and the premium widgets (habits, portfolio and GitHub) unlocked. Limits are enforced on the server and only stop growth: a save past a cap gets 403 PLAN_LIMIT and nothing is deleted. When a plan ends, premium widgets are locked, not removed. Payment happens on Ganje's website through Zibal or ZarinPal, and a plan turns on only after the gateway confirms.

Manifest V3, permissions and the build

manifest.mjs generates the manifest for each browser at build time:

chrome_url_overrides: { newtab: 'index.html' },
permissions: ['search'],
host_permissions: [...(origin ? [`${origin}/*`] : []), ...DIRECT_HOSTS],

The only permission is search: Chrome Web Store policy requires a new tab with a search box to respect the user's default engine, so queries go to chrome.search.query(). host_permissions is just Ganje's API origin plus two addresses for the GitHub widget. The Chrome build sets minimum_chrome_version to 116; the Firefox build gets a gecko block with a minimum of 140 and a declaration of the data that leaves the browser, where account data is optional and requested only at sign-in.

npm run package runs tsc --noEmit, then writes three zips: Chrome, Firefox, and the source code for AMO's reviewers. Both packages share one bundle; only the manifest differs. Without the API URL or the Firefox id in .env.production, no store package gets built. Nothing is loaded from the network, and the fonts are bundled.

The Chrome Web Store

Most of the publishing work wasn't code; it lives in a store/ folder:

  • Text: Persian and English descriptions, the Productivity category, and reviewer notes with two test accounts, one free and one paid.
  • Images: seven 1280×800 screenshots across looks and languages (Chrome takes up to five) and a 440×280 promo tile.
  • The Privacy tab: a single-purpose statement, a reason for every permission, "no" to remote code, and only four data categories: personal, financial, authentication and location.
  • The privacy policy: the site's own /fa/privacy/ page, with an English version for reviewers, covering what stays on the device, what each widget sends, and the absence of analytics.

One hurdle had nothing to do with code: according to Google's documentation, Iran is not among the countries the Chrome Web Store accepts developer accounts from. The proper route is a publisher in a supported country who registers in their own name and adds me as a publisher member; signing up through a VPN or with false details can get the account closed. After approval, the extension's id gets pinned in CORS_ALLOWED_ORIGINS and GANJE_ALLOW_ANY_EXTENSION is turned off.

What I took away

  • A sync that replaces the whole set will eventually delete someone's work unless it carries an etag.
  • Enforce plan limits by refusing, never by deleting.
  • Every permission needs a one-line reason; asking for more than you need is a common cause of rejection.