This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "app.html" %}
{% block title %}Forum{% endblock %}
{% block header_text %}<nav class="breadcrumb" id="breadcrumb">
<!-- Breadcrumb will be rendered here -->
</nav>{% endblock %}
{% block main %}
<style>
snek-forum, .container {
height: 100%;
width: 90%;
background: transparent !important;
}
</style>
<script>
// 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() {
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(`
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<g>
${Array.from({length: 40}).map(() => {
const x = Math.random() * 100;
const y = Math.random() * 100;
const r = Math.random() * 1.4 + 0.2;
return `<circle cx="${x}vw" cy="${y}vh" r="${r}" fill="white" opacity="0.2" />`;
}).join('')}
</g>
</svg>
`);
this.shadowRoot.innerHTML = `
<style>
@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;
}
</style>
<div class="container">
<div class="content" id="main-content">
<!-- Content will be rendered here -->
</div>
</div>
<div class="modal" id="new-thread-modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">NEW THREAD</h2>
<button class="modal-close" type="button">&times;</button>
</div>
<form id="new-thread-form">
<div class="form-group">
<label>Title</label>
<input type="text" name="title" required minlength="5" maxlength="200" placeholder="Thread Title">
</div>
<div class="form-group">
<label>Content</label>
<textarea name="content" required minlength="1" placeholder="Write your post..."></textarea>
</div>
<button type="submit" class="button">CREATE THREAD</button>
</form>
</div>
</div>
`;
// 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() {
return;
const crumb = [];
crumb.push(`<a style="display: none;" href="#" data-action="forums">FORUMS</a>`);
if (this.currentView === "forum" && this.currentForum) {
crumb.push(`<span></span>`);
crumb.push(`<span>${this.currentForum.name.toUpperCase()}</span>`);
}
if (this.currentView === "thread" && this.currentThread && this.currentForum) {
crumb.push(`<span></span>`);
crumb.push(`<a href="#" data-action="forum" data-slug="${this.currentForum.slug}">${this.currentForum.name.toUpperCase()}</a>`);
crumb.push(`<span></span>`);
crumb.push(`<span>${this.currentThread.title.toUpperCase()}</span>`);
}
this.shadowRoot.getElementById('breadcrumb').innerHTML = crumb.join(' ');
}
renderForums(forums) {
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div class="forums-list">
${forums.map(forum => `
<div class="forum-item" data-forum-slug="${forum.slug}">
<div class="forum-icon">${forum.icon || '📁'}</div>
<div class="forum-info">
<div class="forum-name">${forum.name}</div>
${forum.description ? `<div class="forum-description">${forum.description}</div>` : ''}
<div class="forum-stats">
${forum.thread_count} threads · ${forum.post_count} posts
</div>
</div>
</div>
`).join('')}
</div>
`;
}
renderForum(data) {
const { forum, threads } = data;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div style="padding: 18px;">
<button class="button new-thread-button">NEW THREAD</button>
<div style="clear: both;"></div>
<div class="threads-list">
${threads.map(thread => `
<div class="thread-item ${thread.is_pinned ? 'pinned' : ''}" data-thread-slug="${thread.slug}">
<div class="thread-info">
<div class="thread-title">
${thread.title}
${thread.is_pinned ? '<span class="badge pinned">PINNED</span>' : ''}
${thread.is_locked ? '<span class="badge locked">LOCKED</span>' : ''}
</div>
<div class="thread-meta">
Started by <span style="color:#00FFFF;font-weight:700">${thread.author.nick}</span>
· ${this.formatDate(thread.created_at)}
</div>
</div>
<div class="thread-stats">
<div>${thread.post_count} replies</div>
<div>${thread.view_count} views</div>
${thread.last_post_author ? `
<div style="margin-top: 7px; font-size: 12pt;">
Last: <span style="color:#FF6200;">${thread.last_post_author.nick}</span><br>
${this.formatDate(thread.last_post_at)}
</div>
` : ''}
</div>
</div>
`).join('')}
</div>
${data.hasMore ? `
<div class="pagination">
<button class="page-button" data-page="${data.page - 1}" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<span class="page-button active">${data.page}</span>
<button class="page-button" data-page="${data.page + 1}">Next</button>
</div>
` : ''}
</div>
`;
}
renderThread(data) {
const { thread, forum, posts } = data;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div class="posts-list">
${posts.map(post => `
<div class="post" data-post-uid="${post.uid}">
<div class="post-author">
<div class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}
</div>
<div class="author-name">${post.author.nick}</div>
</div>
<div class="post-content">
<div class="post-header">
Posted ${this.formatDate(post.created_at)}
${post.edited_at ? `· Edited ${this.formatDate(post.edited_at)}` : ''}
</div>
<div class="post-body">${this.escapeHtml(post.content)}</div>
<div class="post-footer">
<span class="post-action like-button ${post.is_liked ? 'liked' : ''}">
<span>${post.is_liked ? '❤️' : '🤍'}</span>
<span class="like-count">${post.like_count}</span>
</span>
</div>
</div>
</div>
`).join('')}
</div>
${!thread.is_locked ? `
<div class="reply-form">
<h3>Reply</h3>
<form id="reply-form">
<div class="form-group">
<textarea name="content" placeholder="Write your reply..." required></textarea>
</div>
<button type="submit" class="button">POST REPLY</button>
</form>
</div>
` : `
<div class="reply-form">
<p style="text-align: center; color: #A9A9A9; font-size: 18pt;">This thread is locked.</p>
</div>
`}
${data.hasMore ? `
<div class="pagination">
<button class="page-button" data-page="${data.page - 1}" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<span class="page-button active">${data.page}</span>
<button class="page-button" data-page="${data.page + 1}">Next</button>
</div>
` : ''}
`;
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 = `
<div class="post" data-post-uid="${post.uid}">
<div class="post-author">
<div class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}
</div>
<div class="author-name">${post.author.nick}</div>
</div>
<div class="post-content">
<div class="post-header">
Posted ${this.formatDate(post.created_at)}
</div>
<div class="post-body">${this.escapeHtml(post.content)}</div>
<div class="post-footer">
<span class="post-action like-button">
<span>🤍</span>
<span class="like-count">0</span>
</span>
</div>
</div>
</div>
`;
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);
</script>
<snek-forum></snek-forum>
{% endblock %}