Blev nyfiken på protokollet som Bluesky skapat, AT-Proto, och hur långt utvecklingen kommit. Så jag bad Claude göra en webbtjänst som låter användaren titta på ett Bluesky-flöde ur olika perspektiv
- bilder
- länkar
- sorterade i popularitetsordning
- text
Du kan testa här https://rikardlinde.se/bsky-feed.html
Mata in en adress med formatet
rikardlinde.bsky.social
Så här såg de mest populära inläggen ut i mitt flöde alldeles nyss (den sorterar de 50 senaste inläggen efter antal gillningar)

Här är koden, en webbsida, ingen inloggning. För över till ett webbhotell så har du en egen fullt fungerande kopia. Hör av dig om du vidareutvecklar :-)
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bluesky Feed</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,600;1,400&family=DM+Serif+Display&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f6f5f1;
--surface: #fff;
--text: #1a1a1a;
--text-secondary: #6b6b6b;
--accent: #1d6ce0;
--border: #e2e0db;
--dim-opacity: 0.18;
--radius: 6px;
}
body {
font-family: 'DM Sans', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.55;
min-height: 100vh;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 3rem 1.25rem 4rem;
}
h1 {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
letter-spacing: -0.01em;
margin-bottom: 0.25rem;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 2rem;
}
/* --- Search --- */
.search-row {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.search-row input {
flex: 1;
font-family: inherit;
font-size: 0.95rem;
padding: 0.65rem 0.9rem;
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
outline: none;
transition: border-color 0.2s;
}
.search-row input:focus {
border-color: var(--accent);
}
.search-row button {
font-family: inherit;
font-size: 0.9rem;
font-weight: 500;
padding: 0.65rem 1.3rem;
border: none;
border-radius: var(--radius);
background: var(--text);
color: var(--bg);
cursor: pointer;
transition: opacity 0.2s;
white-space: nowrap;
}
.search-row button:hover { opacity: 0.8; }
.search-row button:disabled { opacity: 0.4; cursor: wait; }
/* --- Tabs --- */
.tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.75rem;
border-bottom: 1.5px solid var(--border);
padding-bottom: 0;
}
.tab {
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
padding: 0.5rem 1rem 0.65rem;
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.tab::after {
content: '';
position: absolute;
bottom: -1.5px;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
transform: scaleX(0);
transition: transform 0.25s ease;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); }
.tab.active::after { transform: scaleX(1); }
.tab .count {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
background: var(--border);
color: var(--text-secondary);
padding: 0.1rem 0.4rem;
border-radius: 99px;
margin-left: 0.3rem;
vertical-align: middle;
transition: background 0.2s, color 0.2s;
}
.tab.active .count {
background: var(--accent);
color: #fff;
}
/* --- Feed --- */
.feed { display: flex; flex-direction: column; gap: 1rem; }
.feed.feed-images { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.4rem; }
.feed.feed-links { gap: 0.6rem; }
.feed.feed-text { gap: 0; }
.post {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.15rem 1.25rem;
}
/* --- Image gallery items --- */
.gallery-item {
border-radius: var(--radius);
overflow: hidden;
line-height: 0;
}
.gallery-item img {
width: 100%;
height: 200px;
object-fit: cover;
display: block;
background: var(--border);
cursor: pointer;
transition: transform 0.2s;
}
.gallery-item img:hover { transform: scale(1.03); }
/* --- Standalone link cards --- */
.link-item {
display: flex;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
text-decoration: none;
color: inherit;
background: var(--surface);
transition: border-color 0.2s;
}
.link-item:hover { border-color: var(--accent); }
/* --- Text-only items --- */
.text-item {
padding: 0.8rem 0;
border-bottom: 1px solid var(--border);
}
.text-item:last-child { border-bottom: none; }
.text-item-date {
font-size: 0.72rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.text-item-body {
font-size: 0.93rem;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
.text-item-body a {
color: var(--accent);
text-decoration: none;
}
.text-item-body a:hover { text-decoration: underline; }
/* --- Post stats bar --- */
.post-stats {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.78rem;
color: var(--text-secondary);
}
.post-stats span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.stat-heart { color: #e0245e; }
.post-meta {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.6rem;
}
.post-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
background: var(--border);
}
.post-author {
font-weight: 600;
font-size: 0.88rem;
}
.post-handle {
color: var(--text-secondary);
font-size: 0.8rem;
}
.post-date {
margin-left: auto;
color: var(--text-secondary);
font-size: 0.78rem;
white-space: nowrap;
}
.post-text {
font-size: 0.93rem;
margin-bottom: 0.5rem;
white-space: pre-wrap;
word-break: break-word;
}
.post-text a {
color: var(--accent);
text-decoration: none;
}
.post-text a:hover { text-decoration: underline; }
/* Images */
.post-images {
display: grid;
gap: 0.4rem;
margin-top: 0.65rem;
border-radius: var(--radius);
overflow: hidden;
}
.post-images.count-1 { grid-template-columns: 1fr; }
.post-images.count-2 { grid-template-columns: 1fr 1fr; }
.post-images.count-3 { grid-template-columns: 1fr 1fr; }
.post-images.count-4 { grid-template-columns: 1fr 1fr; }
.post-images img {
width: 100%;
height: 200px;
object-fit: cover;
display: block;
background: var(--border);
}
.post-images.count-1 img { height: 300px; }
/* External link card */
.post-link-card {
display: flex;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-top: 0.65rem;
text-decoration: none;
color: inherit;
transition: border-color 0.2s;
}
.post-link-card:hover { border-color: var(--accent); }
.post-link-card-thumb {
width: 120px;
min-height: 80px;
object-fit: cover;
flex-shrink: 0;
background: var(--border);
}
.post-link-card-body {
padding: 0.6rem 0.8rem;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.post-link-card-title {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.post-link-card-desc {
font-size: 0.78rem;
color: var(--text-secondary);
margin-top: 0.15rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-link-card-url {
font-size: 0.72rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
/* Tags */
.post-tags {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.6rem;
}
.tag {
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: 99px;
background: var(--bg);
color: var(--text-secondary);
}
.tag.tag-image { background: #e8f0fe; color: #1a5bb5; }
.tag.tag-link { background: #fef3e0; color: #b5711a; }
.tag.tag-text { background: #e8f6e8; color: #2d7a2d; }
/* States */
.status {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
.status.error { color: #c0392b; }
.load-more {
display: block;
margin: 1.5rem auto 0;
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
padding: 0.55rem 1.5rem;
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
cursor: pointer;
transition: border-color 0.2s;
}
.load-more:hover { border-color: var(--accent); }
@media (max-width: 500px) {
.container { padding: 2rem 1rem 3rem; }
h1 { font-size: 1.6rem; }
.post-link-card { flex-direction: column; }
.post-link-card-thumb { width: 100%; height: 140px; }
}
</style>
</head>
<body>
<div class="container">
<h1>Bluesky Feed</h1>
<p class="subtitle">Visa och filtrera inlägg från ett Bluesky-konto</p>
<div class="search-row">
<input type="text" id="handle" placeholder="t.ex. jay.bsky.team" spellcheck="false"
autocomplete="off" autocapitalize="none">
<button id="fetch-btn" onclick="loadFeed()">Hämta</button>
</div>
<div class="tabs" id="tabs" style="display:none">
<button class="tab active" data-filter="alla" onclick="setFilter('alla', this)">Alla <span class="count" id="count-alla"></span></button>
<button class="tab" data-filter="populara" onclick="setFilter('populara', this)">Populära</button>
<button class="tab" data-filter="bilder" onclick="setFilter('bilder', this)">Bilder <span class="count" id="count-bilder"></span></button>
<button class="tab" data-filter="lankar" onclick="setFilter('lankar', this)">Länkar <span class="count" id="count-lankar"></span></button>
<button class="tab" data-filter="text" onclick="setFilter('text', this)">Enbart text <span class="count" id="count-text"></span></button>
</div>
<div id="feed" class="feed"></div>
<button id="load-more" class="load-more" style="display:none" onclick="loadMore()">Ladda fler</button>
</div>
<script>
const API = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed';
let posts = [];
let cursor = null;
let currentFilter = 'alla';
let currentActor = '';
document.getElementById('handle').addEventListener('keydown', e => {
if (e.key === 'Enter') loadFeed();
});
async function loadFeed() {
const handle = document.getElementById('handle').value.trim();
if (!handle) return;
currentActor = handle;
posts = [];
cursor = null;
document.getElementById('feed').innerHTML = '<div class="status">Hämtar inlägg…</div>';
document.getElementById('tabs').style.display = 'none';
document.getElementById('load-more').style.display = 'none';
document.getElementById('fetch-btn').disabled = true;
await fetchPage();
document.getElementById('fetch-btn').disabled = false;
}
async function loadMore() {
if (!cursor) return;
await fetchPage();
}
async function fetchPage() {
try {
let url = `${API}?actor=${encodeURIComponent(currentActor)}&limit=50`;
if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`;
const resp = await fetch(url);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || `HTTP ${resp.status}`);
}
const data = await resp.json();
cursor = data.cursor || null;
const newPosts = (data.feed || []).map(item => parsePost(item));
posts.push(...newPosts);
renderFeed();
updateCounts();
document.getElementById('tabs').style.display = 'flex';
document.getElementById('load-more').style.display = cursor ? 'block' : 'none';
} catch (err) {
if (posts.length === 0) {
document.getElementById('feed').innerHTML =
`<div class="status error">Kunde inte hämta: ${esc(err.message)}</div>`;
}
}
}
function parsePost(item) {
const p = item.post;
const record = p.record || {};
const embed = p.embed || {};
const author = p.author || {};
// Classify
const images = extractImages(embed);
const link = extractLink(embed);
const hasImages = images.length > 0;
const hasLink = !!link;
const textOnly = !hasImages && !hasLink;
// Extract inline links from facets
const inlineLinks = extractFacetLinks(record);
return {
uri: p.uri,
cid: p.cid,
text: record.text || '',
createdAt: record.createdAt || '',
author: {
displayName: author.displayName || author.handle || '',
handle: author.handle || '',
avatar: author.avatar || '',
},
images,
link,
inlineLinks,
hasImages,
hasLink: hasLink || inlineLinks.length > 0,
textOnly: textOnly && inlineLinks.length === 0,
likeCount: p.likeCount || 0,
repostCount: p.repostCount || 0,
replyCount: p.replyCount || 0,
};
}
function extractImages(embed) {
// app.bsky.embed.images#view
if (embed.$type === 'app.bsky.embed.images#view' && embed.images) {
return embed.images.map(img => ({
thumb: img.thumb,
fullsize: img.fullsize,
alt: img.alt || '',
}));
}
// app.bsky.embed.recordWithMedia#view
if (embed.$type === 'app.bsky.embed.recordWithMedia#view' && embed.media) {
return extractImages(embed.media);
}
return [];
}
function extractLink(embed) {
// app.bsky.embed.external#view
if (embed.$type === 'app.bsky.embed.external#view' && embed.external) {
const ext = embed.external;
return {
uri: ext.uri || '',
title: ext.title || '',
description: ext.description || '',
thumb: ext.thumb || '',
};
}
if (embed.$type === 'app.bsky.embed.recordWithMedia#view' && embed.media) {
return extractLink(embed.media);
}
return null;
}
function extractFacetLinks(record) {
if (!record.facets) return [];
const links = [];
for (const facet of record.facets) {
for (const feature of (facet.features || [])) {
if (feature.$type === 'app.bsky.richtext.facet#link') {
links.push(feature.uri);
}
}
}
return links;
}
function setFilter(filter, tabEl) {
currentFilter = filter;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tabEl.classList.add('active');
renderFeed();
}
function updateCounts() {
document.getElementById('count-alla').textContent = posts.length;
document.getElementById('count-bilder').textContent = posts.filter(p => p.hasImages).length;
document.getElementById('count-lankar').textContent = posts.filter(p => p.hasLink).length;
document.getElementById('count-text').textContent = posts.length;
}
function renderFeed() {
const feed = document.getElementById('feed');
if (posts.length === 0) {
feed.innerHTML = '<div class="status">Inga inlägg hittades.</div>';
return;
}
feed.className = 'feed';
if (currentFilter === 'bilder') {
feed.classList.add('feed-images');
const allImages = [];
posts.forEach(post => {
post.images.forEach(img => {
allImages.push(img);
});
});
if (allImages.length === 0) {
feed.innerHTML = '<div class="status">Inga bilder hittades.</div>';
return;
}
feed.innerHTML = allImages.map(img =>
`<div class="gallery-item">
<a href="${esc(img.fullsize)}" target="_blank" rel="noopener">
<img src="${esc(img.thumb)}" alt="${esc(img.alt)}" loading="lazy">
</a>
</div>`
).join('');
return;
}
if (currentFilter === 'lankar') {
feed.classList.add('feed-links');
const allLinks = [];
posts.forEach(post => {
if (post.link) allLinks.push(post.link);
});
if (allLinks.length === 0) {
feed.innerHTML = '<div class="status">Inga länkar hittades.</div>';
return;
}
feed.innerHTML = allLinks.map(link => {
let domain = '';
try { domain = new URL(link.uri).hostname; } catch {}
return `
<a class="link-item" href="${esc(link.uri)}" target="_blank" rel="noopener">
${link.thumb ? `<img class="post-link-card-thumb" src="${esc(link.thumb)}" alt="" loading="lazy">` : ''}
<div class="post-link-card-body">
<div class="post-link-card-title">${esc(link.title || link.uri)}</div>
${link.description ? `<div class="post-link-card-desc">${esc(link.description)}</div>` : ''}
<div class="post-link-card-url">${esc(domain)}</div>
</div>
</a>`;
}).join('');
return;
}
if (currentFilter === 'text') {
feed.classList.add('feed-text');
feed.innerHTML = posts.filter(p => p.text).map(post =>
`<div class="text-item">
<div class="text-item-date">${formatDate(post.createdAt)}</div>
<div class="text-item-body">${renderText(post)}</div>
</div>`
).join('');
return;
}
// "Populära" — full posts sorted by likes
if (currentFilter === 'populara') {
const sorted = [...posts].sort((a, b) => b.likeCount - a.likeCount);
feed.innerHTML = sorted.map((post, i) => `
<article class="post" data-index="${i}">
<div class="post-meta">
${post.author.avatar
? `<img class="post-avatar" src="${esc(post.author.avatar)}" alt="" loading="lazy">`
: `<div class="post-avatar"></div>`}
<div>
<div class="post-author">${esc(post.author.displayName)}</div>
<div class="post-handle">@${esc(post.author.handle)}</div>
</div>
<div class="post-date">${formatDate(post.createdAt)}</div>
</div>
${post.text ? `<div class="post-text">${renderText(post)}</div>` : ''}
${renderImages(post.images)}
${renderLinkCard(post.link)}
<div class="post-stats">
<span><span class="stat-heart">♥</span> ${post.likeCount}</span>
<span>↻ ${post.repostCount}</span>
<span>💬 ${post.replyCount}</span>
</div>
</article>`).join('');
return;
}
// "Alla" — full posts
feed.innerHTML = posts.map((post, i) => `
<article class="post" data-index="${i}">
<div class="post-meta">
${post.author.avatar
? `<img class="post-avatar" src="${esc(post.author.avatar)}" alt="" loading="lazy">`
: `<div class="post-avatar"></div>`}
<div>
<div class="post-author">${esc(post.author.displayName)}</div>
<div class="post-handle">@${esc(post.author.handle)}</div>
</div>
<div class="post-date">${formatDate(post.createdAt)}</div>
</div>
${post.text ? `<div class="post-text">${renderText(post)}</div>` : ''}
${renderImages(post.images)}
${renderLinkCard(post.link)}
${renderTags(post)}
</article>`).join('');
}
function renderText(post) {
let text = esc(post.text);
// Simple URL detection for display
text = text.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
return text;
}
function renderImages(images) {
if (!images.length) return '';
const countClass = `count-${Math.min(images.length, 4)}`;
const imgs = images.map(img =>
`<a href="${esc(img.fullsize)}" target="_blank" rel="noopener">` +
`<img src="${esc(img.thumb)}" alt="${esc(img.alt)}" loading="lazy"></a>`
).join('');
return `<div class="post-images ${countClass}">${imgs}</div>`;
}
function renderLinkCard(link) {
if (!link) return '';
let domain = '';
try { domain = new URL(link.uri).hostname; } catch {}
return `
<a class="post-link-card" href="${esc(link.uri)}" target="_blank" rel="noopener">
${link.thumb ? `<img class="post-link-card-thumb" src="${esc(link.thumb)}" alt="" loading="lazy">` : ''}
<div class="post-link-card-body">
<div class="post-link-card-title">${esc(link.title || link.uri)}</div>
${link.description ? `<div class="post-link-card-desc">${esc(link.description)}</div>` : ''}
<div class="post-link-card-url">${esc(domain)}</div>
</div>
</a>`;
}
function renderTags(post) {
const tags = [];
if (post.hasImages) tags.push('<span class="tag tag-image">Bild</span>');
if (post.hasLink) tags.push('<span class="tag tag-link">Länk</span>');
if (post.textOnly) tags.push('<span class="tag tag-text">Text</span>');
return tags.length ? `<div class="post-tags">${tags.join('')}</div>` : '';
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const diff = now - d;
if (diff < 3600000) return `${Math.floor(diff / 60000)} min`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} tim`;
const sameYear = d.getFullYear() === now.getFullYear();
const opts = sameYear
? { day: 'numeric', month: 'short' }
: { day: 'numeric', month: 'short', year: 'numeric' };
return d.toLocaleDateString('sv-SE', opts);
}
function esc(str) {
const el = document.createElement('span');
el.textContent = str || '';
return el.innerHTML;
}
</script>
</body>
</html>
Koden, och övrig information, på denna webbsida är licensierad för återanvändning enligt CC BY-SA.


Lämna ett svar