aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-07-17 18:47:30 +0530
committerBobby <[email protected]>2025-07-17 18:47:30 +0530
commit8df8cdd7e1bdefded59d073c14aa74666740be8c (patch)
tree0af31475e1a5ffecd04a9fbce3faf5cd6bf80140
parent3c39a30a53656fa1c51afb30bb8c07f862bd39e2 (diff)
downloadimageboard-8df8cdd7e1bdefded59d073c14aa74666740be8c.tar.xz
imageboard-8df8cdd7e1bdefded59d073c14aa74666740be8c.zip
tags and ratings filter in posts, uploads progress
-rw-r--r--config/constants.go2
-rw-r--r--controllers/posts.go111
-rw-r--r--database/images.go10
-rw-r--r--database/posts.go33
-rw-r--r--imageboard/main.go1
-rw-r--r--models/image.go2
-rw-r--r--models/tags.go2
-rw-r--r--router/routes.go1
-rw-r--r--static/css/main.css16
-rw-r--r--static/scripts/upload.js175
-rw-r--r--templates/partials/search.django8
-rw-r--r--templates/posts/list.django2
-rw-r--r--templates/posts/single.django10
-rw-r--r--utils/format/image.go14
-rw-r--r--utils/format/numbers.go9
-rw-r--r--utils/handlers/req_map.go57
16 files changed, 382 insertions, 71 deletions
diff --git a/config/constants.go b/config/constants.go
index 8f7a91a..05d6f43 100644
--- a/config/constants.go
+++ b/config/constants.go
@@ -10,12 +10,14 @@ const (
PT_REGISTER = "Register"
PT_404 = "Page Not Found"
PT_VERIFY_EMAIL = "Verify Email"
+ PT_POST_SINGLE = "Post"
// Template names
TEMPLATE_HOME = "home"
TEMPLATE_LOGIN = "login"
TEMPLATE_POST_LIST = "posts/list"
TEMPLATE_POST_NEW = "posts/new"
+ TEMPLATE_POST_SINGLE = "posts/single"
TEMPLATE_PREFERENCES = "preferences"
TEMPLATE_REGISTER = "register"
TEMPLATE_404 = "404"
diff --git a/controllers/posts.go b/controllers/posts.go
index 7e4eae3..11525b8 100644
--- a/controllers/posts.go
+++ b/controllers/posts.go
@@ -5,10 +5,12 @@ import (
"imageboard/database"
"imageboard/utils/auth"
"imageboard/utils/format"
+ "imageboard/utils/handlers"
"imageboard/utils/minio"
"imageboard/utils/shortcuts"
"imageboard/utils/transformers"
"io"
+ "log"
"net/http"
"strings"
@@ -32,36 +34,19 @@ func PostsPageController(ctx *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Invalid request type")
}
- queryTags := ""
- queryRatings := map[string]bool{}
- for _, param := range request.Query {
- switch param.Key {
- case "tags":
- queryTags = param.Value
- case "rating":
- queryRatings[param.Value] = true
- }
- }
-
- if len(queryRatings) == 0 {
- for _, rating := range []string{"safe", "questionable", "sensitive"} {
- queryRatings[rating] = true
- }
- }
-
- posts, err := database.GetPosts(preferences.PostsPerPage)
+ queryTags, queryTagsList := handlers.ExtractQueryTags(request.Query)
+ queryRatings, queryRatingsMap := handlers.ExtractRatingsAndMap(request.Query)
- cdnURL := strings.TrimRight(config.S3.PublicURL, "/") + "/" + config.S3.BucketName
- if config.S3.FolderPath != "" {
- cdnURL += "/" + config.S3.FolderPath
+ posts, err := database.GetPosts(preferences.PostsPerPage, queryRatings, queryTagsList)
+ if err != nil {
+ log.Println(err)
}
return shortcuts.Render(ctx, config.TEMPLATE_POST_LIST, fiber.Map{
"Posts": posts,
- "Error": err,
"QueryTags": queryTags,
- "QueryRatings": queryRatings,
- "CDNURL": cdnURL,
+ "QueryRatings": queryRatingsMap,
+ "CDNURL": format.GetCDNURL(),
})
}
@@ -168,7 +153,18 @@ func PostsUploadPostController(ctx *fiber.Ctx) error {
md5Hash := transformers.GenerateMD5Hash(imageData)
- dbImage, err := database.CreateImage(
+ tx := database.DB.Begin()
+ defer func() {
+ if r := recover(); r != nil {
+ tx.Rollback()
+ }
+ }()
+
+ if tx.Error != nil {
+ return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction: "+tx.Error.Error())
+ }
+
+ dbImage, err := database.CreateImageWithTx(tx,
fileName,
contentType,
md5Hash,
@@ -178,26 +174,51 @@ func PostsUploadPostController(ctx *fiber.Ctx) error {
currentUser.PostsRequireApproval,
)
if err != nil {
+ tx.Rollback()
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create image record: "+err.Error())
}
+ type imageSizeData struct {
+ sizeType config.ImageSizeType
+ width int
+ height int
+ fileSize int64
+ }
+ var imageSizes []imageSizeData
+
for _, sizeType := range isizeArray {
- width, height, fileSize, imageData, err := transformers.TransformImageToVariant(decodedImage, sizeType)
+ width, height, fileSize, processedImageData, err := transformers.TransformImageToVariant(decodedImage, sizeType)
if err != nil {
+ tx.Rollback()
return fiber.NewError(fiber.StatusInternalServerError, "Failed to process image: "+err.Error())
}
- err = minio.UploadImage(imageData, sizeType, fileName, contentType)
+ err = minio.UploadImage(processedImageData, sizeType, fileName, contentType)
if err != nil {
+ tx.Rollback()
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload image: "+err.Error())
}
- _, err = database.CreateImageSize(dbImage.ID, sizeType, width, height, fileSize)
+ imageSizes = append(imageSizes, imageSizeData{
+ sizeType: sizeType,
+ width: width,
+ height: height,
+ fileSize: fileSize,
+ })
+ }
+
+ for _, sizeData := range imageSizes {
+ _, err = database.CreateImageSizeWithTx(tx, dbImage.ID, sizeData.sizeType, sizeData.width, sizeData.height, sizeData.fileSize)
if err != nil {
+ tx.Rollback()
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create image size record: "+err.Error())
}
}
+ if err := tx.Commit().Error; err != nil {
+ return fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction: "+err.Error())
+ }
+
return ctx.SendStatus(fiber.StatusOK)
}
@@ -250,3 +271,37 @@ func PostsUploadImageLinkProxyController(ctx *fiber.Ctx) error {
}
return ctx.Send(buf)
}
+
+func renderSinglePostError(ctx *fiber.Ctx, errorMsg string, statusCode int) error {
+ return shortcuts.RenderWithStatus(ctx, config.TEMPLATE_POST_SINGLE, fiber.Map{
+ "Error": errorMsg,
+ }, statusCode)
+}
+
+func PostsSinglePostPageController(ctx *fiber.Ctx) error {
+ ctx.Locals("Title", config.PT_POST_SINGLE)
+
+ postID := ctx.Params("id")
+ if postID == "" {
+ return renderSinglePostError(ctx, "Post ID is required", fiber.StatusBadRequest)
+ }
+
+ uintPostID, err := format.StringToUint(postID)
+ if err != nil {
+ return renderSinglePostError(ctx, "Invalid Post ID", fiber.StatusBadRequest)
+ }
+
+ post, err := database.GetPostByID(uintPostID)
+ if err != nil {
+ if err.Error() == "record not found" {
+ return renderSinglePostError(ctx, "Post not found", fiber.StatusNotFound)
+ }
+ return renderSinglePostError(ctx, "Failed to retrieve post. "+err.Error(), fiber.StatusInternalServerError)
+ }
+
+ ctx.Locals("Title", config.PT_POST_SINGLE+" #"+format.Int64ToString(int64(post.ID)))
+ return shortcuts.Render(ctx, config.TEMPLATE_POST_SINGLE, fiber.Map{
+ "Post": post,
+ "CDNURL": format.GetCDNURL(),
+ })
+}
diff --git a/database/images.go b/database/images.go
index 35efa7b..95b5339 100644
--- a/database/images.go
+++ b/database/images.go
@@ -6,6 +6,8 @@ import (
"imageboard/utils/format"
"imageboard/utils/transformers"
"time"
+
+ "gorm.io/gorm"
)
func GetTotalPostsCount() (int64, error) {
@@ -35,7 +37,7 @@ func GetTotalStorageSize() (string, error) {
return format.FileSize(totalSize), nil
}
-func CreateImage(fileName, contentType, md5Hash, sourceURL, rating string, uploaderID uint, requiresApproval bool) (*models.Image, error) {
+func CreateImageWithTx(tx *gorm.DB, fileName, contentType, md5Hash, sourceURL, rating string, uploaderID uint, requiresApproval bool) (*models.Image, error) {
ratingEnum, err := transformers.ConvertStringRatingToType(rating)
if err != nil {
return nil, err
@@ -56,14 +58,14 @@ func CreateImage(fileName, contentType, md5Hash, sourceURL, rating string, uploa
IsApproved: !requiresApproval,
}
- if err := DB.Create(&image).Error; err != nil {
+ if err := tx.Create(&image).Error; err != nil {
return nil, err
}
return &image, nil
}
-func CreateImageSize(imageID uint, sizeType config.ImageSizeType, width, height int, fileSize int64) (*models.ImageSize, error) {
+func CreateImageSizeWithTx(tx *gorm.DB, imageID uint, sizeType config.ImageSizeType, width, height int, fileSize int64) (*models.ImageSize, error) {
imageSize := models.ImageSize{
ImageID: imageID,
SizeType: sizeType,
@@ -72,7 +74,7 @@ func CreateImageSize(imageID uint, sizeType config.ImageSizeType, width, height
FileSize: fileSize,
}
- if err := DB.Create(&imageSize).Error; err != nil {
+ if err := tx.Create(&imageSize).Error; err != nil {
return nil, err
}
diff --git a/database/posts.go b/database/posts.go
index dd2a74e..1aeaecd 100644
--- a/database/posts.go
+++ b/database/posts.go
@@ -1,11 +1,38 @@
package database
-import "imageboard/models"
+import (
+ "imageboard/config"
+ "imageboard/models"
+)
-func GetPosts(limit int) ([]models.Image, error) {
+func GetPosts(limit int, ratings []config.Rating, tags []string) ([]models.Image, error) {
var posts []models.Image
- if err := DB.Preload("Sizes").Preload("Uploader").Preload("Tags").Limit(limit).Find(&posts).Error; err != nil {
+ query := DB.Preload("Sizes").Preload("Uploader").Limit(limit).Order("created_at DESC")
+
+ if len(ratings) > 0 {
+ query = query.Where("rating IN ?", ratings)
+ }
+
+ if len(tags) > 0 {
+ query = query.Joins("JOIN image_tags ON images.id = image_tags.image_id").
+ Joins("JOIN tags ON image_tags.tag_id = tags.id").
+ Where("tags.name IN ?", tags).
+ Group("images.id").
+ Preload("Tags")
+ } else {
+ query = query.Preload("Tags")
+ }
+
+ if err := query.Find(&posts).Error; err != nil {
return nil, err
}
return posts, nil
}
+
+func GetPostByID(postID uint) (*models.Image, error) {
+ var post models.Image
+ if err := DB.Preload("Sizes").Preload("Uploader").Preload("Tags").First(&post, postID).Error; err != nil {
+ return nil, err
+ }
+ return &post, nil
+}
diff --git a/imageboard/main.go b/imageboard/main.go
index 207b1aa..8e6fef5 100644
--- a/imageboard/main.go
+++ b/imageboard/main.go
@@ -29,6 +29,7 @@ func main() {
app := fiber.New(fiber.Config{
Views: engine,
ErrorHandler: handlers.ServerErrorHandler,
+ BodyLimit: 2 * config.Upload.MaxSize,
})
app.Use(recover.New())
diff --git a/models/image.go b/models/image.go
index 03f6d0f..d902ec7 100644
--- a/models/image.go
+++ b/models/image.go
@@ -75,7 +75,7 @@ type Image struct {
FavouriteCount int64 `gorm:"not null;default:0" json:"favorite_count"`
CommentCount int64 `gorm:"not null;default:0" json:"comment_count"`
Sizes []ImageSize `gorm:"foreignKey:ImageID" json:"sizes,omitempty"`
- Tags []Tag `gorm:"many2many:image_tags" json:"tags,omitempty"`
+ Tags []Tag `gorm:"many2many:image_tags;joinForeignKey:image_id;joinReferences:tag_id" json:"tags,omitempty"`
FavoritedBy []User `gorm:"many2many:user_favorites" json:"favorited_by,omitempty"`
Comments []Comment `gorm:"foreignKey:ImageID" json:"comments,omitempty"`
}
diff --git a/models/tags.go b/models/tags.go
index 83f0735..c3c9063 100644
--- a/models/tags.go
+++ b/models/tags.go
@@ -19,7 +19,7 @@ type Tag struct {
ParentID *uint `gorm:"index" json:"-"`
Parent *Tag `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Children []Tag `gorm:"foreignKey:ParentID" json:"children,omitempty"`
- Images []Image `gorm:"many2many:image_tags" json:"images,omitempty"`
+ Images []Image `gorm:"many2many:image_tags;joinForeignKey:tag_id;joinReferences:image_id" json:"images,omitempty"`
}
func (t *Tag) BeforeCreate(tx *gorm.DB) error {
diff --git a/router/routes.go b/router/routes.go
index 586f970..bac5237 100644
--- a/router/routes.go
+++ b/router/routes.go
@@ -15,6 +15,7 @@ func Initialize(router *fiber.App) {
posts.Get("/new", controllers.PostsUploadPageController)
posts.Post("/new", controllers.PostsUploadPostController)
posts.Get("/new/ilinkfetch", controllers.PostsUploadImageLinkProxyController)
+ posts.Get("/:id", controllers.PostsSinglePostPageController)
login := router.Group("/login")
login.Get("/", controllers.LoginPageController)
diff --git a/static/css/main.css b/static/css/main.css
index 685a24e..f253f7c 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -759,6 +759,22 @@ footer::before {
color: #ff6b6b;
}
+.post-rating.Safe {
+ color: #4caf50;
+}
+
+.post-rating.Questionable {
+ color: #ff9800;
+}
+
+.post-rating.Sensitive {
+ color: #9c27b0;
+}
+
+.post-rating.Explicit {
+ color: #f44336;
+}
+
.post-tags {
word-wrap: break-word;
max-height: 40px;
diff --git a/static/scripts/upload.js b/static/scripts/upload.js
index d190014..fe4c4ef 100644
--- a/static/scripts/upload.js
+++ b/static/scripts/upload.js
@@ -289,12 +289,10 @@ function setupLocalImageUpload() {
* @param {string} url
*/
function handleDroppedUrl(url) {
- // Accept direct image URLs or blob/data URLs
const imageExt = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i;
if (imageExt.test(url) || url.startsWith('blob:') || url.startsWith('data:image/')) {
uploadViaLinkDirect(url);
} else {
- // Try to fetch and see if it's an image
fetch(url, { method: 'HEAD' })
.then(resp => {
const type = resp.headers.get('content-type') || '';
@@ -388,40 +386,34 @@ async function uploadAllImages() {
isUploading = true;
disableAllRemoveButtons();
- // Disable the upload button and show progress
- uploadAllBtn.disabled = true;
- updateUploadButtonText(uploadedCount, totalImages, false);
-
- try {
+ // Disable the upload button
+ uploadAllBtn.disabled = true; try {
+ let currentImageIndex = 1;
for (const [key, imageData] of imageBlobMapping) {
try {
- // Get the selected rating for this image
+ updateUploadButtonText(currentImageIndex, totalImages, false);
const selectedRating = getSelectedRating(key);
imageData.rating = selectedRating;
-
- // Create and submit form for this image
+ showImageProgress(imageData.previewElement);
await uploadSingleImage(imageData, selectedRating);
-
- // Mark this image as successfully uploaded
markImageAsUploaded(imageData.previewElement);
uploadedCount++;
- updateUploadButtonText(uploadedCount, totalImages, false);
-
+ currentImageIndex++;
} catch (error) {
console.error(`Failed to upload image ${key}:`, error);
markImageAsError(imageData.previewElement, error.message);
hasErrors = true;
+ currentImageIndex++;
}
}
- // Update button text based on results
if (hasErrors) {
- updateUploadButtonText(uploadedCount, totalImages, true);
+ uploadAllBtn.textContent = `⚠ Uploaded ${uploadedCount}/${totalImages} (some failed)`;
+ uploadAllBtn.className = 'upload-all-btn warning';
+ uploadAllBtn.disabled = false;
} else {
uploadAllBtn.textContent = `✓ All ${totalImages} images uploaded successfully!`;
uploadAllBtn.className = 'upload-all-btn success';
-
- // Clear all uploaded images after a delay
setTimeout(() => {
clearAllUploadedImages();
}, 2000);
@@ -433,7 +425,6 @@ async function uploadAllImages() {
uploadAllBtn.className = 'upload-all-btn error';
uploadAllBtn.disabled = false;
} finally {
- // Reset upload state
isUploading = false;
enableAllRemoveButtons();
}
@@ -479,26 +470,33 @@ function getSelectedRating(key) {
*/
async function uploadSingleImage(imageData, rating) {
const formData = new FormData();
+ const fileSize = imageData.blob.size;
+ const estimatedDuration = estimateUploadDuration(fileSize);
+
+ const progressBar = imageData.previewElement.nextElementSibling?.querySelector('.upload-progress-bar');
+ if (progressBar) {
+ animateProgress(progressBar, estimatedDuration);
+ }
- // Add the image blob to the form
if (imageData.type === 'local') {
formData.append('image', imageData.blob, imageData.nameOrUrl);
} else {
- // For link images, create a File object from the blob
const file = new File([imageData.blob], 'image.jpg', { type: imageData.blob.type });
formData.append('image', file);
formData.append('source_url', imageData.nameOrUrl);
}
- // Add the rating
formData.append('rating', rating);
- // Submit to the backend
const response = await fetch('/posts/new', {
method: 'POST',
body: formData
});
+ if (progressBar) {
+ completeProgress(progressBar);
+ }
+
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(errorText || `Upload failed with status ${response.status}`);
@@ -509,20 +507,21 @@ async function uploadSingleImage(imageData, rating) {
/**
* Updates the upload button text to show progress.
- * @param {number} current - Current number of uploaded images
+ * @param {number} current - Current image being uploaded (1-based)
* @param {number} total - Total number of images to upload
* @param {boolean} hasErrors - Whether there were errors during upload
*/
function updateUploadButtonText(current, total, hasErrors) {
if (hasErrors) {
- uploadAllBtn.textContent = `⚠ Uploaded ${current}/${total} (some failed)`;
+ const uploadedCount = current - 1;
+ uploadAllBtn.textContent = `⚠ Uploaded ${uploadedCount}/${total} (some failed)`;
uploadAllBtn.className = 'upload-all-btn warning';
uploadAllBtn.disabled = false;
- } else if (current === total) {
+ } else if (current > total) {
uploadAllBtn.textContent = `✓ All ${total} images uploaded!`;
uploadAllBtn.className = 'upload-all-btn success';
} else {
- uploadAllBtn.textContent = `⏳ Uploading (${current}/${total})`;
+ uploadAllBtn.textContent = `⏳ Uploading image ${current}/${total}`;
uploadAllBtn.className = 'upload-all-btn uploading';
}
}
@@ -533,6 +532,7 @@ function updateUploadButtonText(current, total, hasErrors) {
*/
function markImageAsUploaded(previewElement) {
previewElement.classList.add('uploaded');
+
const removeBtn = previewElement.querySelector('.preview-remove-btn');
if (removeBtn) {
removeBtn.textContent = '✓ Uploaded';
@@ -548,6 +548,12 @@ function markImageAsUploaded(previewElement) {
*/
function markImageAsError(previewElement, errorMessage) {
previewElement.classList.add('upload-error');
+
+ const progressContainer = previewElement.nextElementSibling;
+ if (progressContainer && progressContainer.classList.contains('upload-progress')) {
+ progressContainer.style.display = 'none';
+ }
+
const removeBtn = previewElement.querySelector('.preview-remove-btn');
if (removeBtn) {
removeBtn.textContent = '✗ Failed';
@@ -570,7 +576,6 @@ function clearAllUploadedImages() {
});
updateUploadAllBtn();
- // Reset button state if all images are cleared
if (imageBlobMapping.size === 0) {
if (uploadAllBtn) {
uploadAllBtn.textContent = 'Upload All';
@@ -588,11 +593,123 @@ function clearAllUploadedImages() {
function getImageKeyFromElement(previewElement) {
const radioInput = previewElement.querySelector('input[type="radio"]');
if (radioInput && radioInput.name) {
- // Extract key from "rating-{key}" format
const match = radioInput.name.match(/^rating-(.+)$/);
return match ? match[1] : null;
}
return null;
}
+/**
+ * Shows a progress bar for an image being uploaded.
+ * @param {HTMLElement} previewElement - The preview element
+ */
+function showImageProgress(previewElement) {
+ const existingProgress = previewElement.nextElementSibling;
+ if (existingProgress && existingProgress.classList.contains('upload-progress')) {
+ existingProgress.remove();
+ }
+
+ const progressContainer = document.createElement('div');
+ progressContainer.className = 'upload-progress';
+ progressContainer.style.cssText = `
+ width: 100%;
+ height: 4px;
+ overflow: hidden;
+ margin-top: -16px;
+ margin-bottom: 16px;
+ `;
+
+ const progressBar = document.createElement('div');
+ progressBar.className = 'upload-progress-bar';
+ progressBar.style.cssText = `
+ height: 100%;
+ width: 0%;
+ background-color: #4a9eff;
+ `;
+
+ progressContainer.appendChild(progressBar);
+ previewElement.parentNode.insertBefore(progressContainer, previewElement.nextSibling);
+
+ return progressBar;
+}
+
+/**
+ * Estimates upload duration based on file size and creates realistic progress.
+ * @param {number} fileSize - File size in bytes
+ * @returns {number} Estimated duration in milliseconds
+ */
+function estimateUploadDuration(fileSize) {
+ const sizeMB = fileSize / (1024 * 1024);
+ const baseTime = 2000;
+ const timePerMB = 6500;
+ const randomFactor = 0.5 + Math.random();
+
+ return Math.max(3000, baseTime + (sizeMB * timePerMB * randomFactor));
+}
+
+/**
+ * Animates progress bar with realistic timing based on file size.
+ * @param {HTMLElement} progressBar - The progress bar element
+ * @param {number} duration - Duration in milliseconds
+ * @returns {Promise<void>}
+ */
+function animateProgress(progressBar, duration) {
+ let progress = 0;
+ const startTime = Date.now();
+ let animationId;
+ let isCompleted = false;
+
+ const updateProgress = () => {
+ if (isCompleted) return;
+
+ const elapsed = Date.now() - startTime;
+ const timeRatio = elapsed / (duration * 4);
+
+ let targetProgress;
+ if (timeRatio < 0.2) {
+ targetProgress = (timeRatio / 0.2) * 0.05;
+ } else if (timeRatio < 0.8) {
+ const middleProgress = (timeRatio - 0.2) / 0.6;
+ targetProgress = 0.05 + (middleProgress * 0.85);
+ } else {
+ const endProgress = (timeRatio - 0.8) / 0.2;
+ const slowEnd = Math.sqrt(endProgress);
+ targetProgress = 0.90 + (slowEnd * 0.099);
+ }
+
+ targetProgress = Math.max(0, Math.min(targetProgress, 0.999));
+ progress = Math.max(progress, targetProgress);
+ progressBar.style.width = `${progress * 100}%`;
+
+ animationId = requestAnimationFrame(updateProgress);
+ };
+
+ animationId = requestAnimationFrame(updateProgress);
+
+ progressBar._completeAnimation = () => {
+ isCompleted = true;
+ if (animationId) {
+ cancelAnimationFrame(animationId);
+ }
+ };
+}
+
+/**
+ * Completes the progress bar animation to 100%
+ * @param {HTMLElement} progressBar - The progress bar element
+ */
+function completeProgress(progressBar) {
+ if (progressBar._completeAnimation) {
+ progressBar._completeAnimation();
+ }
+ progressBar.style.width = '100%';
+
+ setTimeout(() => {
+ const progressContainer = progressBar.parentElement;
+ if (progressContainer && progressContainer.classList.contains('upload-progress')) {
+ progressContainer.style.display = 'none';
+ }
+ }, 500);
+}
+
setupLocalImageUpload();
diff --git a/templates/partials/search.django b/templates/partials/search.django
index 75f8368..4798717 100644
--- a/templates/partials/search.django
+++ b/templates/partials/search.django
@@ -6,19 +6,19 @@
<input type="button" value="Clear" onclick="this.form.tags.value='';" />
<div class="rating-toggles">
<label class="rating-checkbox">
- <input type="checkbox" name="rating" value="safe" {{ QueryRatings.safe|yesno:'checked,' }} />
+ <input type="checkbox" name="rating" value="safe" {{ QueryRatings.Safe|yesno:'checked,' }} />
<span class="checkbox-custom safe"></span>
</label>
<label class="rating-checkbox">
- <input type="checkbox" name="rating" value="questionable" {{ QueryRatings.questionable|yesno:'checked,' }} />
+ <input type="checkbox" name="rating" value="questionable" {{ QueryRatings.Questionable|yesno:'checked,' }} />
<span class="checkbox-custom questionable"></span>
</label>
<label class="rating-checkbox">
- <input type="checkbox" name="rating" value="sensitive" {{ QueryRatings.sensitive|yesno:'checked,' }} />
+ <input type="checkbox" name="rating" value="sensitive" {{ QueryRatings.Sensitive|yesno:'checked,' }} />
<span class="checkbox-custom sensitive"></span>
</label>
<label class="rating-checkbox">
- <input type="checkbox" name="rating" value="explicit" {{ QueryRatings.explicit|yesno:'checked,' }} />
+ <input type="checkbox" name="rating" value="explicit" {{ QueryRatings.Explicit|yesno:'checked,' }} />
<span class="checkbox-custom explicit"></span>
</label>
</div>
diff --git a/templates/posts/list.django b/templates/posts/list.django
index c476cfa..3603cb7 100644
--- a/templates/posts/list.django
+++ b/templates/posts/list.django
@@ -16,7 +16,7 @@
<div class="post-id">ID: {{ image.ID }}</div>
<div class="post-score">★{{ image.FavouriteCount }}</div>
</div>
- <div class="post-rating">{{ image.Rating }}</div>
+ <div class="post-rating {{ image.Rating }}">{{ image.Rating }}</div>
<div class="post-tags">
{% for tag in image.Tags %}
<span class="post-tag" style="color: {{ tag.Type.Color }};">{{ tag.Name }}</span>
diff --git a/templates/posts/single.django b/templates/posts/single.django
new file mode 100644
index 0000000..4769309
--- /dev/null
+++ b/templates/posts/single.django
@@ -0,0 +1,10 @@
+{% extends 'layouts/main.django' %}
+{% block content %}
+ {% if Error %}
+ <div class="centered-main">
+ <div class="error">{{ Error }}</div>
+ </div>
+ {% else %}
+ Single Post
+ {% endif %}
+{% endblock %}
diff --git a/utils/format/image.go b/utils/format/image.go
new file mode 100644
index 0000000..b68271c
--- /dev/null
+++ b/utils/format/image.go
@@ -0,0 +1,14 @@
+package format
+
+import (
+ "imageboard/config"
+ "strings"
+)
+
+func GetCDNURL() string {
+ cdnURL := strings.TrimRight(config.S3.PublicURL, "/") + "/" + config.S3.BucketName
+ if config.S3.FolderPath != "" {
+ cdnURL += "/" + config.S3.FolderPath
+ }
+ return cdnURL
+}
diff --git a/utils/format/numbers.go b/utils/format/numbers.go
index 8f546f1..b1561e5 100644
--- a/utils/format/numbers.go
+++ b/utils/format/numbers.go
@@ -18,3 +18,12 @@ func Int64ToString(value int64) string {
}
return fmt.Sprintf("%d", value)
}
+
+func StringToUint(value string) (uint, error) {
+ var uintValue uint
+ _, err := fmt.Sscanf(value, "%d", &uintValue)
+ if err != nil {
+ return 0, fmt.Errorf("invalid string to uint conversion: %w", err)
+ }
+ return uintValue, nil
+}
diff --git a/utils/handlers/req_map.go b/utils/handlers/req_map.go
new file mode 100644
index 0000000..2b1145c
--- /dev/null
+++ b/utils/handlers/req_map.go
@@ -0,0 +1,57 @@
+package handlers
+
+import (
+ "imageboard/config"
+ "strings"
+)
+
+func ExtractRatingsAndMap(queryParams []config.QueryParam) ([]config.Rating, map[string]bool) {
+ var ratings []config.Rating
+ ratingsMap := map[string]bool{}
+ for _, param := range queryParams {
+ if param.Key == "rating" {
+ switch strings.ToLower(param.Value) {
+ case "safe":
+ ratings = append(ratings, config.RatingSafe)
+ ratingsMap["Safe"] = true
+ case "questionable":
+ ratings = append(ratings, config.RatingQuestionable)
+ ratingsMap["Questionable"] = true
+ case "sensitive":
+ ratings = append(ratings, config.RatingSensitive)
+ ratingsMap["Sensitive"] = true
+ case "explicit":
+ ratings = append(ratings, config.RatingExplicit)
+ ratingsMap["Explicit"] = true
+ }
+ }
+ }
+ if len(ratings) == 0 {
+ ratings = []config.Rating{
+ config.RatingSafe,
+ config.RatingQuestionable,
+ config.RatingSensitive,
+ }
+ ratingsMap["Safe"] = true
+ ratingsMap["Questionable"] = true
+ ratingsMap["Sensitive"] = true
+ }
+ return ratings, ratingsMap
+}
+
+func ExtractQueryTags(queryParams []config.QueryParam) (string, []string) {
+ for _, param := range queryParams {
+ if param.Key == "tags" {
+ tags := strings.TrimSpace(param.Value)
+ if tags == "" {
+ return "", nil
+ }
+ tagList := strings.Split(tags, ",")
+ for i := range tagList {
+ tagList[i] = strings.TrimSpace(tagList[i])
+ }
+ return tags, tagList
+ }
+ }
+ return "", nil
+}