Projects / snek / src / snek / templates / forum.html

git clone https://molodetz.nl/retoor/snek.git

Raw source file available here .

{% extends "app.html" %}

{% block title %}Forum{% endblock %}

{% block header_text %} class="breadcrumb" id="breadcrumb">
Breadcrumb will be rendered here -->
{% endblock %}
{% block main %}

snek-forum, .container {
height: 100%;
width: 90%;
background: transparent !important;
}


// snek-forum.js - Forum Web Component (Tech-centric Dark Theme)
class SnekForum extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.ws = null;
this.currentView = 'forums';
this.currentForum = null;
this.currentThread = null;
this.currentPage = 1;
}

connectedCallback() {
this.render();
this.connectWebSocket();
this.loadForums();
}

disconnectedCallback() {
if (this.ws) {
this.ws.close();
}
}

connectWebSocket() {
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/forum/ws`;
this.ws = new WebSocket(wsUrl);

this.ws.onopen = () => {
console.log('WebSocket connected');
};

this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};

this.ws.onclose = () => {
console.log('WebSocket disconnected');
setTimeout(() => this.connectWebSocket(), 3000);
};
}

handleWebSocketMessage(data) {
switch (data.type) {
case 'post_created':
if (this.currentView === 'thread' && this.currentThread?.uid === data.data.thread_uid) {
this.addNewPost(data.data.post);
}
break;
case 'post_edited':
this.updatePost(data.data.post);
break;
case 'post_deleted':
this.removePost(data.data.post.uid);
break;
case 'post_liked':
case 'post_unliked':
this.updatePostLikes(data.data.post_uid);
break;
case 'thread_created':
if (this.currentView === 'forum' && this.currentForum?.uid === data.data.forum_uid) {
this.loadForum(this.currentForum.slug);
}
break;
}
}

subscribe(type, id) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'subscribe',
type: type,
id: id
}));
}
}

unsubscribe(type, id) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'unsubscribe',
type: type,
id: id
}));
}
}

async fetchAPI(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}

async loadForums() {
if (window.preloadedForums) {
this.currentView = 'forums';
this.renderForums(window.preloadedForums);
this.updateBreadcrumb();
window.preloadedForums = null; // Clear it after use
return;
}
try {
const data = await this.fetchAPI('/forum/api/forums');
this.currentView = 'forums';
this.renderForums(data.forums);
this.updateBreadcrumb();
} catch (error) {
console.error('Error loading forums:', error);
}
}

async loadForum(slug, page = 1) {
try {
this.currentPage = page;
const data = await this.fetchAPI(`/forum/api/forums/${slug}?page=${this.currentPage}`);
this.currentView = 'forum';
this.currentForum = data.forum;
this.subscribe('forum', data.forum.uid);
this.renderForum(data);
this.updateBreadcrumb();
} catch (error) {
console.error('Error loading forum:', error);
}
}

async loadThread(slug, page = 1) {
try {
this.currentPage = page;
const data = await this.fetchAPI(`/forum/api/threads/${slug}?page=${this.currentPage}`);
this.currentView = 'thread';
this.currentThread = data.thread;
if (this.currentForum) {
this.unsubscribe('forum', this.currentForum.uid);
}
this.subscribe('thread', data.thread.uid);
this.renderThread(data);
this.updateBreadcrumb();
} catch (error) {
console.error('Error loading thread:', error);
}
}

async createThread(forumSlug, title, content) {
try {
const data = await this.fetchAPI(`/forum/api/forums/${forumSlug}/threads`, {
method: 'POST',
body: JSON.stringify({ title, content })
});
this.loadThread(data.thread.slug);
} catch (error) {
console.error('Error creating thread:', error);
}
}

async createPost(threadUid, content) {
try {
await this.fetchAPI(`/forum/api/threads/${threadUid}/posts`, {
method: 'POST',
body: JSON.stringify({ content })
});
// Post will be added via WebSocket
} catch (error) {
console.error('Error creating post:', error);
}
}

async toggleLike(postUid) {
try {
const data = await this.fetchAPI(`/forum/api/posts/${postUid}/like`, {
method: 'POST'
});
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${postUid}"]`);
if (postEl) {
const likeBtn = postEl.querySelector('.like-button');
const likeCount = postEl.querySelector('.like-count');
likeBtn.classList.toggle('liked', data.is_liked);
likeCount.textContent = data.like_count;
likeBtn.querySelector('span').textContent = data.is_liked ? '❤️' : '🤍';
}
} catch (error) {
console.error('Error toggling like:', error);
}
}

