Add web templates and handlers for home, embed card, and admin pages

This commit is contained in:
2025-05-18 12:23:00 +03:30
parent 0854387eb4
commit 7659cca37e
7 changed files with 657 additions and 7 deletions

View File

@@ -3,6 +3,7 @@ package server
import ( import (
"CatsOfMastodonBotGo/internal/auth" "CatsOfMastodonBotGo/internal/auth"
"CatsOfMastodonBotGo/internal/web/handlers" "CatsOfMastodonBotGo/internal/web/handlers"
"net/http"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -18,17 +19,32 @@ func SetupRouter() *gin.Engine {
AllowCredentials: true, AllowCredentials: true,
})) }))
r.LoadHTMLGlob("internal/web/templates/**/*")
auth.InitJwtTokenGenerator() // Must be befor initializing admin handler, otherwise 'panic: runtime error: invalid memory address or nil pointer dereference' auth.InitJwtTokenGenerator() // Must be befor initializing admin handler, otherwise 'panic: runtime error: invalid memory address or nil pointer dereference'
handlers.InitAdminDashboardHandler() handlers.InitAdminDashboardHandler()
handlers.InitApiEndpointHandler() handlers.InitApiEndpointHandler()
handlers.InitEmbedCardHandler()
r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "home/index.html", nil)
})
r.GET("/embed", handlers.EmbedCardHandlerInstance.GetEmbedCard)
admin := r.Group("/admin") admin := r.Group("/admin")
// My man, this is done way more efficient and fast in .NET, specially the authentication part // My man, this is done way more efficient and fast in .NET, specially the authentication part
admin.POST("/login", handlers.AdminDashboardHandlerInstance.Login) admin.GET("/", func(c *gin.Context) {
admin.GET("/getmedia", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.GetMedia) c.HTML(http.StatusOK, "admin/index.html", nil)
admin.POST("/approve", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.ApproveMedia) })
admin.POST("/reject", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.RejectMedia) admin.GET("/login", func(c *gin.Context) {
c.HTML(http.StatusOK, "admin/login.html", nil)
})
adminApi := admin.Group("/api")
adminApi.POST("/login", handlers.AdminDashboardHandlerInstance.Login)
adminApi.GET("/getmedia", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.GetMedia)
adminApi.POST("/approve", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.ApproveMedia)
adminApi.POST("/reject", auth.JwtTokenGeneratorInstance.GinMiddleware(), handlers.AdminDashboardHandlerInstance.RejectMedia)
api := r.Group("/api") api := r.Group("/api")

View File

