All projects

Markly

Save images, videos, and links, sort them into categories. A social bookmarking app built on ASP.NET Core with vanilla JS on the front end.

  • ASP.NET Core
  • Bootstrap
Markly — screenshot
Markly

What it is

Think Pinterest with votes and comments. You save plain text, an image, or a YouTube or Vimeo link, drop it in a public or private category, and it shows up in an infinite masonry feed with full-text search, trending tags, and profile pages.

There’s some extra stuff around the edges too. The app suggests tags and categories from your title and description, there is a share modal with a branded QR code, a bookmarklet for saving in one click, and an admin panel.

Why I built it

I wanted to see how far the classic server-rendered .NET stack could go without reaching for a SPA framework, so I gave myself the full checklist: masonry layout, infinite scroll, and voting and commenting that update in place, all without a full page reload anywhere.

How it works

It’s ASP.NET Core MVC with Razor views, EF Core over Postgres, and Identity for auth, with controllers sitting on a thin service layer. The front end is Bootstrap plus small vanilla JS modules. I wrote the masonry engine myself. It measures every card, drops each one into the shortest column, and reflows whenever an image loads, the window resizes, or another page gets appended.

Feed append cycle
Markly infinite-scroll append cycleWhen the sentinel scrolls into view, a CSRF-wrapped fetch asks the controller for the next page, which returns a Razor partial. The new cards are appended to the masonry grid, then layout waits for their images to report their own dimensions — racing a three-second timeout so one dead image can't stall the feed — before re-flowing every card into the currently shortest column and resizing the grid. Then it waits for the next sentinel hit.01Sentinel visibleIntersectionObserver02csrf.fetch()token injected, retry once03ControllerRazor partial → HTML04Append cardsinto .masonry-grid05waitForImages()race vs. 3s timeout06MasonryGrid.layout()shortest column first07Position cardsabsolute left / top08Set grid height= tallest column…and again on the next sentinel hit

Append, wait for media, then lay out.

The layout pass is simple. It doesn’t cache measurements and there’s no virtual DOM. It just asks each card how tall it is and puts it in whichever column is shortest right now.

wwwroot/js/masonry.js:83-105View source
items.forEach((item) => {
  item.style.width = `${columnWidth}px`;
  item.style.position = 'absolute';

  const col = getShortestColumn(columnHeights);

  const left = col * (columnWidth + GAP);
  const top = columnHeights[col];

  item.style.left = `${left}px`;
  item.style.top = `${top}px`;

  columnHeights[col] = top + item.offsetHeight + GAP;
});

const maxHeight = Math.max(...columnHeights);
grid.style.height = `${maxHeight > 0 ? maxHeight - GAP : 0}px`;
Place each card in the shortest column.

I store media as JSON on the bookmark. YouTube and Vimeo URLs get parsed into embed URLs with auto-generated thumbnails. Anything that doesn’t match a pattern I recognize becomes null, because a missing thumbnail beats an injected iframe.

Where it got hard

Masonry and infinite scroll fight each other. The layout math would run before freshly appended images knew their own height, so cards would visibly jump after they landed. I made the feed wait for the new batch of images to settle, with a hard timeout so one dead image can’t stall the whole page.

Since every interaction is an AJAX call, every endpoint needed antiforgery protection. All requests go through one fetch wrapper that injects the token and silently retries once when it expires.

wwwroot/js/csrf-helper.js:90-112View source
const response = await fetch(url, {
    ...options,
    headers: { ...options.headers, 'X-XSRF-TOKEN': token }
});

if (response.status === 400) {
    const newToken = await this.refreshToken();
    if (newToken && newToken !== token) {
        return fetch(url, {
            ...options,
            headers: { ...options.headers, 'X-XSRF-TOKEN': newToken }
        });
    }
}

return response;
Refresh the token and retry once.

I also check uploads by magic bytes, not just the file extension.