diff --git a/.gitignore b/.gitignore index a3078c3..b66c2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ db.sqlite3 .local/ staticfiles static/sitemap.xml +media/ # Art *.*~ diff --git a/apps/blog/admin.py b/apps/blog/admin.py index 993f054..9a07b00 100644 --- a/apps/blog/admin.py +++ b/apps/blog/admin.py @@ -96,19 +96,21 @@ class BlogImageAdmin(admin.ModelAdmin): "image_preview", "uploaded_by", "uploaded_at", + "size", + "checksum", "markdown_link", ] list_filter = ["uploaded_at", "uploaded_by"] - search_fields = ["filename", "uploaded_by__username"] - readonly_fields = ["uploaded_at", "markdown_link"] + search_fields = ["filename", "uploaded_by__username", "checksum"] + readonly_fields = ["uploaded_at", "size", "checksum", "markdown_link"] def image_preview(self, obj): - if obj.image: + if obj.image and obj.is_image: return format_html( '', obj.image.url, ) - return "No image" + return obj.filename or "File" image_preview.short_description = "Preview" diff --git a/apps/blog/migrations/0004_blogimage_filefield.py b/apps/blog/migrations/0004_blogimage_filefield.py new file mode 100644 index 0000000..451803d --- /dev/null +++ b/apps/blog/migrations/0004_blogimage_filefield.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("blog", "0003_comment"), + ] + + operations = [ + migrations.AlterField( + model_name="blogimage", + name="image", + field=models.FileField(max_length=500, upload_to="blog/uploads/"), + ), + ] diff --git a/apps/blog/migrations/0005_blogimage_size_checksum.py b/apps/blog/migrations/0005_blogimage_size_checksum.py new file mode 100644 index 0000000..219385d --- /dev/null +++ b/apps/blog/migrations/0005_blogimage_size_checksum.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("blog", "0004_blogimage_filefield"), + ] + + operations = [ + migrations.AddField( + model_name="blogimage", + name="checksum", + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name="blogimage", + name="size", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/apps/blog/models.py b/apps/blog/models.py index bfbde2e..646760b 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -1,14 +1,18 @@ +import os +import re + from django.db import models from django.contrib.auth import get_user_model from django.urls import reverse from django.utils.text import slugify from django.utils import timezone -from django.urls import reverse import markdown from django.utils.html import strip_tags from django.conf import settings from snowsune.models import SiteSetting +from .uploads import enhance_download_links, file_sha256, image_extensions + User = get_user_model() @@ -80,8 +84,10 @@ def save(self, *args, **kwargs): # Generate HTML content from markdown if self.content: - self.content_html = markdown.markdown( - self.content, extensions=["extra", "codehilite", "toc"] + self.content_html = enhance_download_links( + markdown.markdown( + self.content, extensions=["extra", "codehilite", "toc"] + ) ) # Generate excerpt from first paragraph if not self.excerpt: @@ -102,6 +108,7 @@ def save(self, *args, **kwargs): self.published_at = timezone.now() super().save(*args, **kwargs) + prune_unreferenced_uploads(self.author_id) # Send Discord webhook notification when post is newly published if is_now_published and not was_published: @@ -168,6 +175,12 @@ def send_discord_notification(self): # Log error but don't break the save process print(f"Failed to send Discord webhook for blog post {self.title}: {e}") + def delete(self, *args, **kwargs): + author_id = self.author_id + result = super().delete(*args, **kwargs) + prune_unreferenced_uploads(author_id) + return result + def get_absolute_url(self): return reverse("blog:post_detail", kwargs={"slug": self.slug}) @@ -182,12 +195,20 @@ def display_date(self): class BlogImage(models.Model): - """Model for storing images uploaded via the blog editor""" + """ + Images uploaded to the blog editor/interface! + I also kinda tried to do some other file-stuff in here as well.. + """ - image = models.ImageField(upload_to="blog/images/") + # Paths inserted into post markdown. Older uploads live under blog/images/. + UPLOAD_PATH = re.compile(r"/media/(blog/(?:images|uploads)/[^\s)\"'<>]+)") + + image = models.FileField(upload_to="blog/uploads/", max_length=500) uploaded_at = models.DateTimeField(auto_now_add=True) uploaded_by = models.ForeignKey(User, on_delete=models.CASCADE) filename = models.CharField(max_length=255, blank=True) + size = models.PositiveBigIntegerField(null=True, blank=True) + checksum = models.CharField(max_length=64, blank=True) class Meta: ordering = ["-uploaded_at"] @@ -196,14 +217,53 @@ def __str__(self): return self.filename or self.image.name def save(self, *args, **kwargs): - if not self.filename: - self.filename = self.image.name + if not self.filename and self.image: + self.filename = os.path.basename(self.image.name) super().save(*args, **kwargs) + if self.image and (self.size is None or not self.checksum): + try: + self.size = self.image.size + except Exception: + self.size = self.size or 0 + if not self.checksum: + try: + self.checksum = file_sha256(self.image) + except Exception: + self.checksum = self.checksum or "" + type(self).objects.filter(pk=self.pk).update( + size=self.size, checksum=self.checksum + ) + + @property + def is_image(self): + """If the file is an image""" + name = self.filename or self.image.name + return os.path.splitext(name)[1].lower() in image_extensions() @property def markdown_link(self): - """Return the markdown image link for this image""" - return f"![{self.filename}]({self.image.url})" + name = self.filename or os.path.basename(self.image.name) + if self.is_image: + return f"![{name}]({self.image.url})" + return f"[{name}]({self.image.url})" + + +def media_paths(text): + return set(BlogImage.UPLOAD_PATH.findall(text or "")) + + +def prune_unreferenced_uploads(owner_id): + """Delete uploads that are not linked anywhere""" + if not owner_id: + return + referenced = set() + for content in BlogPost.objects.values_list("content", flat=True): + referenced.update(media_paths(content)) + stale = BlogImage.objects.filter(uploaded_by_id=owner_id) + if referenced: + stale = stale.exclude(image__in=referenced) + for upload in stale: + upload.delete() class Comment(models.Model): diff --git a/apps/blog/static/css/blog.css b/apps/blog/static/css/blog.css index 8983891..2e65ae8 100644 --- a/apps/blog/static/css/blog.css +++ b/apps/blog/static/css/blog.css @@ -63,7 +63,7 @@ } .search-btn:hover { - background: var(--link-hover); + text-decoration: underline; } .tag-filter { @@ -89,9 +89,9 @@ .tag-link:hover, .tag-link.active { - background: var(--link-default); - color: rgb(22, 17, 74); - border-color: var(--link-default); + text-decoration: underline; + color: var(--link-hover); + background: transparent; } /* Blog Posts */ @@ -107,13 +107,7 @@ .post-card { background: var(--tile-background); - border-radius: 8px; overflow: hidden; - transition: transform 0.2s; -} - -.post-card:hover { - transform: translateY(-2px); } .post-image img { @@ -191,16 +185,16 @@ } .page-link:hover { - background: var(--link-default); - color: white; - border-color: var(--link-default); + text-decoration: underline; + color: var(--link-hover); + background: transparent; } .current-page { padding: 0.5em 0.75em; - background: var(--link-default); - color: white; - border-radius: 4px; + background: transparent; + color: var(--text-color); + font-weight: bold; } /* Blog Sidebar */ @@ -437,191 +431,21 @@ .blog-form-container .btn, .blog-post-form .btn, .blog-form-sidebar .btn { - max-width: 100% !important; - width: auto !important; - box-sizing: border-box !important; -} - -.blog-form-container .btn-block, -.blog-post-form .btn-block, -.blog-form-sidebar .btn-block { - width: 100% !important; - max-width: 100% !important; - box-sizing: border-box !important; -} - -/* Very specific button containment with highest specificity */ -div.blog-form-container div.form-actions button.btn, -div.blog-form-container div.form-actions a.btn, -div.blog-form-sidebar div.form-actions button.btn, -div.blog-form-sidebar div.form-actions a.btn { - max-width: 100% !important; - width: auto !important; - min-width: 0 !important; - box-sizing: border-box !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - white-space: nowrap !important; -} - -div.blog-form-container div.form-actions button.btn.btn-primary, -div.blog-form-container div.form-actions a.btn.btn-primary, -div.blog-form-container div.form-actions a.btn.btn-secondary, -div.blog-form-container div.form-actions a.btn.btn-outline { - max-width: 100% !important; - width: auto !important; - min-width: 0 !important; - box-sizing: border-box !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - white-space: nowrap !important; -} - -/* Override any potential min-width issues */ -.blog-form-container button, -.blog-form-container a.btn, -.blog-form-sidebar button, -.blog-form-sidebar a.btn { - min-width: 0 !important; - max-width: 100% !important; -} - -/* Nuclear option - override everything for buttons */ -.blog-form-container .form-actions button, -.blog-form-container .form-actions a.btn, -.blog-form-sidebar .form-actions button, -.blog-form-sidebar .form-actions a.btn, -.blog-form-container .form-actions .btn, -.blog-form-container .form-actions .btn-primary, -.blog-form-container .form-actions .btn-secondary, -.blog-form-container .form-actions .btn-outline { - max-width: 100% !important; - width: auto !important; - min-width: 0 !important; - box-sizing: border-box !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - white-space: nowrap !important; - display: inline-block !important; -} - -/* Force form actions container to respect boundaries */ -.blog-form-container .form-actions, -.blog-post-form .form-actions { - max-width: 100% !important; - width: 100% !important; - overflow: hidden !important; - box-sizing: border-box !important; -} - -/* Debug styles - add borders to see what's happening */ -.blog-form-container .form-actions { - border: 2px solid red !important; -} - -.blog-form-container .form-actions .btn { - border: 2px solid blue !important; -} - -/* Force entire blog form layout to respect boundaries */ -.blog-form-container, -.blog-post-form, -.blog-form-sidebar { - max-width: 100% !important; - overflow: hidden !important; - box-sizing: border-box !important; -} - -/* Force all child elements to respect boundaries */ -.blog-form-container *, -.blog-post-form *, -.blog-form-sidebar * { - max-width: 100% !important; - box-sizing: border-box !important; + max-width: 100%; + box-sizing: border-box; } -/* Override any potential width issues */ .blog-form-container .form-actions, .blog-post-form .form-actions { - max-width: 100% !important; - width: 100% !important; - overflow: hidden !important; - box-sizing: border-box !important; - display: flex !important; - flex-wrap: wrap !important; - gap: 0.5em !important; -} - -/* Force buttons to be contained */ -.blog-form-container .form-actions .btn, -.blog-post-form .form-actions .btn { - flex: 1 1 auto !important; - min-width: 0 !important; - max-width: 100% !important; - width: auto !important; - box-sizing: border-box !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - white-space: nowrap !important; -} - -/* Override responsive button styles for blog forms */ -@media (max-width: 768px) { - .blog-form-container .btn, - .blog-post-form .btn, - .blog-form-sidebar .btn { - width: auto !important; - max-width: 100% !important; - min-width: 0 !important; - box-sizing: border-box !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - white-space: nowrap !important; - } - - .blog-form-container .form-actions .btn, - .blog-post-form .form-actions .btn, - .blog-form-sidebar .form-actions .btn { - flex: 1 1 auto !important; - width: auto !important; - max-width: 100% !important; - min-width: 0 !important; - } -} - -/* Always ensure sidebar buttons are contained */ -.blog-form-sidebar .btn, -.blog-form-sidebar button, -.blog-form-sidebar a.btn { - width: auto !important; - max-width: 100% !important; - min-width: 0 !important; - box-sizing: border-box !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - white-space: nowrap !important; - display: inline-block !important; -} - -/* Override any potential width inheritance */ -.blog-form-sidebar .form-actions .btn { - width: auto !important; - max-width: 100% !important; - min-width: 0 !important; - flex: 1 1 auto !important; -} - - - -/* Ensure blog form content doesn't exceed available space */ -.blog-form-container .markdown-editor-container { - max-width: 100% !important; - overflow: hidden !important; + display: flex; + flex-wrap: wrap; + gap: 0.5em; + max-width: 100%; } +.blog-form-container .markdown-editor-container, .blog-form-container .editor-toolbar { - max-width: 100% !important; - overflow: hidden !important; + max-width: 100%; } /* Blog Post Detail */ @@ -650,10 +474,8 @@ div.blog-form-container div.form-actions a.btn.btn-outline { flex-shrink: 0; width: 120px; height: 120px; - border-radius: 8px; overflow: hidden; - border: 2px solid var(--text-color); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border: 1px solid var(--text-color); } .post-thumbnail img { @@ -688,15 +510,73 @@ div.blog-form-container div.form-actions a.btn.btn-outline { } .post-featured-image img { - width: 100%; - max-height: 400px; - object-fit: cover; - border-radius: 8px; + max-width: 33%; + height: auto; } .post-content { line-height: 1.6; margin-bottom: 1em; + overflow-wrap: anywhere; +} + +.post-content img { + max-width: 33%; + height: auto; + cursor: zoom-in; +} + +@media (max-width: 768px) { + .post-featured-image img, + .post-content img { + max-width: 100%; + } +} + +.blog-lightbox { + padding: 0; + border: 1px solid var(--text-color); + max-width: 95vw; + max-height: 95vh; + background: var(--tile-background); +} + +.blog-lightbox::backdrop { + background: rgba(0, 0, 0, 0.8); +} + +.blog-lightbox img { + display: block; + max-width: 95vw; + max-height: 95vh; + cursor: zoom-out; +} + +.blog-download { + display: inline-block; + max-width: 100%; + margin: 0.75em 0; + padding: 0.5em 0.75em; + border: 1px solid var(--text-color); + box-sizing: border-box; +} + +.blog-download-name { + display: inline-block; + max-width: 100%; + font-weight: bold; + overflow-wrap: anywhere; +} + +.blog-download-meta { + margin-top: 0.25em; + font-size: 0.8rem; + opacity: 0.75; + overflow-wrap: anywhere; +} + +.blog-download-checksum { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .post-footer { @@ -742,13 +622,7 @@ div.blog-form-container div.form-actions a.btn.btn-outline { .related-post-card { background: var(--tile-background); - border-radius: 8px; overflow: hidden; - transition: transform 0.2s; -} - -.related-post-card:hover { - transform: translateY(-2px); } .related-post-image img { @@ -1028,13 +902,7 @@ div.blog-form-container div.form-actions a.btn.btn-outline { .post-card { background: var(--tile-background); - border-radius: 8px; overflow: hidden; - transition: transform 0.2s; -} - -.post-card:hover { - transform: translateY(-2px); } .view-all-posts { @@ -1064,64 +932,36 @@ div.blog-form-container div.form-actions a.btn.btn-outline { .btn { display: inline-block; padding: 0.5em 1em; - background: var(--tile-background); - color: var(--text-color); + background: transparent; + color: var(--link-default); text-decoration: none; border: 1px solid var(--text-color); - border-radius: 4px; + border-radius: 0; cursor: pointer; font-size: 1rem; - transition: all 0.2s; } .btn:hover { - background: var(--link-default); - color: white; - border-color: var(--link-default); -} - -.btn-primary { - background: var(--link-default); - color: rgb(3, 27, 32); - border-color: var(--link-default); + text-decoration: underline; + color: var(--link-hover); } -.btn-primary:hover { - background: var(--link-hover); - border-color: var(--link-hover); -} - -.btn-secondary { - background: var(--tile-background); - color: var(--text-color); - border-color: var(--text-color); -} - -.btn-secondary:hover { - background: var(--text-color); - color: var(--tile-background); -} - -.btn-outline { +.btn-primary, +.btn-secondary, +.btn-outline, +.btn-danger { background: transparent; - color: var(--text-color); + color: var(--link-default); border-color: var(--text-color); } -.btn-outline:hover { - background: var(--text-color); - color: var(--tile-background); -} - -.btn-danger { - background: #dc3545; - color: white; - border-color: #dc3545; -} - +.btn-primary:hover, +.btn-secondary:hover, +.btn-outline:hover, .btn-danger:hover { - background: #c82333; - border-color: #bd2130; + text-decoration: underline; + color: var(--link-hover); + background: transparent; } .btn-sm { @@ -1469,37 +1309,6 @@ div.blog-form-container div.form-actions a.btn.btn-outline { font-size: 0.85em; } -/* Data attribute enhancements */ -[data-comment-id] { - transition: all 0.2s ease; -} - -[data-comment-id]:hover { - transform: translateX(2px); -} - -[data-action] { - transition: all 0.2s ease; -} - -[data-action]:hover { - transform: translateY(-1px); -} - -/* Comment form enhancements */ -[data-comment-form] { - transition: all 0.3s ease; -} - -[data-reply-form] { - transition: all 0.3s ease; - transform-origin: top; -} - -[data-reply-form][style*="display: block"] { - animation: slideDown 0.3s ease; -} - /* Form submission states */ .form-submitting { opacity: 0.8; @@ -1571,47 +1380,6 @@ div.blog-form-container div.form-actions a.btn.btn-outline { display: none; } -/* Comment form status indicators */ -.comment-form-status { - margin-top: 1rem; - padding: 0.75rem; - border-radius: 4px; - border: 1px solid; - font-size: 0.9em; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.comment-form-status.info { - background-color: rgba(23, 162, 184, 0.1); - border-color: var(--info-color, #17a2b8); - color: var(--info-color, #17a2b8); -} - -.comment-form-status.success { - background-color: rgba(40, 167, 69, 0.1); - border-color: var(--success-color, #28a745); - color: var(--success-color, #28a745); -} - -.comment-form-status.warning { - background-color: rgba(255, 193, 7, 0.1); - border-color: var(--warning-color, #ffc107); - color: var(--warning-color, #ffc107); -} - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - /* Badges */ .badge { display: inline-block; @@ -1746,10 +1514,6 @@ div.blog-form-container div.form-actions a.btn.btn-outline { } /* Dashboard moderation actions */ -.moderation-action { - transition: all 0.2s ease; -} - .moderation-action:disabled { opacity: 0.6; cursor: not-allowed; @@ -1774,14 +1538,8 @@ div.blog-form-container div.form-actions a.btn.btn-outline { animation: spin 1s linear infinite; } -/* Comment row transitions */ -.comment-row { - transition: all 0.3s ease; -} - .comment-row.removing { - opacity: 0; - transform: translateX(-100%); + display: none; } /* Empty state styling */ diff --git a/apps/blog/static/js/blog-comments.js b/apps/blog/static/js/blog-comments.js index 6c80911..94172ae 100644 --- a/apps/blog/static/js/blog-comments.js +++ b/apps/blog/static/js/blog-comments.js @@ -121,15 +121,6 @@ function setupFormFeedback() { // Add visual feedback to the form form.classList.add('form-submitting'); - // Show immediate feedback notification - if (window.notifications) { - if (document.body.classList.contains('user-authenticated')) { - window.notifications.info('Submitting your comment...', 3000); - } else { - window.notifications.info('Submitting comment for moderation...', 3000); - } - } - // Submit form via AJAX submitFormAjax(form, submitBtn, originalText); } @@ -186,29 +177,13 @@ function submitFormAjax(form, submitBtn, originalText) { form.classList.remove('form-submitting'); if (data.success) { - // Show success notification - if (window.notifications) { - if (data.user_authenticated) { - window.notifications.success(data.message, 5000); - } else { - window.notifications.info(data.message, 8000); - } - } - // Clear form form.reset(); - // Reload page after a short delay to show the new comment - setTimeout(() => { - window.location.reload(); - }, 1500); + // Reload so the new comment (or moderation state) shows up + window.location.reload(); } else { - // Show error notification - if (window.notifications) { - window.notifications.error(data.message || 'An error occurred', 10000); - } - // Show field errors if any if (data.errors) { Object.keys(data.errors).forEach(field => { @@ -234,15 +209,8 @@ function submitFormAjax(form, submitBtn, originalText) { submitBtn.classList.remove('btn-loading'); form.classList.remove('form-submitting'); - // Show error notification - if (window.notifications) { - window.notifications.error('Network error. Please try again.', 10000); - } - // Fallback to regular form submission - setTimeout(() => { - form.submit(); - }, 1000); + form.submit(); }); } diff --git a/apps/blog/static/js/blog-dashboard.js b/apps/blog/static/js/blog-dashboard.js index 854ba8e..a3bc2e3 100644 --- a/apps/blog/static/js/blog-dashboard.js +++ b/apps/blog/static/js/blog-dashboard.js @@ -9,23 +9,7 @@ document.addEventListener('DOMContentLoaded', function () { }); function initDashboard() { - // Show welcome notification if there are pending comments - const pendingComments = document.querySelectorAll('[data-moderation-type="pending"] .comment-row'); - if (pendingComments.length > 0 && window.notifications) { - window.notifications.info( - `You have ${pendingComments.length} comment(s) awaiting moderation`, - 8000 - ); - } - - // Set up moderation action handlers setupModerationActions(); - - // Add any dashboard-specific functionality here - // For now, this is a placeholder for future enhancements - - // Example: Could add comment moderation shortcuts, bulk actions, etc. - console.log('Blog dashboard initialized'); } function setupModerationActions() { @@ -41,7 +25,6 @@ function setupModerationActions() { function handleModerationAction(button) { const action = button.getAttribute('data-action'); const commentId = button.getAttribute('data-comment-id'); - const commentType = button.getAttribute('data-comment-type'); if (!action || !commentId) { console.error('Missing action or comment ID'); @@ -54,11 +37,6 @@ function handleModerationAction(button) { button.textContent = 'Processing...'; button.classList.add('btn-loading'); - // Show notification - if (window.notifications) { - window.notifications.info(`Processing ${action} action...`, 3000); - } - // Make AJAX request fetch(`/blog/comment/${commentId}/${action}/`, { method: 'GET', @@ -69,32 +47,13 @@ function handleModerationAction(button) { .then(response => response.json()) .then(data => { if (data.success) { - // Show success notification - if (window.notifications) { - window.notifications.success(data.message, 5000); - } - - // Remove comment row from current section with animation const commentRow = document.querySelector(`[data-comment-id="${commentId}"]`); if (commentRow) { - commentRow.classList.add('removing'); - setTimeout(() => { - commentRow.remove(); - // Update comment counts after removal - updateCommentCounts(); - }, 300); + commentRow.remove(); + updateCommentCounts(); } - - // Refresh the dashboard data refreshDashboardData(); - } else { - // Show error notification - if (window.notifications) { - window.notifications.error(data.message || 'Action failed', 10000); - } - - // Re-enable button button.disabled = false; button.textContent = originalText; button.classList.remove('btn-loading'); @@ -102,13 +61,6 @@ function handleModerationAction(button) { }) .catch(error => { console.error('Moderation action error:', error); - - // Show error notification - if (window.notifications) { - window.notifications.error('Network error. Please try again.', 10000); - } - - // Re-enable button button.disabled = false; button.textContent = originalText; button.classList.remove('btn-loading'); diff --git a/apps/blog/static/js/blog-editor.js b/apps/blog/static/js/blog-editor.js index 84f9071..aa22199 100644 --- a/apps/blog/static/js/blog-editor.js +++ b/apps/blog/static/js/blog-editor.js @@ -1,17 +1,16 @@ document.addEventListener('DOMContentLoaded', function () { - // Initialize toolbar buttons const toolbarButtons = document.querySelectorAll('.toolbar-btn'); const contentField = document.querySelector('textarea[name="content"]'); + const imageUploadZone = document.getElementById('imageUploadZone'); if (toolbarButtons.length && contentField) { - toolbarButtons.forEach(button => { + toolbarButtons.forEach(function (button) { button.addEventListener('click', function () { const insertText = this.getAttribute('data-insert'); if (insertText && contentField) { const start = contentField.selectionStart; const end = contentField.selectionEnd; const text = contentField.value; - contentField.value = text.substring(0, start) + insertText + text.substring(end); contentField.focus(); contentField.setSelectionRange(start + insertText.length, start + insertText.length); @@ -20,83 +19,134 @@ document.addEventListener('DOMContentLoaded', function () { }); } - // Image upload zone functionality - const imageUploadZone = document.getElementById('imageUploadZone'); + if (!imageUploadZone || !contentField || typeof Resumable === 'undefined') { + return; + } - if (imageUploadZone && contentField) { - // Handle drag and drop - imageUploadZone.addEventListener('dragover', function (e) { - e.preventDefault(); - this.classList.add('drag-over'); - }); + const maxBytes = Number(imageUploadZone.dataset.maxBytes) || (2 * 1024 * 1024 * 1024); + const chunkBytes = Number(imageUploadZone.dataset.chunkBytes) || (4 * 1024 * 1024); + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]'); + const progress = document.getElementById('uploadProgress'); + const bar = document.getElementById('uploadProgressBar'); - imageUploadZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - this.classList.remove('drag-over'); - }); + const r = new Resumable({ + target: '/blog/upload-chunk/', + chunkSize: chunkBytes, + simultaneousUploads: 2, + testChunks: true, + throttleProgressCallbacks: 1, + fileParameterName: 'file', + headers: csrfToken ? { 'X-CSRFToken': csrfToken.value } : {}, + }); - imageUploadZone.addEventListener('drop', function (e) { - e.preventDefault(); - this.classList.remove('drag-over'); + if (!r.support) { + setUploadStatus('Chunked uploads are not supported in this browser.'); + return; + } - const files = e.dataTransfer.files; - if (files.length > 0) { - handleImageUpload(files[0]); - } + const fileInput = document.getElementById('blogFileInput'); + const fileButton = document.getElementById('blogFileButton'); + if (fileButton && fileInput) { + r.assignBrowse(fileInput); + fileButton.addEventListener('click', function () { + fileInput.click(); }); + } + r.assignDrop(imageUploadZone); + + imageUploadZone.addEventListener('dragover', function (e) { + e.preventDefault(); + this.classList.add('drag-over'); + }); + imageUploadZone.addEventListener('dragleave', function (e) { + e.preventDefault(); + this.classList.remove('drag-over'); + }); + imageUploadZone.addEventListener('drop', function () { + this.classList.remove('drag-over'); + }); - // Handle paste from clipboard - document.addEventListener('paste', function (e) { - if (e.clipboardData && e.clipboardData.items) { - for (let i = 0; i < e.clipboardData.items.length; i++) { - if (e.clipboardData.items[i].type.indexOf('image') !== -1) { - const file = e.clipboardData.items[i].getAsFile(); - handleImageUpload(file); - break; - } + document.addEventListener('paste', function (e) { + if (!e.clipboardData) { + return; + } + for (const item of e.clipboardData.items || []) { + if (item.type.indexOf('image') !== -1) { + const file = item.getAsFile(); + if (file) { + r.addFile(file); } } - }); - } + } + }); - function handleImageUpload(file) { - if (!file || !file.type.startsWith('image/')) { + r.on('fileAdded', function (file) { + if (file.size > maxBytes) { + r.removeFile(file); + setUploadStatus(file.fileName + ' is too large.'); return; } + if (progress) { + progress.hidden = false; + } + setUploadStatus('Uploading ' + file.fileName + '…'); + r.upload(); + }); - const formData = new FormData(); - formData.append('image', file); + r.on('fileProgress', function (file) { + const pct = Math.round(file.progress() * 100); + if (bar) { + bar.style.width = pct + '%'; + } + setUploadStatus('Uploading ' + file.fileName + '… ' + pct + '%'); + }); - fetch('/blog/upload-image/', { - method: 'POST', - body: formData, - headers: { - 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value - } - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - const imageMarkdown = `![${file.name}](${data.url})`; - insertAtCursor(imageMarkdown); - } else { - console.error('Upload failed:', data.error); - } - }) - .catch(error => { - console.error('Upload error:', error); - }); - } + r.on('fileSuccess', function (file, message) { + let data = {}; + try { + data = JSON.parse(message || '{}'); + } catch (err) { + data = {}; + } + if (data.complete && data.markdown) { + insertAtCursor(data.markdown); + setUploadStatus('Added ' + file.fileName); + } else if (data.error) { + setUploadStatus(data.error); + } else { + setUploadStatus('Upload finished for ' + file.fileName); + } + if (bar) { + bar.style.width = '100%'; + } + }); - function insertAtCursor(text) { - if (contentField) { - const start = contentField.selectionStart; - const end = contentField.selectionEnd; - const currentText = contentField.value; + r.on('fileError', function (file, message) { + let detail = message; + try { + const data = JSON.parse(message || '{}'); + detail = data.error || message; + } catch (err) { + // keep raw message + } + setUploadStatus(detail || ('Upload failed for ' + file.fileName)); + }); - contentField.value = currentText.substring(0, start) + text + currentText.substring(end); - contentField.focus(); - contentField.setSelectionRange(start + text.length, start + text.length); + function setUploadStatus(message) { + const status = document.getElementById('uploadStatus'); + if (!status) { + return; } + status.hidden = false; + status.textContent = message; + } + + function insertAtCursor(text) { + const start = contentField.selectionStart; + const end = contentField.selectionEnd; + const currentText = contentField.value; + contentField.value = currentText.substring(0, start) + text + currentText.substring(end); + contentField.focus(); + contentField.setSelectionRange(start + text.length, start + text.length); } -}); \ No newline at end of file +}); diff --git a/apps/blog/static/js/blog-lightbox.js b/apps/blog/static/js/blog-lightbox.js new file mode 100644 index 0000000..222d185 --- /dev/null +++ b/apps/blog/static/js/blog-lightbox.js @@ -0,0 +1,26 @@ +document.addEventListener("DOMContentLoaded", function () { + const content = document.querySelector(".post-content.markdown-content"); + if (!content) { + return; + } + + const dialog = document.createElement("dialog"); + dialog.className = "blog-lightbox"; + dialog.innerHTML = "\"\""; + document.body.appendChild(dialog); + const full = dialog.querySelector("img"); + + content.addEventListener("click", function (event) { + const img = event.target.closest("img"); + if (!img || !content.contains(img)) { + return; + } + full.src = img.currentSrc || img.src; + full.alt = img.alt || ""; + dialog.showModal(); + }); + + dialog.addEventListener("click", function () { + dialog.close(); + }); +}); diff --git a/apps/blog/static/js/resumable.min.js b/apps/blog/static/js/resumable.min.js new file mode 100644 index 0000000..069c284 --- /dev/null +++ b/apps/blog/static/js/resumable.min.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v5.39.0. + * Original file: /npm/resumablejs@1.1.0/resumable.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(){"use strict";var e=function(t){if(!(this instanceof e))return new e(t);if(this.version=1,this.support=!("undefined"==typeof File||"undefined"==typeof Blob||"undefined"==typeof FileList||!Blob.prototype.webkitSlice&&!Blob.prototype.mozSlice&&!Blob.prototype.slice),!this.support)return!1;var r=this;r.files=[],r.defaults={chunkSize:1048576,forceChunkSize:!1,simultaneousUploads:3,fileParameterName:"file",chunkNumberParameterName:"resumableChunkNumber",chunkSizeParameterName:"resumableChunkSize",currentChunkSizeParameterName:"resumableCurrentChunkSize",totalSizeParameterName:"resumableTotalSize",typeParameterName:"resumableType",identifierParameterName:"resumableIdentifier",fileNameParameterName:"resumableFilename",relativePathParameterName:"resumableRelativePath",totalChunksParameterName:"resumableTotalChunks",throttleProgressCallbacks:.5,query:{},headers:{},preprocess:null,method:"multipart",uploadMethod:"POST",testMethod:"GET",prioritizeFirstAndLastChunk:!1,target:"/",testTarget:null,parameterNamespace:"",testChunks:!0,generateUniqueIdentifier:null,getTarget:null,maxChunkRetries:100,chunkRetryInterval:void 0,permanentErrors:[400,404,415,500,501],maxFiles:void 0,withCredentials:!1,xhrTimeout:0,clearInput:!0,chunkFormat:"blob",setChunkTypeFromFile:!1,maxFilesErrorCallback:function(e,t){var n=r.getOpt("maxFiles");alert("Please upload no more than "+n+" file"+(1===n?"":"s")+" at a time.")},minFileSize:1,minFileSizeErrorCallback:function(e,t){alert(e.fileName||e.name+" is too small, please upload files larger than "+n.formatSize(r.getOpt("minFileSize"))+".")},maxFileSize:void 0,maxFileSizeErrorCallback:function(e,t){alert(e.fileName||e.name+" is too large, please upload files less than "+n.formatSize(r.getOpt("maxFileSize"))+".")},fileType:[],fileTypeErrorCallback:function(e,t){alert(e.fileName||e.name+" has type not allowed, please upload files of type "+r.getOpt("fileType")+".")}},r.opts=t||{},r.getOpt=function(t){var r=this;if(t instanceof Array){var i={};return n.each(t,(function(e){i[e]=r.getOpt(e)})),i}if(r instanceof c){if(void 0!==r.opts[t])return r.opts[t];r=r.fileObj}if(r instanceof f){if(void 0!==r.opts[t])return r.opts[t];r=r.resumableObj}if(r instanceof e)return void 0!==r.opts[t]?r.opts[t]:r.defaults[t]},r.events=[],r.on=function(e,t){r.events.push(e.toLowerCase(),t)},r.fire=function(){for(var e=[],t=0;t0){var c=!1;for(var p in a.fileType){var d="."+a.fileType[p];if(-1!==u.toLowerCase().indexOf(d.toLowerCase(),u.length-d.length)){c=!0;break}}if(!c)return a.fileTypeErrorCallback(e,i++),!1}if(void 0!==a.minFileSize&&e.sizea.maxFileSize)return a.maxFileSizeErrorCallback(e,i++),!1;function h(n){r.getFromUniqueIdentifier(n)?o.push(e):function(){e.uniqueIdentifier=n;var i=new f(r,e,n);r.files.push(i),s.push(i),i.container=void 0!==t?t.srcElement:null,window.setTimeout((function(){r.fire("fileAdded",i,t)}),0)}(),l()}var m=n.generateUniqueIdentifier(e,t);m&&"function"==typeof m.then?m.then((function(e){h(e)}),(function(){l()})):h(m)}))};function f(e,t,r){var i=this;i.opts={},i.getOpt=e.getOpt,i._prevProgress=0,i.resumableObj=e,i.file=t,i.fileName=t.fileName||t.name,i.size=t.size,i.relativePath=t.relativePath||t.webkitRelativePath||i.fileName,i.uniqueIdentifier=r,i._pause=!1,i.container="";var a=void 0!==r,s=function(e,t){switch(e){case"progress":i.resumableObj.fire("fileProgress",i,t);break;case"error":i.abort(),a=!0,i.chunks=[],i.resumableObj.fire("fileError",i,t);break;case"success":if(a)return;i.resumableObj.fire("fileProgress",i),i.isComplete()&&i.resumableObj.fire("fileSuccess",i,t);break;case"retry":i.resumableObj.fire("fileRetry",i)}};return i.chunks=[],i.abort=function(){var e=0;n.each(i.chunks,(function(t){"uploading"==t.status()&&(t.abort(),e++)})),e>0&&i.resumableObj.fire("fileProgress",i)},i.cancel=function(){var e=i.chunks;i.chunks=[],n.each(e,(function(e){"uploading"==e.status()&&(e.abort(),i.resumableObj.uploadNextChunk())})),i.resumableObj.removeFile(i),i.resumableObj.fire("fileProgress",i)},i.retry=function(){i.bootstrap();var e=!1;i.resumableObj.on("chunkingComplete",(function(){e||i.resumableObj.upload(),e=!0}))},i.bootstrap=function(){i.abort(),a=!1,i.chunks=[],i._prevProgress=0;for(var e=i.getOpt("forceChunkSize")?Math.ceil:Math.floor,t=Math.max(e(i.file.size/i.getOpt("chunkSize")),1),r=0;r.99999?1:e,e=Math.max(i._prevProgress,e),i._prevProgress=e,e},i.isUploading=function(){var e=!1;return n.each(i.chunks,(function(t){if("uploading"==t.status())return e=!0,!1})),e},i.isComplete=function(){var e=!1;return n.each(i.chunks,(function(t){var r=t.status();if("pending"==r||"uploading"==r||1===t.preprocessState)return e=!0,!1})),!e},i.pause=function(e){i._pause=void 0===e?!i._pause:e},i.isPaused=function(){return i._pause},i.resumableObj.fire("chunkingStart",i),i.bootstrap(),this}function c(e,t,r,i){var a=this;a.opts={},a.getOpt=e.getOpt,a.resumableObj=e,a.fileObj=t,a.fileObjSize=t.size,a.fileObjType=t.file.type,a.offset=r,a.callback=i,a.lastProgressCallback=new Date,a.tested=!1,a.retries=0,a.pendingRetry=!1,a.preprocessState=0;var s=a.getOpt("chunkSize");return a.loaded=0,a.startByte=a.offset*s,a.endByte=Math.min(a.fileObjSize,(a.offset+1)*s),a.fileObjSize-a.endByte1e3*a.getOpt("throttleProgressCallbacks")&&(a.callback("progress"),a.lastProgressCallback=new Date),a.loaded=e.loaded||0}),!1),a.loaded=0,a.pendingRetry=!1,a.callback("progress");var t=function(e){var t=a.status();if("success"==t||"error"==t)a.callback(t,a.message()),a.resumableObj.uploadNextChunk();else{a.callback("retry",a.message()),a.abort(),a.retries++;var r=a.getOpt("chunkRetryInterval");void 0!==r?(a.pendingRetry=!0,setTimeout(a.send,r)):a.send()}};a.xhr.addEventListener("load",t,!1),a.xhr.addEventListener("error",t,!1),a.xhr.addEventListener("timeout",t,!1);var r=[["chunkNumberParameterName",a.offset+1],["chunkSizeParameterName",a.getOpt("chunkSize")],["currentChunkSizeParameterName",a.endByte-a.startByte],["totalSizeParameterName",a.fileObjSize],["typeParameterName",a.fileObjType],["identifierParameterName",a.fileObj.uniqueIdentifier],["fileNameParameterName",a.fileObj.fileName],["relativePathParameterName",a.fileObj.relativePath],["totalChunksParameterName",a.fileObj.chunks.length]].filter((function(e){return a.getOpt(e[0])})).reduce((function(e,t){return e[a.getOpt(t[0])]=t[1],e}),{}),i=a.getOpt("query");"function"==typeof i&&(i=i(a.fileObj,a)),n.each(i,(function(e,t){r[e]=t}));var s=a.fileObj.file.slice?"slice":a.fileObj.file.mozSlice?"mozSlice":a.fileObj.file.webkitSlice?"webkitSlice":"slice",o=a.fileObj.file[s](a.startByte,a.endByte,a.getOpt("setChunkTypeFromFile")?a.fileObj.file.type:""),u=null,l=[],f=a.getOpt("parameterNamespace");if("octet"===a.getOpt("method"))u=o,n.each(r,(function(e,t){l.push([encodeURIComponent(f+e),encodeURIComponent(t)].join("="))}));else if(u=new FormData,n.each(r,(function(e,t){u.append(f+e,t),l.push([encodeURIComponent(f+e),encodeURIComponent(t)].join("="))})),"blob"==a.getOpt("chunkFormat"))u.append(f+a.getOpt("fileParameterName"),o,a.fileObj.fileName);else if("base64"==a.getOpt("chunkFormat")){var c=new FileReader;c.onload=function(e){u.append(f+a.getOpt("fileParameterName"),c.result),a.xhr.send(u)},c.readAsDataURL(o)}var p=n.getTarget("upload",l),d=a.getOpt("uploadMethod");a.xhr.open(d,p),"octet"===a.getOpt("method")&&a.xhr.setRequestHeader("Content-Type","application/octet-stream"),a.xhr.timeout=a.getOpt("xhrTimeout"),a.xhr.withCredentials=a.getOpt("withCredentials");var h=a.getOpt("headers");"function"==typeof h&&(h=h(a.fileObj,a)),n.each(h,(function(e,t){a.xhr.setRequestHeader(e,t)})),"blob"==a.getOpt("chunkFormat")&&a.xhr.send(u)}else a.test()},a.abort=function(){a.xhr&&a.xhr.abort(),a.xhr=null},a.status=function(){return a.pendingRetry?"uploading":a.xhr?a.xhr.readyState<4?"uploading":200==a.xhr.status||201==a.xhr.status?"success":n.contains(a.getOpt("permanentErrors"),a.xhr.status)||a.retries>=a.getOpt("maxChunkRetries")?"error":(a.abort(),"pending"):"pending"},a.message=function(){return a.xhr?a.xhr.responseText:""},a.progress=function(e){void 0===e&&(e=!1);var t=e?(a.endByte-a.startByte)/a.fileObjSize:1;if(a.pendingRetry)return 0;switch(a.xhr&&a.xhr.status||(t*=.95),a.status()){case"success":case"error":return 1*t;case"pending":return 0*t;default:return a.loaded/(a.endByte-a.startByte)*t}},this}return r.uploadNextChunk=function(){var e=!1;if(r.getOpt("prioritizeFirstAndLastChunk")&&(n.each(r.files,(function(t){return t.chunks.length&&"pending"==t.chunks[0].status()&&0===t.chunks[0].preprocessState?(t.chunks[0].send(),e=!0,!1):t.chunks.length>1&&"pending"==t.chunks[t.chunks.length-1].status()&&0===t.chunks[t.chunks.length-1].preprocessState?(t.chunks[t.chunks.length-1].send(),e=!0,!1):void 0})),e))return!0;if(n.each(r.files,(function(t){if(!1===t.isPaused()&&n.each(t.chunks,(function(t){if("pending"==t.status()&&0===t.preprocessState)return t.send(),e=!0,!1})),e)return!1})),e)return!0;var t=!1;return n.each(r.files,(function(e){if(!e.isComplete())return t=!0,!1})),t||r.fire("complete"),!1},r.assignBrowse=function(e,t){void 0===e.length&&(e=[e]),n.each(e,(function(e){var n;"INPUT"===e.tagName&&"file"===e.type?n=e:((n=document.createElement("input")).setAttribute("type","file"),n.style.display="none",e.addEventListener("click",(function(){n.style.opacity=0,n.style.display="block",n.focus(),n.click(),n.style.display="none"}),!1),e.appendChild(n));var i=r.getOpt("maxFiles");void 0===i||1!=i?n.setAttribute("multiple","multiple"):n.removeAttribute("multiple"),t?n.setAttribute("webkitdirectory","webkitdirectory"):n.removeAttribute("webkitdirectory");var a=r.getOpt("fileType");void 0!==a&&a.length>=1?n.setAttribute("accept",a.map((function(e){return"."+e})).join(",")):n.removeAttribute("accept"),n.addEventListener("change",(function(e){l(e.target.files,e),r.getOpt("clearInput")&&(e.target.value="")}),!1)}))},r.assignDrop=function(e){void 0===e.length&&(e=[e]),n.each(e,(function(e){e.addEventListener("dragover",a,!1),e.addEventListener("dragenter",a,!1),e.addEventListener("drop",i,!1)}))},r.unAssignDrop=function(e){void 0===e.length&&(e=[e]),n.each(e,(function(e){e.removeEventListener("dragover",a),e.removeEventListener("dragenter",a),e.removeEventListener("drop",i)}))},r.isUploading=function(){var e=!1;return n.each(r.files,(function(t){if(t.isUploading())return e=!0,!1})),e},r.upload=function(){if(!r.isUploading()){r.fire("uploadStart");for(var e=1;e<=r.getOpt("simultaneousUploads");e++)r.uploadNextChunk()}},r.pause=function(){n.each(r.files,(function(e){e.abort()})),r.fire("pause")},r.cancel=function(){r.fire("beforeCancel");for(var e=r.files.length-1;e>=0;e--)r.files[e].cancel();r.fire("cancel")},r.progress=function(){var e=0,t=0;return n.each(r.files,(function(r){e+=r.progress()*r.size,t+=r.size})),t>0?e/t:0},r.addFile=function(e,t){l([e],t)},r.addFiles=function(e,t){l(e,t)},r.removeFile=function(e){for(var t=r.files.length-1;t>=0;t--)r.files[t]===e&&r.files.splice(t,1)},r.getFromUniqueIdentifier=function(e){var t=!1;return n.each(r.files,(function(r){r.uniqueIdentifier==e&&(t=r)})),t},r.getSize=function(){var e=0;return n.each(r.files,(function(t){e+=t.size})),e},r.handleDropEvent=function(e){i(e)},r.handleChangeEvent=function(e){l(e.target.files,e),e.target.value=""},r.updateQuery=function(e){r.opts.query=e},this};"undefined"!=typeof module?module.exports=e:"function"==typeof define&&define.amd?define((function(){return e})):window.Resumable=e}(); +//# sourceMappingURL=/sm/8efb27f7a98a2f927c1da6ecbe1bb212288b2f4a31be7ccf048c7a5d3fe76abd.map \ No newline at end of file diff --git a/apps/blog/templates/blog/blog_detail.html b/apps/blog/templates/blog/blog_detail.html index c3eaec0..61d5ca4 100644 --- a/apps/blog/templates/blog/blog_detail.html +++ b/apps/blog/templates/blog/blog_detail.html @@ -86,7 +86,11 @@