render() {
const accent = "#00FFFF";
const accent_alt = "#FF6200";
const isCyan = true; // switch between cyan and orange here

// Generate subtle star background (canvas dots)
const bgSvg = encodeURIComponent(`
width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">

${Array.from({length: 40}).map(() => {
const x = Math.random() * 100;
const y = Math.random() * 100;
const r = Math.random() * 1.4 + 0.2;
return ` cx="${x}vw" cy="${y}vh" r="${r}" fill="white" opacity="0.2" />`;
}).join('')}


`);
this.shadowRoot.innerHTML = `

@import url('https://fonts.googleapis.com/css?family=Montserrat:400,700&display=swap');
:host {
display: block;
min-height: 100vh;
font-family: 'Montserrat', 'Poppins', 'Roboto', Arial, sans-serif;
background: transparent !important;
color: #fff;
}
.container {
width: 100%;
height: 100%;
background: transparent !important;
}
.header {
margin-bottom: 24px;
text-align: center;
}
.breadcrumb {
display: flex;
gap: 10px;
align-items: center;
font-size: 14pt;
justify-content: center;
color: ${accent};
letter-spacing: 0.1em;
}
.breadcrumb a {
color: #fff;
text-decoration: none;
font-weight: 600;
cursor: pointer;
}
.breadcrumb a:hover {
text-decoration: underline;
color: ${accent_alt};
}
.breadcrumb span {
color: #A9A9A9;
font-weight: 700;
}
.content {
min-height: 120px;
background: transparent !important;
}
/* Forums List */
.forums-list {
padding: 0;
margin: 0;
}
.forum-item {
display: flex;
align-items: center;
padding: 24px 18px;
cursor: pointer;
transition: background 0.12s;
background: transparent !important;
}
.forum-item:hover {
background: #23282D;
}
.forum-item:last-child { border-bottom: none; }
.forum-icon {
width: 56px;
height: 56px;
margin-right: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #202B2E;
border-radius: 10px;
font-size: 30px;
color: ${accent};
border: 1.5px solid ${accent};
box-shadow: 0 1px 4px 0 #00FFFF22;
}
.forum-info { flex: 1; }
.forum-name {
font-size: 22pt;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 3px;
color: #fff;
letter-spacing: 0.05em;
}
.forum-description {
font-size: 16pt;
color: #A9A9A9;
margin-bottom: 5px;
font-weight: 400;
}
.forum-stats {
font-size: 12pt;
color: #00FFFF;
opacity: 0.7;
font-weight: 500;
}
/* Threads List */
.thread-item {
display: flex;
padding: 18px 18px;
cursor: pointer;
transition: background 0.12s;
background: transparent !important;
}
.thread-item:hover {
background: #20262B;
}
.thread-item.pinned {
border-left: 5px solid ${accent};
}
.thread-info { flex: 1; }
.thread-title {
font-size: 18pt;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
text-transform: uppercase;
color: #fff;
}
.thread-meta {
font-size: 12pt;
color: #A9A9A9;
margin-top: 3px;
}
.thread-stats {
text-align: right;
font-size: 11pt;
color: #A9A9A9;
min-width: 120px;
}
.badge {
font-size: 10pt;
padding: 2px 10px;
border-radius: 4px;
background: #232323;
color: ${accent};
font-weight: 700;
text-transform: uppercase;
margin-left: 4px;
}
.badge.pinned {
background: ${accent};
color: #181E22;
}
.badge.locked {
background: #fff;
color: #181E22;
border: 1px solid ${accent_alt};
}
/* Posts */
.posts-list { margin: 0; }
.post {
display: flex;
padding: 24px 18px;
background: transparent !important;
}
.post-author {
width: 120px;
margin-right: 30px;
text-align: center;
}
.author-avatar {
width: 62px;
height: 62px;
border-radius: 50%;
background: #232323;
margin: 0 auto 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 27pt;
font-weight: bold;
border: 2px solid ${accent};
box-shadow: 0 2px 12px 0 #00FFFF11;
}
.author-name {
font-weight: 700;
font-size: 13pt;
color: #fff;
letter-spacing: 0.03em;
}
.post-content { flex: 1; }
.post-header {
font-size: 12pt;
color: #A9A9A9;
margin-bottom: 10px;
}
.post-body {
line-height: 1.7;
font-size: 15pt;
color: #fff;
white-space: pre-wrap;
}
.post-footer {
display: flex;
gap: 24px;
margin-top: 18px;
padding-top: 14px;
}
.post-action {
font-size: 13pt;
color: #A9A9A9;
cursor: pointer;
display: flex;
align-items: center;
gap: 7px;
font-weight: 600;
letter-spacing: 0.03em;
transition: color 0.12s;
}
.post-action:hover, .post-action.liked {
color: ${accent};
}
.like-button.liked span:first-child {
color: ${accent_alt};
text-shadow: 0 2px 8px #FF620066;
}
/* Forms */
.form-group {
margin-bottom: 22px;
}
.form-group label {
display: block;
margin-bottom: 7px;
font-weight: 700;
text-transform: uppercase;
color: ${accent};
font-size: 15pt;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 13px 16px;
font-size: 16pt;
font-family: inherit;
color: #fff;
background: #232323;
outline: none;
transition: border-color 0.16s;
}
.form-group input:focus,
.form-group textarea:focus {
border-color: ${accent_alt};
}
.form-group textarea {
min-height: 130px;
resize: vertical;
}
.button {
padding: 12px 32px;
border: none;
border-radius: 10px;
font-size: 16pt;
font-weight: 700;
cursor: pointer;
background: ${accent};
color: #fff;
box-shadow: 0 2px 10px #00FFFF33;
text-transform: uppercase;
letter-spacing: 0.07em;
transition: background 0.12s, color 0.12s;
}
.button:hover {
background: ${accent_alt};
color: #fff;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.new-thread-button {
display: block;
margin: 0 auto 24px auto;
width: 250px;
}
.reply-form {
width: 33%;
padding: 32px 18px 18px 18px;
margin-top: 0;
}
.reply-form h3 {
text-align: center;
color: ${accent};
font-size: 22pt;
margin-bottom: 18px;
font-weight: 700;
text-transform: uppercase;
}
.modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: #181E22;
border-radius: 14px;
padding: 36px 32px;
max-width: 520px;
width: 96vw;
max-height: 90vh;
box-shadow: 0 2px 32px 0 #000C;
color: #fff;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 24pt;
font-weight: 700;
text-transform: uppercase;
color: ${accent};
}
.modal-close {
font-size: 32px;
cursor: pointer;
background: none;
border: none;
color: #A9A9A9;
transition: color 0.13s;
}
.modal-close:hover {
color: ${accent_alt};
}
.pagination {
display: flex;
justify-content: center;
gap: 15px;
padding: 24px 0 10px 0;
}
.page-button {
padding: 7px 18px;
border: 1.5px solid ${accent};
background: #232323;
cursor: pointer;
border-radius: 8px;
color: #fff;
font-size: 14pt;
font-weight: 700;
text-transform: uppercase;
transition: background 0.13s, color 0.13s;
}
.page-button:hover {
background: ${accent};
color: #181E22;
}
.page-button.active {
background: ${accent};
color: #181E22;
border-color: ${accent_alt};
}
/* Feature card style for content sections */
.feature-card {
background: #1A1A1A;
border-radius: 10px;
box-shadow: 0 1px 8px #0006;
border: 1px solid #232323;
padding: 28px 22px;
margin-bottom: 20px;
color: #fff;
}
.feature-card .title {
color: ${accent_alt};
font-size: 20pt;
font-weight: 700;
margin-bottom: 9px;
text-transform: uppercase;
}
/* Minimal snake icon for branding */
.snake-icon {
display: inline-block;
width: 42px;
height: 42px;
margin-bottom: -10px;
vertical-align: middle;
}
.snake-icon path {
stroke: ${accent};
stroke-width: 3;
fill: none;
}

class="container">
class="content" id="main-content">
Content will be rendered here -->


class="modal" id="new-thread-modal">
class="modal-content">
class="modal-header">
class="modal-title">NEW THREAD
class="modal-close" type="button">×

id="new-thread-form">
class="form-group">

type="text" name="title" required minlength="5" maxlength="200" placeholder="Thread Title">

class="form-group">

name="content" required minlength="1" placeholder="Write your post...">

type="submit" class="button">CREATE THREAD



`;

// Breadcrumb render
this.updateBreadcrumb();

// Add event listeners
this.shadowRoot.addEventListener('click', (e) => {
// Breadcrumb navigation
if (e.target.closest('.breadcrumb a')) {
e.preventDefault();
const node = e.target.closest('.breadcrumb a');
const action = node.dataset.action;
if (action === "forums") {
this.loadForums();
} else if (action === "forum") {
this.loadForum(node.dataset.slug);
}
}
// Forum item
else if (e.target.closest('[data-forum-slug]')) {
this.loadForum(e.target.closest('[data-forum-slug]').dataset.forumSlug);
}
// Thread item
else if (e.target.closest('[data-thread-slug]')) {
this.loadThread(e.target.closest('[data-thread-slug]').dataset.threadSlug);
}
// Like button
else if (e.target.closest('.like-button')) {
this.toggleLike(e.target.closest('[data-post-uid]').dataset.postUid);
}
// New thread button
else if (e.target.closest('.new-thread-button')) {
this.shadowRoot.getElementById('new-thread-modal').classList.add('show');
}
// Modal close
else if (e.target.closest('.modal-close')) {
e.target.closest('.modal').classList.remove('show');
}
// Pagination buttons
else if (e.target.matches('.page-button[data-page]')) {
const page = parseInt(e.target.dataset.page, 10);
if (this.currentView === "forum") {
this.loadForum(this.currentForum.slug, page);
} else if (this.currentView === "thread") {
this.loadThread(this.currentThread.slug, page);
}
}
});

// Form submissions
this.shadowRoot.getElementById('new-thread-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
this.createThread(this.currentForum.slug, formData.get('title'), formData.get('content'));
e.target.reset();
this.shadowRoot.getElementById('new-thread-modal').classList.remove('show');
});
}

