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.

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.
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.
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`;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.
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;I also check uploads by magic bytes, not just the file extension.