{{ post.title }}

{% if post.tags.all %} {% endif %} @@ -159,15 +163,6 @@

Comments

Leave a Comment

- -
- {% if user.is_authenticated %} - 🚀 Instant Posting: Your comment will appear immediately after submission. - {% else %} - ⏳ Moderation Required: Your comment will be reviewed by site staff before appearing. - {% endif %} -
-
{% csrf_token %} @@ -371,5 +366,6 @@

Quick Actions

{% endblock %} {% block extra_js %} + {% endblock %} \ No newline at end of file diff --git a/apps/blog/templates/blog/blog_form.html b/apps/blog/templates/blog/blog_form.html index 0d3df92..fef290d 100644 --- a/apps/blog/templates/blog/blog_form.html +++ b/apps/blog/templates/blog/blog_form.html @@ -11,6 +11,7 @@ {% endblock %} {% block extra_js %} + {% endblock %} @@ -44,11 +45,20 @@

{% if form.instance.pk %}Edit Post{% else %}Create New Post{% endif %}

{{ form.content }} -
+
-

Drag & drop images here or paste from clipboard

+

Drop a file here, or paste an image. Up to {{ max_upload_bytes|filesizeformat }}.

+

If its an image i'll try to embed it, otherwise it might render as a file-block!

+ + + +
{% if form.content.errors %} diff --git a/apps/blog/templates/blog/blog_list.html b/apps/blog/templates/blog/blog_list.html index c1e48e2..e70ebc5 100644 --- a/apps/blog/templates/blog/blog_list.html +++ b/apps/blog/templates/blog/blog_list.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% load static %} -{% block title %}Blog Posts - Snowsune{% endblock %} +{% block title %}{% if page_title %}{{ page_title }}{% else %}Blog Posts{% endif %} - Snowsune{% endblock %} {% block extra_css %} @@ -9,10 +9,12 @@ {% block content %}
- + {% if page_title %} +
+

