Created
December 1, 2024 13:36
-
-
Save cworld1/cf0dc6daa89264e6d745d3e68ef8c01c to your computer and use it in GitHub Desktop.
Friend Circle Lite for astro-theme-pure
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
interface Config { | |
private_api_url: string | |
page_turning_number: number | |
error_img: string | |
} | |
interface Article { | |
title: string | |
link: string | URL | |
avatar: string | |
author: string | |
created: string | |
} | |
interface ArticleData { | |
article_data: Article[] | |
statistical_data: { | |
friends_num: number | |
active_num: number | |
article_num: number | |
last_updated_time: string | |
} | |
} | |
export class FriendCircle { | |
config!: Config | |
root!: HTMLElement | |
start = 0 | |
allArticles: Article[] = [] | |
container!: HTMLElement | |
randomArticleContainer!: HTMLElement | |
statsContainer!: HTMLElement | |
loadMoreBtn!: HTMLButtonElement | |
modal!: HTMLElement | |
load() { | |
this.loadMoreArticles() | |
this.loadMoreBtn.addEventListener('click', this.loadMoreArticles.bind(this)) | |
window.onclick = (event) => { | |
const modal = document.getElementById('modal') | |
if (event.target === modal) { | |
this.hideModal() | |
} | |
} | |
} | |
init(config: Partial<Config>) { | |
this.config = { | |
private_api_url: config.private_api_url || '', | |
page_turning_number: config.page_turning_number || 20, | |
error_img: | |
config.error_img || | |
'https://fastly.jsdelivr.net/gh/willow-god/Friend-Circle-Lite@latest/static/favicon.ico' | |
} | |
this.root = document.getElementById('friend-circle-lite-root') as HTMLElement | |
if (!this.root) return | |
this.root.innerHTML = '' | |
this.createContainers() | |
} | |
private createContainers() { | |
this.randomArticleContainer = this.createElement('div', { id: 'random-article' }) | |
this.container = this.createElement('div', { | |
className: 'articles-container', | |
id: 'articles-container' | |
}) | |
this.loadMoreBtn = this.createElement('button', { | |
id: 'load-more-btn', | |
innerText: 'Load more' | |
}) as HTMLButtonElement | |
this.statsContainer = this.createElement('div', { id: 'stats-container' }) | |
this.root.append( | |
this.randomArticleContainer, | |
this.container, | |
this.loadMoreBtn, | |
this.statsContainer | |
) | |
} | |
private createElement<K extends keyof HTMLElementTagNameMap>( | |
tag: K, | |
attributes: Partial<HTMLElementTagNameMap[K]> | |
): HTMLElementTagNameMap[K] { | |
const element = document.createElement(tag) | |
Object.assign(element, attributes) | |
return element | |
} | |
loadMoreArticles() { | |
const cacheKey = 'friend-circle-lite-cache' | |
const cacheTimeKey = 'friend-circle-lite-cache-time' | |
const cacheTime = localStorage.getItem(cacheTimeKey) | |
const now = Date.now() | |
if (cacheTime && now - Number(cacheTime) < 10 * 60 * 1000) { | |
const cachedDataString = localStorage.getItem(cacheKey) | |
const cachedData = cachedDataString ? JSON.parse(cachedDataString) : null | |
if (cachedData) { | |
this.processArticles(cachedData) | |
return | |
} | |
} | |
fetch(`${this.config.private_api_url}all.json`) | |
.then((response) => response.json()) | |
.then((data) => { | |
localStorage.setItem(cacheKey, JSON.stringify(data)) | |
localStorage.setItem(cacheTimeKey, now.toString()) | |
this.processArticles(data) | |
}) | |
.finally(() => { | |
this.loadMoreBtn.innerText = 'Load more' | |
}) | |
} | |
processArticles({ article_data, statistical_data }: ArticleData) { | |
this.allArticles = article_data | |
this.updateStats(statistical_data) | |
this.displayRandomArticle() | |
this.displayArticles() | |
} | |
private updateStats(stats: ArticleData['statistical_data']) { | |
this.statsContainer.innerHTML = ` | |
<div>${stats.friends_num} links with ${stats.active_num} active | ${stats.article_num} articles in total</div> | |
<div>Updated at ${stats.last_updated_time}</div> | |
<div>Powered by <a href="https://github.com/willow-god/Friend-Circle-Lite" target="_blank">FriendCircleLite</a><br></div> | |
` | |
} | |
private displayArticles() { | |
const articles = this.allArticles.slice( | |
this.start, | |
this.start + this.config.page_turning_number | |
) | |
articles.forEach((article) => this.createArticleCard(article)) | |
this.start += this.config.page_turning_number | |
if (this.start >= this.allArticles.length) { | |
this.loadMoreBtn.style.display = 'none' | |
} | |
} | |
private createArticleCard(article: Article) { | |
const card = document.createElement('div') | |
card.className = 'article' | |
card.innerHTML = ` | |
<div class="article-image author-click"> | |
<img class="no-lightbox" src="${article.avatar || this.config.error_img}" onerror="this.src='${this.config.error_img}'"> | |
</div> | |
<div class="article-container"> | |
<div class="article-author author-click">${article.author}</div> | |
<a class="article-title" href="${article.link instanceof URL ? article.link.toString() : article.link}" target="_blank">${article.title}</a> | |
<div class="article-date">️${article.created.substring(0, 10)}</div> | |
</div> | |
` | |
card.querySelectorAll('.author-click').forEach((el) => { | |
el.addEventListener('click', () => { | |
this.showAuthorArticles(article.author, article.avatar, article.link) | |
}) | |
}) | |
this.container.appendChild(card) | |
} | |
displayRandomArticle() { | |
const randomArticle = this.allArticles[Math.floor(Math.random() * this.allArticles.length)] | |
this.randomArticleContainer.innerHTML = ` | |
<div class="random-title">Random Poll</div> | |
<div class="article-container"> | |
<div class="article-author">${randomArticle.author}</div> | |
<a class="article-title" href="${randomArticle.link}" target="_blank">${randomArticle.title}</a> | |
<div class="article-date">️${randomArticle.created.substring(0, 10)}</div> | |
</div> | |
<button id="random-refresh"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none"><path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"/><path fill="currentColor" d="M2 12.08c-.006-.862.91-1.356 1.618-.975l.095.058l2.678 1.804c.972.655.377 2.143-.734 2.007l-.117-.02l-1.063-.234a8.002 8.002 0 0 0 14.804.605a1 1 0 0 1 1.82.828c-1.987 4.37-6.896 6.793-11.687 5.509A10 10 0 0 1 2 12.08m.903-4.228C4.89 3.482 9.799 1.06 14.59 2.343a10 10 0 0 1 7.414 9.581c.007.863-.91 1.358-1.617.976l-.096-.058l-2.678-1.804c-.972-.655-.377-2.143.734-2.007l.117.02l1.063.234A8.002 8.002 0 0 0 4.723 8.68a1 1 0 1 1-1.82-.828"/></g></svg> | |
</button> | |
` | |
this.randomArticleContainer | |
.querySelector('button#random-refresh') | |
?.addEventListener('click', (event) => { | |
event.preventDefault() | |
this.displayRandomArticle() | |
}) | |
} | |
// Enable modal | |
showAuthorArticles(author: string, avatar: string, link: string | URL) { | |
if (!document.getElementById('fclite-modal')) { | |
const modal = this.createElement('div', { id: 'modal', className: 'modal' }) | |
modal.innerHTML = ` | |
<div class="modal-content"> | |
<div class="modal-header"> | |
<img class="modal-author-avatar" src="${avatar || this.config.error_img}" alt=""> | |
<a class="modal-author-name-link" href="${new URL(link.toString()).origin}" target="_blank">${author}</a> | |
</div> | |
<div id="modal-articles-container"></div> | |
</div> | |
` | |
this.root.appendChild(modal) | |
} | |
this.modal = document.getElementById('modal') as HTMLElement | |
const modalArticlesContainer = document.getElementById( | |
'modal-articles-container' | |
) as HTMLElement | |
const authorArticles = this.allArticles.filter((article) => article.author === author) | |
authorArticles.slice(0, 4).forEach((article) => { | |
const articleTemplate = ` | |
<div class="modal-article"> | |
<a class="modal-article-title" href="${article.link instanceof URL ? article.link.toString() : article.link}" target="_blank">${article.title}</a> | |
<div class="modal-article-date">${article.created.substring(0, 10)}</div> | |
</div>` | |
modalArticlesContainer.insertAdjacentHTML('beforeend', articleTemplate) | |
}) | |
this.modal.style.display = 'block' | |
setTimeout(() => { | |
this.modal.classList.add('modal-open') | |
}, 10) | |
} | |
hideModal() { | |
this.modal.classList.remove('modal-open') | |
this.modal.addEventListener( | |
'transitionend', | |
() => { | |
this.modal.style.display = 'none' | |
this.root.removeChild(this.modal) | |
}, | |
{ once: true } | |
) | |
} | |
} |
Style example:
/* Random article */
#random-article {
display: flex;
margin-bottom: 0.7rem;
column-gap: 0.7rem;
align-items: center;
}
.random-title {
color: hsl(var(--foreground));
white-space: nowrap;
}
#random-refresh {
height: 2.35rem;
aspect-ratio: 1;
display: grid;
place-items: center;
border-radius: 0.75rem;
border: 1px solid hsl(var(--border));
transition: background-color 0.2s;
}
#random-refresh:hover {
background-color: hsl(var(--primary-foreground));
}
#random-refresh svg {
width: 1.25rem;
height: 1.25rem;
}
@media (max-width: 640px) {
#random-article {
flex-wrap: wrap;
row-gap: 0.3rem;
margin-bottom: 1.5rem;
}
.random-title {
flex-grow: 1;
}
.article-container {
order: 3;
flex-basis: 100%;
}
#random-refresh {
height: max-content;
padding: 0.2rem;
}
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: hsl(var(--background) / 0.3);
--tw-blur: blur(24px);
backdrop-filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale)
var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
-webkit-backdrop-filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast)
var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia)
var(--tw-drop-shadow);
z-index: 999;
opacity: 0;
visibility: hidden;
transition:
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out;
}
.modal.modal-open {
opacity: 1;
visibility: visible;
}
.modal-content {
opacity: 0;
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) translateY(-50px);
width: 40rem;
z-index: 1000;
max-height: 90%;
transition:
transform 0.3s ease-in-out,
opacity 0.3s ease-in-out;
overflow: hidden;
display: flex;
flex-direction: column;
}
@media screen and (max-width: 675px) {
.modal-content {
width: 90%;
}
}
.modal.modal-open .modal-content {
transform: translate(-50%, -50%) translateY(0);
opacity: 1;
}
.modal-header {
display: flex;
justify-content: center;
align-items: center;
column-gap: 0.9rem;
padding: 0.7rem 1rem;
}
.modal-author-avatar {
border-radius: 999px;
width: 3rem;
height: 3rem;
}
.modal-author-name-link {
text-decoration: none;
}
#modal-articles-container {
background-color: hsl(var(--primary-foreground));
border: 1px solid hsl(var(--border));
padding: 0.7rem 0.7rem;
border-radius: 0.75rem;
overflow-y: scroll;
display: flex;
flex-direction: column;
row-gap: 0.7rem;
}
.modal-article .modal-article-title {
cursor: pointer;
text-decoration: none;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-article .modal-article-date {
font-size: 0.75rem;
line-height: 1rem;
text-align: right;
}
/* Articles */
.articles-container {
display: flex;
flex-direction: column;
row-gap: 0.7rem;
}
.article {
display: flex;
align-items: center;
column-gap: 0.7rem;
}
.article-image img {
border-radius: 999px;
min-width: 2rem;
min-height: 2rem;
width: 2rem;
height: 2rem;
}
.article-container {
flex-grow: 1;
border-radius: 0.75rem;
padding: 0.3rem 1rem;
border: 1px solid hsl(var(--border));
display: flex;
align-items: center;
column-gap: 0.6rem;
transition: background-color 0.2s;
}
.article-container:hover {
background-color: hsl(var(--primary-foreground));
}
.author-click {
cursor: pointer;
}
.article-author {
white-space: nowrap;
}
.article-title {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
line-clamp: 1;
flex-grow: 1;
text-decoration: none;
color: hsl(var(--foreground));
transition: color 0.2s;
}
.article-date {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 0.75rem;
line-height: 1rem;
}
@media (max-width: 640px) {
.article {
align-items: start;
}
.article-image {
margin-top: 0.3rem;
}
.article-container {
flex-wrap: wrap;
}
.article-author {
flex-grow: 1;
}
.article-title {
order: 3;
flex-basis: 100%;
}
}
/* Load more */
#load-more-btn {
background-color: hsl(var(--primary-foreground));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
padding: 0.2rem 1rem;
margin: 0.75rem auto;
display: block;
transition:
color 0.2s,
background-color 0.2s;
}
#load-more-btn:hover {
color: hsl(var(--primary));
background-color: hsl(var(--input));
}
/* Status */
#stats-container {
font-size: 0.75rem;
line-height: 1rem;
text-align: right;
}
#stats-container > * {
margin-bottom: 0.2rem;
}
#stats-container a {
color: hsl(var(--foreground));
text-decoration: none;
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See more:
Usage:
Example: https://cworld0.com/links