updateBreadcrumb() {
const breadcrumbContainer = document.getElementById('breadcrumb');
if (!breadcrumbContainer) return;

const crumb = [];
crumb.push(` href="#" data-action="forums">FORUMS`);
if (this.currentView === "forum" && this.currentForum) {
crumb.push(``);
crumb.push(`${this.currentForum.name.toUpperCase()}`);
}
if (this.currentView === "thread" && this.currentThread && this.currentForum) {
crumb.push(``);
crumb.push(` href="#" data-action="forum" data-slug="${this.currentForum.slug}">${this.currentForum.name.toUpperCase()}`);
crumb.push(``);
crumb.push(`${this.currentThread.title.toUpperCase()}`);
}
breadcrumbContainer.innerHTML = crumb.join(' ');
}

renderForums(forums) {
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
class="forums-list">
${forums.map(forum => `
class="forum-item" data-forum-slug="${forum.slug}">
class="forum-icon">${forum.icon || '📁'}
class="forum-info">
class="forum-name">${forum.name}
${forum.description ? ` class="forum-description">${forum.description}` : ''}
class="forum-stats">
${forum.thread_count} threads · ${forum.post_count} posts



`).join('')}

`;
}

renderForum(data) {
const { forum, threads } = data;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
style="padding: 18px;">
class="button new-thread-button">NEW THREAD
style="clear: both;">
class="threads-list">
${threads.map(thread => `
class="thread-item ${thread.is_pinned ? 'pinned' : ''}" data-thread-slug="${thread.slug}">
class="thread-info">
class="thread-title">
${thread.title}
${thread.is_pinned ? 'PINNED' : ''}
${thread.is_locked ? 'LOCKED' : ''}

class="thread-meta">
Started by style="color:#00FFFF;font-weight:700">${thread.author.nick}
· ${this.formatDate(thread.created_at)}


class="thread-stats">
${thread.post_count} replies
${thread.view_count} views
${thread.last_post_author ? `
style="margin-top: 7px; font-size: 12pt;">
Last: style="color:#FF6200;">${thread.last_post_author.nick}

${this.formatDate(thread.last_post_at)}

` : ''}


`).join('')}

${data.hasMore ? `
class="pagination">
class="page-button" data-page="${data.page - 1}" ${data.page === 1 ? 'disabled' : ''}>Previous
class="page-button active">${data.page}
class="page-button" data-page="${data.page + 1}">Next

` : ''}

`;
}