{{ page_title }}

+

Posts tagged VRChat (for testing for now!)

+
+ {% endif %}
@@ -24,6 +26,7 @@

Blog Posts

+ {% if not page_title %}
Filter by tag: All @@ -34,6 +37,7 @@

Blog Posts

{% endfor %}
+ {% endif %}
diff --git a/apps/blog/tests.py b/apps/blog/tests.py index 946a3db..62495d6 100644 --- a/apps/blog/tests.py +++ b/apps/blog/tests.py @@ -1,4 +1,11 @@ -from django.test import TestCase, Client, RequestFactory +from django.test import ( + TestCase, + Client, + RequestFactory, + TransactionTestCase, + override_settings, +) +from django.core.files.uploadedfile import SimpleUploadedFile from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone @@ -8,8 +15,10 @@ from django.http import HttpRequest from unittest.mock import patch, MagicMock import json +import os +import tempfile -from .models import BlogPost, Tag, Comment +from .models import BlogPost, Tag, Comment, BlogImage from .forms import BlogPostForm, CommentForm from .views import submit_comment, moderate_comment @@ -1079,3 +1088,149 @@ def test_comment_moderation_performance(self): # Verify comment was moderated comment.refresh_from_db() self.assertEqual(comment.status, "approved") + + +@override_settings(BLOG_MAX_UPLOAD_BYTES=1024, MEDIA_ROOT=tempfile.mkdtemp()) +class BlogUploadAndVRChatTests(TransactionTestCase): + def setUp(self): + self.user = User.objects.create_user( + username="uploader", email="up@example.com", password="testpass123" + ) + self.client.login(username="uploader", password="testpass123") + + def _upload(self, name, content): + uploaded = SimpleUploadedFile(name, content) + return self.client.post(reverse("blog:upload_file"), {"file": uploaded}) + + def test_upload_links_download_and_deletes_when_unreferenced(self): + response = self._upload("world.unitypackage", b"pkg") + self.assertEqual(response.status_code, 200) + markdown = response.json()["markdown"] + self.assertIn("[world.unitypackage](", markdown) + upload = BlogImage.objects.get() + stored_path = upload.image.path + self.assertEqual(upload.size, 3) + self.assertEqual(len(upload.checksum), 64) + + post = BlogPost.objects.create( + title="World", + slug="world", + content=markdown, + author=self.user, + status="published", + published_at=timezone.now(), + ) + self.assertTrue(BlogImage.objects.filter(pk=upload.pk).exists()) + self.assertIn('class="blog-download"', post.content_html) + self.assertIn("world.unitypackage", post.content_html) + self.assertIn("SHA-256", post.content_html) + self.assertIn(upload.checksum, post.content_html) + + other = BlogPost.objects.create( + title="Also", + slug="also", + content=markdown, + author=self.user, + status="published", + published_at=timezone.now(), + ) + post.content = "Link removed." + post.save() + self.assertTrue(BlogImage.objects.filter(pk=upload.pk).exists()) + + other.delete() + self.assertFalse(BlogImage.objects.filter(pk=upload.pk).exists()) + self.assertFalse(os.path.exists(stored_path)) + + def test_rejects_html_and_oversized_files(self): + html = self._upload("page.html", b"") + self.assertEqual(html.status_code, 400) + oversized = self._upload("big.zip", b"x" * 2048) + self.assertEqual(oversized.status_code, 413) + self.assertFalse(BlogImage.objects.exists()) + + def test_chunked_upload_assembles_file(self): + content = b"chunk-one" + b"chunk-two" + upload_id = "world-unitypackage-18" + params = { + "resumableIdentifier": upload_id, + "resumableFilename": "world.unitypackage", + "resumableTotalChunks": "2", + "resumableTotalSize": str(len(content)), + } + + missing = self.client.get( + reverse("blog:upload_chunk"), + {**params, "resumableChunkNumber": "1"}, + ) + self.assertEqual(missing.status_code, 204) + + first = self.client.post( + reverse("blog:upload_chunk"), + { + **params, + "resumableChunkNumber": "1", + "file": SimpleUploadedFile("blob", b"chunk-one"), + }, + ) + self.assertEqual(first.status_code, 200) + self.assertFalse(first.json()["complete"]) + self.assertFalse(BlogImage.objects.exists()) + + present = self.client.get( + reverse("blog:upload_chunk"), + {**params, "resumableChunkNumber": "1"}, + ) + self.assertEqual(present.status_code, 200) + + second = self.client.post( + reverse("blog:upload_chunk"), + { + **params, + "resumableChunkNumber": "2", + "file": SimpleUploadedFile("blob", b"chunk-two"), + }, + ) + self.assertEqual(second.status_code, 200) + payload = second.json() + self.assertTrue(payload["complete"]) + self.assertIn("[world.unitypackage](", payload["markdown"]) + upload = BlogImage.objects.get() + self.assertEqual(upload.image.read(), content) + self.assertEqual(upload.size, len(content)) + + def test_vrchat_page_lists_only_tagged_published_posts(self): + vrchat = Tag.objects.create(name="VRChat", slug="vrchat") + other = Tag.objects.create(name="Art", slug="art") + shown = BlogPost.objects.create( + title="Avatar drop", + slug="avatar-drop", + content="A world.", + author=self.user, + status="published", + published_at=timezone.now(), + ) + shown.tags.add(vrchat) + hidden = BlogPost.objects.create( + title="Comic notes", + slug="comic-notes", + content="Not vr.", + author=self.user, + status="published", + published_at=timezone.now(), + ) + hidden.tags.add(other) + draft = BlogPost.objects.create( + title="Secret world", + slug="secret-world", + content="Draft.", + author=self.user, + status="draft", + ) + draft.tags.add(vrchat) + + response = self.client.get(reverse("vrchat")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Avatar drop") + self.assertNotContains(response, "Comic notes") + self.assertNotContains(response, "Secret world") diff --git a/apps/blog/uploads.py b/apps/blog/uploads.py new file mode 100644 index 0000000..f7ddead --- /dev/null +++ b/apps/blog/uploads.py @@ -0,0 +1,225 @@ +""" +Blog editor RULES +..and things + +idk i got tired of chasing constants >.< +""" + +import hashlib +import html +import os +import re +import shutil + +from django.conf import settings +from django.core.files import File +from django.template.defaultfilters import filesizeformat +from django.utils.safestring import mark_safe +from django.utils.text import get_valid_filename + +# Executables, scripts, and markup that must not be served from /media/ +DEFAULT_BLOCKED_UPLOAD_EXTENSIONS = frozenset( + { + ".html", + ".htm", + ".xhtml", + ".svg", + ".js", + ".mjs", + ".exe", + ".dll", + ".bat", + ".cmd", + ".sh", + ".php", + } +) + +# Stuff to treat as inline images! +DEFAULT_IMAGE_EXTENSIONS = frozenset( + {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif"} +) + +# Markdown-rendered label download links. +_DOWNLOAD_LINK = re.compile( + r']*>(.*?)', + re.IGNORECASE | re.DOTALL, +) + +_SAFE_UPLOAD_ID = re.compile(r"^[A-Za-z0-9_-]{1,200}$") + + +def blocked_upload_extensions(): + return getattr( + settings, + "BLOG_BLOCKED_UPLOAD_EXTENSIONS", + DEFAULT_BLOCKED_UPLOAD_EXTENSIONS, + ) + + +def image_extensions(): + return getattr(settings, "BLOG_IMAGE_EXTENSIONS", DEFAULT_IMAGE_EXTENSIONS) + + +def upload_chunk_bytes(): + return int(getattr(settings, "BLOG_UPLOAD_CHUNK_BYTES", 4 * 1024 * 1024)) + + +def max_upload_bytes(): + return int(getattr(settings, "BLOG_MAX_UPLOAD_BYTES", 2 * 1024 * 1024 * 1024)) + + +def clean_upload_filename(raw_name): + base = get_valid_filename(os.path.basename(raw_name or "").replace("\x00", "")) + root, ext = os.path.splitext(base) + ext = ext.lower() + if not root or not ext or ext in blocked_upload_extensions(): + return None + if len(base) > 180: + base = root[: 180 - len(ext)] + ext + return base + + +def clean_upload_id(raw_id): + if not raw_id or not _SAFE_UPLOAD_ID.match(raw_id): + return None + return raw_id + + +def chunk_dir(user_id, upload_id): + return os.path.join(settings.MEDIA_ROOT, "blog", "chunks", str(user_id), upload_id) + + +def chunk_path(user_id, upload_id, chunk_number): + return os.path.join(chunk_dir(user_id, upload_id), f"{int(chunk_number):06d}.part") + + +def chunk_exists(user_id, upload_id, chunk_number): + path = chunk_path(user_id, upload_id, chunk_number) + return os.path.isfile(path) and os.path.getsize(path) > 0 + + +def save_chunk(user_id, upload_id, chunk_number, uploaded_file): + directory = chunk_dir(user_id, upload_id) + os.makedirs(directory, exist_ok=True) + path = chunk_path(user_id, upload_id, chunk_number) + with open(path, "wb") as out: + for piece in uploaded_file.chunks(): + out.write(piece) + return path + + +def all_chunks_present(user_id, upload_id, total_chunks): + return all( + chunk_exists(user_id, upload_id, number) + for number in range(1, int(total_chunks) + 1) + ) + + +def assemble_chunks(user_id, upload_id, total_chunks, safe_name): + """Merge parts into a BlogImage and delete the temporary chunk dir.""" + from .models import BlogImage + + directory = chunk_dir(user_id, upload_id) + assembled = os.path.join(directory, "assembled.bin") + with open(assembled, "wb") as out: + for number in range(1, int(total_chunks) + 1): + part = chunk_path(user_id, upload_id, number) + with open(part, "rb") as src: + shutil.copyfileobj(src, out, length=1024 * 1024) + + try: + with open(assembled, "rb") as handle: + blog_file = File(handle, name=safe_name) + return BlogImage.objects.create( + image=blog_file, + uploaded_by_id=user_id, + filename=safe_name, + ) + finally: + shutil.rmtree(directory, ignore_errors=True) + + +def file_sha256(file_obj): + digest = hashlib.sha256() + try: + file_obj.open("rb") + except Exception: + pass + try: + try: + file_obj.seek(0) + except Exception: + pass + if hasattr(file_obj, "chunks"): + for chunk in file_obj.chunks(1024 * 1024): + digest.update(chunk) + else: + while True: + chunk = file_obj.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + finally: + try: + file_obj.seek(0) + except Exception: + pass + return digest.hexdigest() + + +def media_storage_path(href): + """Turn /media/blog/uploads/x into blog/uploads/x.""" + media_url = settings.MEDIA_URL.rstrip("/") + "/" + if href.startswith(media_url): + return href[len(media_url) :] + if href.startswith("/media/"): + return href[len("/media/") :] + return None + + +def download_card_html(upload, href, label): + name = html.escape(upload.filename or label or os.path.basename(href)) + size = filesizeformat(upload.size) if upload.size else None + checksum = upload.checksum or "" + meta_bits = [] + if size: + meta_bits.append(html.escape(str(size))) + if checksum: + meta_bits.append( + f'' + f"SHA-256 {html.escape(checksum)}" + ) + meta_html = ( + f'
{" · ".join(meta_bits)}
' + if meta_bits + else "" + ) + return ( + f'" + ) + + +def enhance_download_links(content_html): + """Turn plain media download links into size/checksum cards.""" + if not content_html: + return content_html + + from .models import BlogImage + + def replace(match): + href, label = match.group(1), match.group(2) + path = media_storage_path(href) + if not path: + return match.group(0) + upload = BlogImage.objects.filter(image=path).first() + if not upload or upload.is_image: + return match.group(0) + plain = re.sub(r"<[^>]+>", "", label or "").strip() + return download_card_html(upload, href, plain) + + return mark_safe(_DOWNLOAD_LINK.sub(replace, content_html)) diff --git a/apps/blog/urls.py b/apps/blog/urls.py index 2e34dca..dbfcd11 100644 --- a/apps/blog/urls.py +++ b/apps/blog/urls.py @@ -10,7 +10,9 @@ path("edit//", views.BlogUpdateView.as_view(), name="post_edit"), path("delete//", views.BlogDeleteView.as_view(), name="post_delete"), path("dashboard/", views.blog_dashboard, name="dashboard"), - path("upload-image/", views.upload_image, name="upload_image"), + path("upload-chunk/", views.upload_chunk, name="upload_chunk"), + path("upload-file/", views.upload_file, name="upload_file"), + path("upload-image/", views.upload_file, name="upload_image"), path("feed/", views.BlogRSSFeed(), name="rss_feed"), # Comment functionality path("post//comment/", views.submit_comment, name="submit_comment"), diff --git a/apps/blog/views.py b/apps/blog/views.py index 312e7c0..1357893 100644 --- a/apps/blog/views.py +++ b/apps/blog/views.py @@ -14,17 +14,25 @@ ) from django.urls import reverse_lazy, reverse from django.http import JsonResponse, HttpResponse -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_http_methods from django.utils.decorators import method_decorator from django.core.paginator import Paginator from django.db.models import Q from django.utils import timezone from django.contrib.syndication.views import Feed from django.utils.feedgenerator import Rss201rev2Feed -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_POST, require_http_methods from django.contrib.auth.decorators import login_required from .models import BlogPost, Tag, BlogImage, Comment +from .uploads import ( + all_chunks_present, + assemble_chunks, + chunk_exists, + clean_upload_filename, + clean_upload_id, + max_upload_bytes, + save_chunk, + upload_chunk_bytes, +) from .forms import BlogPostForm, BlogPostCreateForm, TagForm, CommentForm from apps.notifications.utils import ( show_success_notification, @@ -262,7 +270,15 @@ def moderate_comment(request, comment_id, action): return redirect(comment.post.get_absolute_url()) -class BlogCreateView(LoginRequiredMixin, CreateView): +class BlogEditorMixin: + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["max_upload_bytes"] = settings.BLOG_MAX_UPLOAD_BYTES + context["upload_chunk_bytes"] = settings.BLOG_UPLOAD_CHUNK_BYTES + return context + + +class BlogCreateView(BlogEditorMixin, LoginRequiredMixin, CreateView): model = BlogPost form_class = BlogPostCreateForm template_name = "blog/blog_form.html" @@ -281,7 +297,9 @@ def get_form_kwargs(self): return kwargs -class BlogUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): +class BlogUpdateView( + BlogEditorMixin, LoginRequiredMixin, UserPassesTestMixin, UpdateView +): model = BlogPost form_class = BlogPostForm template_name = "blog/blog_form.html" @@ -362,34 +380,121 @@ def blog_dashboard(request): return render(request, "blog/blog_dashboard.html", context) -@csrf_exempt -@require_http_methods(["POST"]) -def upload_image(request): - """Handle image uploads for the blog editor""" +@require_http_methods(["GET", "POST"]) +def upload_chunk(request): + """ + So this is kinda a big one >.< + + This should take chunks from resumable.js (which is what i have on the editor page) + But there is/was a django library that did this! https://github.com/jeanphix/django-resumable + + it was really old tho.. not updated, i opted not to try and port it or anything BUT + TODO: this is basically copy and paste from that lib, so maybe we could backport/pr + to restore the lib functionality? + """ + if not request.user.is_authenticated: return JsonResponse({"error": "Authentication required"}, status=401) + params = request.GET if request.method == "GET" else request.POST + upload_id = clean_upload_id(params.get("resumableIdentifier")) + filename = clean_upload_filename(params.get("resumableFilename")) try: - image_file = request.FILES.get("image") - if not image_file: - return JsonResponse({"error": "No image file provided"}, status=400) + chunk_number = int(params.get("resumableChunkNumber") or 0) + total_chunks = int(params.get("resumableTotalChunks") or 0) + total_size = int(params.get("resumableTotalSize") or 0) + except (TypeError, ValueError): + return JsonResponse({"error": "Invalid upload metadata."}, status=400) + + if not upload_id or not filename or chunk_number < 1 or total_chunks < 1: + return JsonResponse({"error": "Invalid upload metadata."}, status=400) + if total_size > max_upload_bytes(): + return JsonResponse({"error": "That file is too large."}, status=413) + + if request.method == "GET": + if chunk_exists(request.user.pk, upload_id, chunk_number): + return HttpResponse(status=200) + return HttpResponse(status=204) + + uploaded = request.FILES.get("file") + if not uploaded: + return JsonResponse({"error": "No chunk provided"}, status=400) + if uploaded.size > upload_chunk_bytes() + (256 * 1024): + return JsonResponse({"error": "Chunk too large."}, status=400) + + save_chunk(request.user.pk, upload_id, chunk_number, uploaded) + + if not all_chunks_present(request.user.pk, upload_id, total_chunks): + return JsonResponse({"success": True, "complete": False}) + + saved = assemble_chunks(request.user.pk, upload_id, total_chunks, filename) + return JsonResponse( + { + "success": True, + "complete": True, + "url": saved.image.url, + "markdown": saved.markdown_link, + "filename": saved.filename, + } + ) - # Create BlogImage instance - blog_image = BlogImage.objects.create( - image=image_file, uploaded_by=request.user, filename=image_file.name - ) - return JsonResponse( - { - "success": True, - "url": blog_image.image.url, - "markdown_link": blog_image.markdown_link, - "filename": blog_image.filename, - } +@require_POST +def upload_file(request): + """ + Small single-shot upload (kept for tests / simple clients). + The editor uses upload_chunk for large files. + """ + if not request.user.is_authenticated: + return JsonResponse({"error": "Authentication required"}, status=401) + + uploaded = request.FILES.get("file") or request.FILES.get("image") + if not uploaded: + return JsonResponse({"error": "No file provided"}, status=400) + if uploaded.size > max_upload_bytes(): + return JsonResponse({"error": "That file is too large."}, status=413) + + safe_name = clean_upload_filename(uploaded.name) + if not safe_name: + return JsonResponse({"error": "That file type can't be uploaded."}, status=400) + + uploaded.name = safe_name + saved = BlogImage.objects.create( + image=uploaded, uploaded_by=request.user, filename=safe_name + ) + return JsonResponse( + { + "success": True, + "url": saved.image.url, + "markdown": saved.markdown_link, + "filename": saved.filename, + } + ) + + +class VRChatListView(BlogListView): + """Published posts tagged VRChat, using the normal blog list.""" + + def get_queryset(self): + queryset = ( + BlogPost.objects.filter(status="published") + .filter(Q(tags__slug__iexact="vrchat") | Q(tags__name__iexact="VRChat")) + .select_related("author") + .prefetch_related("tags") + .distinct() ) + search_query = self.request.GET.get("search") + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) | Q(content__icontains=search_query) + ).distinct() + return queryset - except Exception as e: - return JsonResponse({"error": str(e)}, status=500) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_title"] = "VRChat" + context["recent_posts"] = self.get_queryset()[:5] + return context class BlogRSSFeed(Feed): diff --git a/apps/comics/static/css/comics.css b/apps/comics/static/css/comics.css index 5b57bbf..be6618f 100644 --- a/apps/comics/static/css/comics.css +++ b/apps/comics/static/css/comics.css @@ -614,16 +614,17 @@ .comic-discussion-section .btn { display: inline-block; padding: 0.75em 1.5em; - background: var(--link-default); - color: white; + background: transparent; + color: var(--link-default); text-decoration: none; - border-radius: 4px; - transition: background-color 0.2s ease; + border: 1px solid var(--text-color); + border-radius: 0; } .comic-discussion-section .btn:hover { - background: var(--link-hover); - color: white; + text-decoration: underline; + color: var(--link-hover); + background: transparent; } .comic-discussion-section em { @@ -653,14 +654,16 @@ } .staff-actions .btn-secondary { - background: var(--text-color); - color: var(--background-color); + background: transparent; + color: var(--link-default); + border: 1px solid var(--text-color); margin-top: 0.5em; } .staff-actions .btn-secondary:hover { - background: var(--text-hover); - color: var(--background-color); + text-decoration: underline; + color: var(--link-hover); + background: transparent; } /* Markdown Content Styling */ diff --git a/apps/notifications/utils.py b/apps/notifications/utils.py index f1ba2a2..7cf97b9 100644 --- a/apps/notifications/utils.py +++ b/apps/notifications/utils.py @@ -1,52 +1,30 @@ -""" -Notification utilities for Django apps -Provides easy ways to show notifications from Python code -""" +"""Flash messages for views. Uses Django's messages framework.""" +from django.contrib import messages -def show_notification(request, message, type="info", duration=0): - """ - Show a notification by adding it to the request session - - Usage: - from apps.notifications.utils import show_notification - - def my_view(request): - show_notification(request, "Success!", "success") - return render(request, "template.html") - """ - if not hasattr(request, "notifications"): - request.notifications = [] - - if duration == 0: - duration = get_default_duration(type) - notification = {"message": message, "type": type, "duration": duration} - - request.notifications.append(notification) +def show_notification(request, message, type="info", duration=0): + level = { + "success": messages.SUCCESS, + "error": messages.ERROR, + "warning": messages.WARNING, + "info": messages.INFO, + "debug": messages.DEBUG, + }.get(type, messages.INFO) + messages.add_message(request, level, message) def show_success_notification(request, message, duration=5000): - """Show a success notification""" show_notification(request, message, "success", duration) def show_error_notification(request, message, duration=15000): - """Show an error notification""" show_notification(request, message, "error", duration) def show_warning_notification(request, message, duration=10000): - """Show a warning notification""" show_notification(request, message, "warning", duration) def show_info_notification(request, message, duration=8000): - """Show an info notification""" show_notification(request, message, "info", duration) - - -def get_default_duration(type): - """Get default duration for notification type""" - durations = {"success": 5000, "error": 15000, "warning": 10000, "info": 8000} - return durations.get(type, 8000) diff --git a/bin/entrypoint.sh b/bin/entrypoint.sh index c069905..ae106eb 100755 --- a/bin/entrypoint.sh +++ b/bin/entrypoint.sh @@ -18,6 +18,6 @@ exec gunicorn snowsune.wsgi:application \ --worker-class gthread \ --workers 3 \ --threads 25 \ - --timeout 120 \ + --timeout 600 \ --access-logfile - \ --error-logfile - \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index aa126b8..321fc9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ pytz # WSGI gunicorn +django-cleanup diff --git a/snowsune/management/commands/ensure_sitemap.py b/snowsune/management/commands/ensure_sitemap.py index 3c15462..b82e3ef 100644 --- a/snowsune/management/commands/ensure_sitemap.py +++ b/snowsune/management/commands/ensure_sitemap.py @@ -59,6 +59,14 @@ def handle(self, *args, **options): daily 0.9 + + + + https://{domain}/vrchat/ + {timezone.now().strftime('%Y-%m-%d')} + daily + 0.9 + diff --git a/snowsune/settings.py b/snowsune/settings.py index ddc253b..76dc754 100644 --- a/snowsune/settings.py +++ b/snowsune/settings.py @@ -84,6 +84,7 @@ "apps.custompages", # Custom pages with markdown "apps.tanks_manager", # Per-user tank pages + editor "apps.vore_day_rumble", # Vixi's Vore Day Rumble + "django_cleanup.apps.CleanupConfig", # Deletes FileField/ImageField files when their rows are removed. ] MIDDLEWARE = [ @@ -204,6 +205,20 @@ MEDIA_URL = "/media/" MEDIA_ROOT = os.environ.get("MEDIA_ROOT", str(BASE_DIR / "media")) +# Uploads larger than this are streamed to disk. +# BLOG_MAX_UPLOAD_BYTES is the total file cap. The editor uploads in +# BLOG_UPLOAD_CHUNK_BYTES pieces so a reverse proxy only needs to allow +# one chunk (plus a little multipart overhead), not the whole file. +FILE_UPLOAD_MAX_MEMORY_SIZE = int( + os.environ.get("FILE_UPLOAD_MAX_MEMORY_SIZE", str(10 * 1024 * 1024)) +) +BLOG_MAX_UPLOAD_BYTES = int( + os.environ.get("BLOG_MAX_UPLOAD_BYTES", str(2 * 1024 * 1024 * 1024)) +) +BLOG_UPLOAD_CHUNK_BYTES = int( + os.environ.get("BLOG_UPLOAD_CHUNK_BYTES", str(4 * 1024 * 1024)) +) + # Default primary key field type DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/snowsune/urls.py b/snowsune/urls.py index 8962d18..db9efe0 100644 --- a/snowsune/urls.py +++ b/snowsune/urls.py @@ -14,6 +14,7 @@ from snowsune.views.randal_fanclub import RandalFanclubView from snowsune.views.calendar import CalendarView, CalendarEventsAPIView from snowsune.views.health import health_check +from apps.blog.views import VRChatListView from snowsune.views.redirects import matrix_redirect from apps.thank_yous.views import thank_you_view @@ -46,6 +47,7 @@ ), # "app" urls path("blog/", include("apps.blog.urls")), + path("vrchat/", VRChatListView.as_view(), name="vrchat"), path("comics/", include("apps.comics.urls")), path("commorganizer/", include("apps.commorganizer.urls")), path("characters/", include("apps.characters.urls")), diff --git a/static/css/base.css b/static/css/base.css index 3f1268d..12bd50b 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -302,27 +302,26 @@ a:visited { display: inline-flex; align-items: center; gap: 0.4em; - background: var(--accent-color); - color: var(--invert-text-color); + background: transparent; + color: var(--link-default); font-weight: 600; - border-radius: var(--radius-lg); + border-radius: 0; padding: 0.35em 1em 0.35em 0.8em; font-size: 1em; text-decoration: none; margin-bottom: 0; margin-right: 0; - box-shadow: 0 2px 8px var(--shadow-color); - transition: transform 0.12s, box-shadow 0.12s, background 0.12s; + box-shadow: none; cursor: pointer; - border: none; + border: 1px solid var(--text-color); font-family: inherit; } .matrix-navbar-btn:visited { - color: var(--invert-text-color); + color: var(--link-visited); } .login .matrix-navbar-btn { - background: var(--accent-color); - color: var(--invert-text-color); + background: transparent; + color: var(--link-default); margin-bottom: 0.5em; } .login a:not(.matrix-navbar-btn) { @@ -421,26 +420,26 @@ a:visited { align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; - background: var(--link-default); - color: var(--invert-text-color); + background: transparent; + color: var(--link-default); text-decoration: none; - border-radius: var(--radius-lg); + border: 1px solid var(--text-color); + border-radius: 0; font-size: 0.9rem; font-weight: 500; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + box-shadow: none; } .footer-credit-link:visited { - color: var(--invert-text-color); + color: var(--link-visited); } .footer-credit-link:hover { - background: var(--link-hover); - color: white; - text-decoration: none; - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + background: transparent; + color: var(--link-hover); + text-decoration: underline; + transform: none; + box-shadow: none; } .footer-credit-icon { @@ -465,6 +464,7 @@ a:visited { /* Content Section */ .content { flex: 3; /* Take up more space compared to the sidebar */ + min-width: 0; /* Don't let wide images expand the column */ background: var(--background-color); /* White background for content */ min-height: 60vh; } @@ -530,20 +530,15 @@ a:visited { } .matrix-navbar-btn:hover { - background: var(--accent-hover); - color: var(--invert-text-color); - transform: scale(1.06); - box-shadow: 0 4px 16px var(--shadow-hover); - text-decoration: none; + text-decoration: underline; + color: var(--link-hover); + background: transparent; + transform: none; + box-shadow: none; } .matrix-navbar-icon { font-size: 1.2em; margin-right: 0.2em; - animation: matrix-pulse 1.2s infinite alternate; -} -@keyframes matrix-pulse { - 0% { filter: brightness(1); } - 100% { filter: brightness(1.25); } } /* Responsive Navbar */ @@ -582,10 +577,10 @@ a:visited { /* Login buttons in mobile menu */ .nav-mobile-links .user-auth-btn { display: inline-block; - background: #1e6f9c; - color: #fff; - border: none; - border-radius: var(--radius-md); + background: transparent; + color: var(--link-default); + border: 1px solid var(--text-color); + border-radius: 0; padding: 0.6em 1em; font-size: 1em; font-weight: 600; @@ -596,7 +591,9 @@ a:visited { box-sizing: border-box; } .nav-mobile-links .user-auth-btn:hover { - background: #155a7a; + background: transparent; + color: var(--link-hover); + text-decoration: underline; } .nav-mobile-links .user-auth-btn:last-of-type { border-bottom: 1px solid #fff2; @@ -701,27 +698,26 @@ a:visited { } } -/* User Auth Buttons (login/register) */ +/* User Auth Buttons (login/register/edit/logout) */ .user-auth-btn { - background: #1e6f9c; - color: #fff; - border: none; - border-radius: var(--radius-md); + background: transparent; + color: var(--link-default); + border: 1px solid var(--text-color); + border-radius: 0; padding: 0.4em 1.1em; font-size: 1em; font-weight: 600; margin: 0 0.2em; text-decoration: none; - transition: background 0.15s, box-shadow 0.15s; - box-shadow: 0 2px 8px rgba(30,111,156,0.08); + box-shadow: none; cursor: pointer; display: inline-block; } .user-auth-btn:hover, .user-auth-btn:focus { - background: #155a7a; - color: #fff; - text-decoration: none; - box-shadow: 0 4px 16px rgba(30,111,156,0.18); + background: transparent; + color: var(--link-hover); + text-decoration: underline; + box-shadow: none; } /* Latest Blog Post Section */ @@ -817,11 +813,11 @@ a:visited { color: var(--link-default); text-decoration: none; font-weight: 500; - transition: color 0.2s; } .read-more:hover { color: var(--link-hover); + text-decoration: underline; } /* Multiple blog posts layout */ @@ -848,17 +844,12 @@ a:visited { color: var(--link-default); text-decoration: none; font-weight: 500; - padding: 0.5em 1em; - border: 2px solid var(--link-default); - border-radius: var(--radius-lg); - transition: all 0.2s ease; } .blog-section-footer a:hover { color: var(--link-hover); - border-color: var(--link-hover); - background: var(--link-hover); - color: white; + text-decoration: underline; + background: transparent; } /* Home page specific styling */ @@ -954,150 +945,28 @@ a:visited { } } -/* Centralized Notification System Styling */ -.notifications-container { - position: fixed; - top: 20px; - right: 20px; - z-index: 1000; - max-width: 400px; - width: 100%; -} - -.notification { - margin-bottom: 10px; - border-radius: var(--radius-lg); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - animation: slideInRight 0.3s ease-out; - overflow: hidden; - transform: translateX(100%); - opacity: 0; - transition: all 0.3s ease; -} - -.notification.show { - transform: translateX(0); - opacity: 1; -} - -.notification.hiding { - transform: translateX(100%); - opacity: 0; -} - -.notification-content { - display: flex; - align-items: center; - padding: 12px 16px; - gap: 12px; -} - -.notification-icon { - font-size: 1.2em; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.notification-sticker { - width: 44px; - height: 44px; - object-fit: contain; - display: block; - flex-shrink: 0; -} - -.notification-text { - flex: 1; - font-size: 0.95em; - line-height: 1.4; -} - -.notification-close { - background: none; - border: none; - font-size: 1.5em; - cursor: pointer; +/* Inline flash messages (no toast popups) */ +.site-messages { + list-style: none; + margin: 0 0 1em; padding: 0; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-lg); - transition: background-color 0.2s ease; - flex-shrink: 0; -} - -.notification-close:hover { - background-color: rgba(0, 0, 0, 0.1); -} - -/* Notification Types */ -.notification-success { - background: linear-gradient(135deg, #28a745, #20c997); - color: white; -} - -.notification-error { - background: linear-gradient(135deg, #dc3545, #e83e8c); - color: white; } -.notification-warning { - background: linear-gradient(135deg, #ffc107, #fd7e14); - color: #212529; -} - -.notification-info { - background: linear-gradient(135deg, #17a2b8, #6f42c1); - color: white; -} - -/* Notification Animations */ -@keyframes slideInRight { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } +.site-message { + margin: 0 0 0.35em; + padding: 0; + font-size: 0.95rem; } -@keyframes slideOutRight { - from { - transform: translateX(0); - opacity: 1; - } - to { - transform: translateX(100%); - opacity: 0; - } +.site-message-error { + color: #b00020; } -/* Responsive Notifications */ -@media (max-width: 768px) { - .notifications-container { - left: 20px; - right: 20px; - max-width: none; - } - - .notification { - margin-bottom: 8px; - } - - .notification-content { - padding: 10px 14px; - gap: 10px; - } - - .notification-text { - font-size: 0.9em; - } +.site-message-success, +.site-message-info, +.site-message-warning, +.site-message-debug { + color: var(--text-color); } /* Terms of Service page */ diff --git a/static/css/markdown-editor.css b/static/css/markdown-editor.css index 86e597c..1b8bd77 100644 --- a/static/css/markdown-editor.css +++ b/static/css/markdown-editor.css @@ -20,22 +20,21 @@ .toolbar-btn { padding: 0.25em 0.5em; - background: var(--tile-background); - color: var(--text-color); + background: transparent; + color: var(--link-default); border: 1px solid var(--text-color); - border-radius: 4px; + border-radius: 0; cursor: pointer; font-size: 0.75rem; - transition: all 0.2s; - white-space: nowrap; /* Prevent button text from wrapping */ - flex-shrink: 0; /* Prevent buttons from shrinking */ - max-width: 100%; /* Ensure buttons don't exceed container */ + white-space: nowrap; + flex-shrink: 0; + max-width: 100%; } .toolbar-btn:hover { - background: var(--link-default); - color: white; - border-color: var(--link-default); + text-decoration: underline; + color: var(--link-hover); + background: transparent; } /* Content textarea */ @@ -88,6 +87,37 @@ font-size: 0.875rem; } +.upload-hint { + margin-top: 0.35em !important; +} + +.blog-file-input { + display: none; +} + +.upload-progress { + height: 6px; + margin: 0.75em auto 0; + max-width: 240px; + background: var(--tile-background); + border: 1px solid var(--text-color); +} + +.upload-progress-bar { + height: 100%; + width: 0; + background: var(--link-default); +} + +.upload-status { + margin: 0.5em 0 0; + font-size: 0.875rem; +} + +#blogFileButton { + margin-top: 0.75em; +} + /* Current image display */ .current-image { margin-top: 0.5em; diff --git a/static/css/tools.css b/static/css/tools.css index ed1ecb3..1573cb0 100644 --- a/static/css/tools.css +++ b/static/css/tools.css @@ -47,15 +47,11 @@ .tool-card { background: var(--tile-background); border: 2px solid var(--border-color); - border-radius: 12px; + border-radius: 0; overflow: hidden; - transition: all 0.3s ease; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .tool-card:hover { - transform: translateY(-4px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); border-color: var(--accent-color); } @@ -95,52 +91,51 @@ .tool-button { display: inline-block; - background: var(--accent-color); - color: var(--invert-text-color); + background: transparent; + color: var(--link-default); padding: 0.8rem 1.5rem; text-decoration: none; - border-radius: 8px; + border-radius: 0; font-weight: 600; - transition: all 0.3s ease; - border: 2px solid var(--accent-color); + border: 1px solid var(--text-color); } .tool-button:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); - transform: translateY(-2px); + text-decoration: underline; + color: var(--link-hover); + background: transparent; } .tool-button.external { - background: var(--link-default); - border-color: var(--link-default); + background: transparent; + border-color: var(--text-color); } .tool-button.external:hover { - background: var(--link-hover); - border-color: var(--link-hover); + background: transparent; + border-color: var(--text-color); + text-decoration: underline; } .tool-button:visited { - color: var(--invert-text-color); + color: var(--link-visited); } .tool-button.external:visited { - color: var(--invert-text-color); + color: var(--link-visited); } /* Formal/Boring Tool Styles */ .tool-card.formal-tool { border: 1px solid var(--border-color); - border-radius: 6px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); - transition: all 0.2s ease; + border-radius: 0; + box-shadow: none; } .tool-card.formal-tool:hover { transform: none; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1); - border-color: var(--border-color); + box-shadow: none; + border-color: var(--accent-color); } .tool-card.formal-tool .tool-image { @@ -178,9 +173,10 @@ } .tool-card.formal-tool .tool-button:hover { - background: var(--border-hover); - border-color: var(--accent-color); - transform: none; + background: transparent; + border-color: var(--text-color); + text-decoration: underline; + color: var(--link-hover); } /* NSFW tools block */ diff --git a/static/css/users.css b/static/css/users.css index 79618b0..6c435fd 100644 --- a/static/css/users.css +++ b/static/css/users.css @@ -233,19 +233,20 @@ .edit-profile-btn { display: inline-block; - background: var(--accent-color); - color: var(--invert-text-color); + background: transparent; + color: var(--link-default); padding: 0.6rem 1.2rem; text-decoration: none; - border-radius: 4px; + border-radius: 0; font-weight: 600; - transition: all 0.2s ease; - border: 2px solid var(--accent-color); + border: 1px solid var(--text-color); } .edit-profile-btn:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); + background: transparent; + color: var(--link-hover); + text-decoration: underline; + border-color: var(--text-color); } /* Mobile */ diff --git a/templates/base.html b/templates/base.html index ec70f92..4db5163 100644 --- a/templates/base.html +++ b/templates/base.html @@ -143,12 +143,14 @@ Home Apps/Tools Blog + Webcomic - - diff --git a/templates/includes/django_messages.html b/templates/includes/django_messages.html index bd06f1c..1e1a9d0 100644 --- a/templates/includes/django_messages.html +++ b/templates/includes/django_messages.html @@ -1,10 +1,7 @@ {% if messages %} -{# Consumed by base.html → notifications.js as toasts; kept off-screen until then #} - + {% endif %}