@@ -64,7 +64,6 @@ func (ps *PostService) GetExistingAccountIds() []string {
return existingAccountIds return existingAccountIds
} }
func (*PostService) GetNewPosts(existingPostIds []string, posts []domain.Post) []domain.Post { func (*PostService) GetNewPosts(existingPostIds []string, posts []domain.Post) []domain.Post {
var newPosts []domain.Post = nil var newPosts []domain.Post = nil
for _, post := range posts { for _, post := range posts {
@@ -108,7 +107,8 @@ func (ps *PostService) GetRandomPost() domain.Post {
var post domain.Post var post domain.Post
ps.db. ps.db.
Preload("Account"). Preload("Account").
Preload("Attachments", "approved = ?", true). Preload("Attachments", "Approved = ?", true).
Where("exists (select 1 from media_attachments where post_id = posts.id and approved = ?)", true).
Order("RANDOM()"). Order("RANDOM()").
First(&post) First(&post)
if len(post.Attachments) > 0 { if len(post.Attachments) > 0 {
@@ -140,7 +140,6 @@ func (ps *PostService) GetMedia() domain.MediaAttachment {
return media return media
} }
func arrayContains(arr []string, str string) bool { func arrayContains(arr []string, str string) bool {
for _, a := range arr { for _, a := range arr {
if a == str { if a == str {

View File

@@ -0,0 +1,27 @@
package handlers
import (
"CatsOfMastodonBotGo/internal/services"
"github.com/gin-gonic/gin"
)
type EmbedCardHandler struct {
PostService services.PostService
}
var EmbedCardHandlerInstance *EmbedCardHandler
func InitEmbedCardHandler() {
EmbedCardHandlerInstance = &EmbedCardHandler{
PostService: *services.PostServiceInstance,
}
}
func (ps *EmbedCardHandler) GetEmbedCard(c *gin.Context) {
post := ps.PostService.GetRandomPost()
c.HTML(200, "home/embed.html", gin.H{
"postUrl": post.Url,
"imageUrl": post.Attachments[0].PreviewUrl,
})
}

View File

@@ -0,0 +1,156 @@
{{ define "admin/index.html" }}
<!DOCTYPE html>
<html>
<head>
<title>Media Approval</title>
<style>
body {
font-family: sans-serif;
background-color: #f9f9f9;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 300px;
position: relative;
}
.card img {
max-width: 100%;
border-radius: 4px;
margin-bottom: 15px;
}
button {
margin: 5px;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
color: white;
}
.approve { background-color: #4CAF50; }
.reject { background-color: #f44336; }
.message {
position: absolute;
top: -25px;
left: 0;
right: 0;
text-align: center;
font-size: 14px;
color: #4CAF50;
display: none;
}
</style>
</head>
<body>
<div class="card" id="media-card">
<div class="message" id="status-msg"></div>
<img id="preview" src="" alt="Media Preview" onerror="loadRemote()" />
<div>
<button class="approve" onclick="sendAction('approve')">Approve</button>
<button class="reject" onclick="sendAction('reject')">Reject</button>
</div>
</div>
<script>
let currentMedia = null;
let nextMedia = null;
let preloadedImage = new Image();
window.onload = async () => {
const token = getToken();
if (!token) {
alert("No token found. Please login first.");
window.location.href = "login";
return;
}
currentMedia = await fetchMedia(token);
showMedia(currentMedia);
preloadNextMedia(token);
};
function getToken() {
const match = document.cookie.match(/(^| )token=([^;]+)/);
return match ? match[2] : null;
}
function showMedia(media) {
const preview = document.getElementById('preview');
preview.src = media.preview_url;
}
function loadRemote() {
if (currentMedia?.remote_url) {
document.getElementById('preview').src = currentMedia.remote_url;
}
}
async function sendAction(action) {
if (!currentMedia?.id) return;
const token = getToken();
const endpoint = `admin/api/${action}`;
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({ mediaId: currentMedia.id })
});
showMessage(action.charAt(0).toUpperCase() + action.slice(1) + " successful");
// Show next media
if (nextMedia) {
currentMedia = nextMedia;
showMedia(currentMedia);
preloadNextMedia(token);
} else {
document.getElementById('media-card').innerHTML = 'No more media.';
}
}
function showMessage(text) {
const msgDiv = document.getElementById('status-msg');
msgDiv.innerText = text;
msgDiv.style.display = 'block';
setTimeout(() => {
msgDiv.style.display = 'none';
}, 500);
}
async function fetchMedia(token) {
const res = await fetch('admin/api/getmedia', {
method: 'GET',
headers: { 'Authorization': 'Bearer ' + token }
});
return await res.json();
}
async function preloadNextMedia(token) {
try {
nextMedia = await fetchMedia(token);
preloadedImage.src = nextMedia.preview_url;
preloadedImage.onerror = () => {
preloadedImage.src = nextMedia.remote_url;
};
} catch (err) {
console.error("Failed to preload next media:", err);
nextMedia = null;
}
}
</script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,92 @@
{{ define "admin/login.html" }}
<!DOCTYPE html>
<html>
<head>
<title>Simple Login Page</title>
<style>
body {
font-family: sans-serif;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.login-container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
text-align: center;
}
input[type="password"] {
padding: 10px;
margin: 10px 0;
border: 1px solid #ccc;
border-radius: 4px;
width: 200px;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #3e8e41;
}
#message {
margin-top: 10px;
font-weight: bold;
}
</style>
</head>
<body>
<div class="login-container">
<h2>Login</h2>
<input type="password" id="password" placeholder="Password"><br>
<button onclick="login()">Login</button>
<div id="message"></div>
</div>
<script>
function login() {
const password = document.getElementById('password').value;
fetch('admin/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ "Password": password })
})
.then(response => response.json())
.then(data => {
if (data.message === "Login successful") {
document.getElementById('message').innerText = data.message;
saveToken(data.token);
setTimeout(() => {
window.location.href = "index.html";
}, 1000);
} else {
document.getElementById('message').innerText = "Login failed.";
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('message').innerText = "An error occurred.";
});
}
function saveToken(token) {
document.cookie = "token=" + token + "; path=/";
}
</script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,62 @@
{{ define "home/embed.html" }}
<html lang='en'>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cat of the Day</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Atma:wght@300;400;500;600;700&display=swap');
.card {
background-color: #161616;
border-radius: 15px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
padding: 20px;
width: 90%;
max-width: 400px;
text-align: center;
transition: transform 0.2s ease-in-out;
font-family: "Atma", system-ui;
}
.cat-image {
width: 100%;
border-radius: 5px;
margin-bottom: 15px;
object-fit: cover;
max-height: auto;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
transition: opacity 0.3s ease;
}
.button {
background-color: #9e44ae;
color: #e0e0e0;
border: none;
padding: 2px 15px;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
box-sizing: border-box;
transition: background-color 0.3s ease, transform 0.2s ease-in-out;
font-weight: 500;
}
.button:hover {
background-color: #6d0e7e;
transform: scale(1.01);
}
</style>
</head>
<body>
<div class="card" style="margin: 0 auto;">
<img class="cat-image" id="catImage" src="{{ .imageUrl }}" alt="Cat Photo">
<a class="button" id="mastodonLink" href="{{ .postUrl }}" target="_blank" style="display: block;">View on Mastodon</a>
</div>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,298 @@
{{ define "home/index.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cats of Mastodon! - Mahdium</title><style>
@import url('https://fonts.googleapis.com/css2?family=Atma:wght@300;400;500;600;700&display=swap');
body {
font-family: "Atma", system-ui;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #1e1e1e;
color: #e0e0e0;
margin: 0;
overflow-x: hidden;
}
.mastodon-title {
font-size: 2.5em;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
text-align: center;
}
.welcome-message {
margin-bottom: 20px;
font-size: 1.2em;
line-height: 1.5;
text-align: center;
}
.welcome-message span {
font-size: 1.5em;
}
.post-container {
background-color: #282828;
border-radius: 15px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
padding: 20px;
width: 90%;
max-width: 500px;
text-align: center;
transition: transform 0.2s ease-in-out;
margin-bottom: 20px;
}
.post-container:hover {
transform: scale(1.02);
}
.post-title-inner {
margin-bottom: 10px;
font-size: 1.2em;
}
.user-name {
color: #ffb347;
font-weight: bold;
font-size: 1.2em;
}
.cat-text {
color: #bbdefb;
font-style: italic;
}
.post-image {
width: 100%;
border-radius: 10px;
margin-bottom: 15px;
object-fit: cover;
max-height: 500px;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
transition: opacity 0.3s ease;
}
.post-image.loading {
opacity: 0;
}
.image-container {
position: relative;
display: inline-block;
}
.image-loading-spinner {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 4px solid #f3f3f3;
border-top: 4px solid #6364ff;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
.post-image.loading + .image-loading-spinner {
display: block;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.button-container {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.mastodon-button,
.neutral-button {
background-color: #404040;
color: #e0e0e0;
border: none;
padding: 12px 18px;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
width: 100%;
box-sizing: border-box;
transition: background-color 0.3s ease, transform 0.2s ease-in-out;
font-weight: 500;
}
.mastodon-button {
background-color: #6364ff;
}
.mastodon-button:hover {
background-color: #5253e0;
transform: scale(1.03);
}
.neutral-button:hover {
background-color: #505050;
transform: scale(1.03);
}
.loading-spinner {
display: none;
border: 6px solid #f3f3f3;
border-top: 6px solid #6364ff;
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
.post-container.loading .loading-spinner {
display: block;
}
.post-container.loading .post-image,
.post-container.loading .button-container,
.post-container.loading .post-title-inner {
display: none;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
font-size: 0.8em;
color: #909090;
width: 90%;
max-width: 500px;
margin: 0 auto;
}
.footer a {
color: inherit;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.footer-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
}
</style>
</head>
<body>
<h2 class="mastodon-title">Cats of Mastodon!</h2>
<p class="welcome-message">Welcome to Daily Catventures! </br> Get your daily dose of purr-fectly adorable feline fun! <span></span><br>Posts gathered across Mastodon 🤝</p>
<div class="post-container">
<div class="post-content" style="display: none;">
<div class="post-title-inner">
<span class="user-name"></span><span class="cat-text">'s cat!</span>
</div>
<div class="image-container">
<img class="post-image" src="" alt="Cat Photo">
<div class="image-loading-spinner"></div>
</div>
<div class="button-container">
<a class="mastodon-button" href="" target="_blank">View on Mastodon</a>
<button class="neutral-button" onclick="loadNewPost()">Show me another cat!</button>
</div>
</div>
<div class="loading-spinner"></div>
</div>
<div class="footer">
<span>© 2024 Mahdium</span>
<a href="https://mahdium.ir" class="footer-button">mahdium.ir</a>
<a href="https://gitlab.com/mahdium/cats-of-mastodon-telegram-bot" class="footer-button">Source Code</a>
</div>
<script>
const postContainer = document.querySelector('.post-container');
const postContent = document.querySelector('.post-content');
const userNameSpan = document.querySelector('.post-content .user-name');
const postImage = document.querySelector('.post-content .post-image');
const mastodonLink = document.querySelector('.post-content .mastodon-button');
const imageLoadingSpinner = document.querySelector('.image-container .image-loading-spinner');
function loadNewPost() {
postContainer.classList.add('loading');
postContent.style.display = 'none';
postImage.classList.add('loading');
fetch('/api/post/random')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
postContainer.classList.remove('loading');
postContent.style.display = 'block';
if (!data || !data.account || !data.media_attachments || data.media_attachments.length === 0) {
console.error("Invalid API response format:", data);
alert("Invalid data received from the server.");
postImage.classList.remove('loading');
return;
}
userNameSpan.textContent = data.account.display_name;
let imageUrl = data.media_attachments[0].remote_url;
if (imageUrl) {
imageUrl = imageUrl.replace('/original/', '/small/');
} else if (data.media_attachments[0].PreviewUrl) {
imageUrl = data.media_attachments[0].PreviewUrl;
} else {
console.warn("No RemoteUrl or PreviewUrl found, using placeholder");
postImage.src = "https://s6.uupload.ir/files/a69d5fc9e900cc51_1920_kmnr.png";
postImage.classList.remove('loading');
return;
}
postImage.onload = () => {
postImage.classList.remove('loading');
window.scrollTo(0, document.body.scrollHeight);
};
postImage.onerror = () => {
console.error("Error loading image:", imageUrl);
postImage.src = "https://s6.uupload.ir/files/a69d5fc9e900cc51_1920_kmnr.png";
loadNewPost();
postImage.classList.remove('loading');
};
postImage.src = imageUrl;
mastodonLink.href = data.url;
})
.catch(error => {
postContainer.classList.remove('loading');
postImage.classList.remove('loading');
console.error("Error fetching data:", error);
alert("Failed to load a new post. Please try again later.");
});
}
loadNewPost();
</script>
</body>
</html>
{{ end }}