diff options
| -rw-r--r-- | config/constants.go | 2 | ||||
| -rw-r--r-- | controllers/posts.go | 111 | ||||
| -rw-r--r-- | database/images.go | 10 | ||||
| -rw-r--r-- | database/posts.go | 33 | ||||
| -rw-r--r-- | imageboard/main.go | 1 | ||||
| -rw-r--r-- | models/image.go | 2 | ||||
| -rw-r--r-- | models/tags.go | 2 | ||||
| -rw-r--r-- | router/routes.go | 1 | ||||
| -rw-r--r-- | static/css/main.css | 16 | ||||
| -rw-r--r-- | static/scripts/upload.js | 175 | ||||
| -rw-r--r-- | templates/partials/search.django | 8 | ||||
| -rw-r--r-- | templates/posts/list.django | 2 | ||||
| -rw-r--r-- | templates/posts/single.django | 10 | ||||
| -rw-r--r-- | utils/format/image.go | 14 | ||||
| -rw-r--r-- | utils/format/numbers.go | 9 | ||||
| -rw-r--r-- | utils/handlers/req_map.go | 57 |
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 +} |