renderThread(data) {
const { thread, forum, posts } = data;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
class="posts-list">
${posts.map(post => `
class="post" data-post-uid="${post.uid}">
class="post-author">
class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}

class="author-name">${post.author.nick}

class="post-content">
class="post-header">
Posted ${this.formatDate(post.created_at)}
${post.edited_at ? `· Edited ${this.formatDate(post.edited_at)}` : ''}

class="post-body">${this.escapeHtml(post.content)}
class="post-footer">
class="post-action like-button ${post.is_liked ? 'liked' : ''}">
${post.is_liked ? '❤️' : '🤍'}
class="like-count">${post.like_count}




`).join('')}

${!thread.is_locked ? `
class="reply-form">

Reply


id="reply-form">
class="form-group">
name="content" placeholder="Write your reply..." required>

type="submit" class="button">POST REPLY


` : `
class="reply-form">
style="text-align: center; color: #A9A9A9; font-size: 18pt;">This thread is locked.

`}
${data.hasMore ? `
class="pagination">
class="page-button" data-page="${data.page - 1}" ${data.page === 1 ? 'disabled' : ''}>Previous
class="page-button active">${data.page}
class="page-button" data-page="${data.page + 1}">Next

` : ''}
`;
const replyForm = this.shadowRoot.getElementById('reply-form');
if (replyForm) {
replyForm.addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
this.createPost(thread.uid, formData.get('content'));
e.target.reset();
});
}
}

addNewPost(post) {
const postsList = this.shadowRoot.querySelector('.posts-list');
if (!postsList) return;
const postHtml = `
class="post" data-post-uid="${post.uid}">
class="post-author">
class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}

class="author-name">${post.author.nick}

class="post-content">
class="post-header">
Posted ${this.formatDate(post.created_at)}

class="post-body">${this.escapeHtml(post.content)}
class="post-footer">
class="post-action like-button">
🤍
class="like-count">0




`;
postsList.insertAdjacentHTML('beforeend', postHtml);
}

updatePost(post) {
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${post.uid}"]`);
if (postEl) {
const bodyEl = postEl.querySelector('.post-body');
const headerEl = postEl.querySelector('.post-header');
if (bodyEl) bodyEl.textContent = post.content;
if (headerEl && post.edited_at) {
headerEl.innerHTML += ` · Edited ${this.formatDate(post.edited_at)}`;
}
}
}

removePost(postUid) {
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${postUid}"]`);
if (postEl) {
postEl.remove();
}
}

formatDate(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)} days ago`;
return date.toLocaleDateString();
}

escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
customElements.define('snek-forum', SnekForum);


window.preloadedForums = {{ forums_json|safe }};


{% endblock %}