aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-07-16 20:07:20 +0530
committerBobby <[email protected]>2025-07-16 20:07:20 +0530
commit2255bbed94e1459788203a92b5f0eec5370abcab (patch)
tree36ec94fb7370235ca3f7e22f892a191b82650d47
parentbb54eaf6623acdcfb2e9056eb803260dff2150a5 (diff)
downloadimageboard-2255bbed94e1459788203a92b5f0eec5370abcab.tar.xz
imageboard-2255bbed94e1459788203a92b5f0eec5370abcab.zip
upload ui for images; registered users can upload
-rw-r--r--controllers/posts.go70
-rw-r--r--processors/preferences.go2
-rw-r--r--router/routes.go1
-rw-r--r--static/css/main.css183
-rw-r--r--static/scripts/errorControls.js11
-rw-r--r--static/scripts/preferences.js12
-rw-r--r--static/scripts/upload.js340
-rw-r--r--templates/layouts/main.django6
-rw-r--r--templates/posts/new.django22
-rw-r--r--templates/preferences.django1
-rw-r--r--utils/format/format.go7
-rw-r--r--utils/validators/links.go12
12 files changed, 639 insertions, 28 deletions
diff --git a/controllers/posts.go b/controllers/posts.go
index c14d793..6a2d01a 100644
--- a/controllers/posts.go
+++ b/controllers/posts.go
@@ -4,7 +4,12 @@ import (
"imageboard/config"
"imageboard/database"
"imageboard/utils/auth"
+ "imageboard/utils/format"
"imageboard/utils/shortcuts"
+ "imageboard/utils/validators"
+ "io"
+ "net/http"
+ "strings"
"github.com/gofiber/fiber/v2"
)
@@ -57,5 +62,68 @@ func PostsUploadPageController(ctx *fiber.Ctx) error {
return nil
}
- return shortcuts.Render(ctx, config.TEMPLATE_POST_NEW, nil)
+ allowedTypes := []string{}
+ for t := range strings.SplitSeq(config.Upload.AllowedTypes, ",") {
+ if idx := strings.Index(t, "/"); idx != -1 && idx+1 < len(t) {
+ subtype := t[idx+1:]
+ if subtype != "" {
+ allowedTypes = append(allowedTypes, "."+subtype)
+ }
+ }
+ }
+
+ return shortcuts.Render(ctx, config.TEMPLATE_POST_NEW, fiber.Map{
+ "AllowedTypes": allowedTypes,
+ "MaxSize": format.FileSize(int64(config.Upload.MaxSize)),
+ })
+}
+
+func PostsUploadImageLinkProxyController(ctx *fiber.Ctx) error {
+ maxSize := int64(config.Upload.MaxSize)
+ if !auth.IsAuthenticated(ctx) {
+ return fiber.NewError(fiber.StatusForbidden, "Forbidden")
+ }
+
+ url := ctx.Query("url")
+ if url == "" {
+ return fiber.NewError(fiber.StatusBadRequest, "Missing url parameter")
+ }
+
+ client := &http.Client{}
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return fiber.NewError(fiber.StatusBadRequest, "Invalid URL")
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36")
+
+ referer := validators.GetRefererForURL(url)
+ if referer != "" {
+ req.Header.Set("Referer", referer)
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return fiber.NewError(fiber.StatusBadGateway, "Failed to fetch image")
+ }
+ if resp.StatusCode != 200 {
+ return fiber.NewError(fiber.StatusBadGateway, "Failed to fetch image")
+ }
+ defer resp.Body.Close()
+
+ contentType := resp.Header.Get("Content-Type")
+ if !strings.HasPrefix(contentType, "image/") {
+ return fiber.NewError(fiber.StatusBadRequest, "URL does not point to an image")
+ }
+
+ ctx.Set("Content-Type", contentType)
+ ctx.Set("Cache-Control", "no-store")
+ buf, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fiber.NewError(fiber.StatusInternalServerError, "Failed to read image data")
+ }
+ if int64(len(buf)) > maxSize {
+ return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Image exceeds maximum allowed size of "+format.FileSize(maxSize))
+ }
+ return ctx.Send(buf)
}
diff --git a/processors/preferences.go b/processors/preferences.go
index a74d1e5..21b7c16 100644
--- a/processors/preferences.go
+++ b/processors/preferences.go
@@ -10,7 +10,7 @@ import (
func PreferencesContextProcessor(context *fiber.Ctx) error {
defaultPreferences := config.SitePreferences{
- SidebarWidth: "180px",
+ SidebarWidth: "220px",
MainContentWidth: "1200px",
H1FontSize: "16px",
BodyFontSize: "13px",
diff --git a/router/routes.go b/router/routes.go
index b074103..8c97318 100644
--- a/router/routes.go
+++ b/router/routes.go
@@ -13,6 +13,7 @@ func Initialize(router *fiber.App) {
posts := router.Group("/posts")
posts.Get("/", controllers.PostsPageController)
posts.Get("/new", controllers.PostsUploadPageController)
+ posts.Get("/new/ilinkfetch", controllers.PostsUploadImageLinkProxyController)
login := router.Group("/login")
login.Get("/", controllers.LoginPageController)
diff --git a/static/css/main.css b/static/css/main.css
index 51d2206..f9e3b4e 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -134,6 +134,11 @@ main {
padding: 10px;
}
+.content-main h1 {
+ color: #ff99cc;
+ margin-bottom: 8px;
+}
+
.centered-main {
display: flex;
flex-direction: column;
@@ -143,17 +148,21 @@ main {
min-height: 580px;
}
-.centered-main h1 {
- color: #ffccff;
- margin: 8px 0px;
+.centered-horizontal {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
}
-.content-main h1 {
- color: #ff99cc;
- margin-bottom: 8px;
+.centered-main h1,
+.centered-horizontal h1 {
+ color: #ffccff;
+ margin: 8px 0px;
}
-.centered-main p {
+.centered-main p,
+.centered-horizontal p {
color: #99ffcc;
}
@@ -187,7 +196,8 @@ main {
input[type="text"],
input[type="email"],
input[type="password"],
-input[type="number"] {
+input[type="number"],
+input[type="url"] {
background-color: #1a0033;
border: 1px solid #9999ff;
color: #ccccff;
@@ -197,13 +207,15 @@ input[type="number"] {
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
-input[type="number"]:focus {
+input[type="number"]:focus,
+input[type="url"]:focus {
border-color: #ff99cc;
background-color: #260040;
outline: none;
}
input[type="button"],
+button[type="button"],
input[type="submit"] {
background-color: #330066;
border: 1px solid #9999ff;
@@ -213,6 +225,7 @@ input[type="submit"] {
}
input[type="button"]:hover,
+button[type="button"]:hover,
input[type="submit"]:hover {
background-color: #ff99cc;
color: #1a001a;
@@ -379,4 +392,156 @@ footer::before {
padding: 8px;
margin-bottom: 16px;
text-align: center;
+}
+
+.upload-drag-box {
+ border: 2px dashed #9999ff;
+ background-color: #1a0033;
+ padding-top: 20px;
+ text-align: center;
+ cursor: pointer;
+ width: 768px;
+ margin: 24px 0px 12px 0px;
+}
+
+.upload-drag-box.dragover {
+ border-color: #ff99cc;
+ background-color: #260040;
+ transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
+}
+
+.upload-drag-box h1 {
+ margin: 0;
+}
+
+.upload-drag-box p {
+ margin: 8px 0 12px 0;
+}
+
+.upload-drag-box small {
+ display: block;
+ border-top: 1px dashed #9999ff;
+ margin-top: 20px;
+ padding-top: 10px;
+ padding-bottom: 10px;
+}
+
+.upload-area {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+.upload-via-link>form {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ align-items: center;
+ width: 768px;
+}
+
+.upload-via-link>form input[type="url"] {
+ flex: 1;
+ width: 100%;
+}
+
+.upload-previews {
+ margin-top: 16px;
+ width: 768px;
+}
+
+.preview-area {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.preview-image {
+ width: 96px;
+ height: 96px;
+ object-fit: cover;
+ border: 1px solid #9999ff;
+ flex-shrink: 0;
+}
+
+.preview-details {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.preview-link {
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ min-width: 0;
+}
+
+.preview-rating-form {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ align-items: center;
+}
+
+.preview-remove-btn {
+ display: inline-block;
+ width: auto;
+ align-self: flex-start;
+ max-width: 120px;
+}
+
+
+
+.upload-area {
+ position: relative;
+}
+
+.upload-drag-box {
+ position: relative;
+}
+
+.upload-loading-indicator {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(26, 0, 51, 0.85);
+ width: 100%;
+ height: 100%;
+ border-radius: 8px;
+ z-index: 20;
+ pointer-events: none;
+}
+
+.upload-loading-icon {
+ font-size: 2.5em;
+ color: #ff99cc;
+ filter: drop-shadow(0 0 8px #ff99cc88);
+ animation: uploadspin 1.2s linear infinite;
+}
+
+.upload-loading-icon {
+ font-size: 3em;
+ color: #ff99cc;
+ filter: drop-shadow(0 0 8px #ff99cc88);
+ animation: uploadspin 1.2s linear infinite;
+}
+
+@keyframes uploadspin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
} \ No newline at end of file
diff --git a/static/scripts/errorControls.js b/static/scripts/errorControls.js
new file mode 100644
index 0000000..8a00756
--- /dev/null
+++ b/static/scripts/errorControls.js
@@ -0,0 +1,11 @@
+function showError(message) {
+ const errorMessage = document.getElementById('error-message');
+ errorMessage.textContent = message;
+ errorMessage.style.display = 'block';
+}
+
+function hideError() {
+ const errorMessage = document.getElementById('error-message');
+ errorMessage.textContent = '';
+ errorMessage.style.display = 'none';
+} \ No newline at end of file
diff --git a/static/scripts/preferences.js b/static/scripts/preferences.js
index 61db579..66a7bb7 100644
--- a/static/scripts/preferences.js
+++ b/static/scripts/preferences.js
@@ -8,18 +8,6 @@ function validateCSSFontSize(value) {
return validFontSizePattern.test(value);
}
-function showError(message) {
- const errorMessage = document.getElementById('error-message');
- errorMessage.textContent = message;
- errorMessage.style.display = 'block';
-}
-
-function hideError() {
- const errorMessage = document.getElementById('error-message');
- errorMessage.textContent = '';
- errorMessage.style.display = 'none';
-}
-
function setPreferences() {
const preferences = {
sidebar_width: document.getElementById('sidebar-width').value,
diff --git a/static/scripts/upload.js b/static/scripts/upload.js
new file mode 100644
index 0000000..5d0f3ec
--- /dev/null
+++ b/static/scripts/upload.js
@@ -0,0 +1,340 @@
+/**
+ * Tracks all images to be uploaded (local files and link-fetched images).
+ * Key is either the file name (for local) or URL (for link).
+ * @type {Map<string, { blob: Blob, rating: string, previewElement: HTMLElement, type: 'local' | 'link', nameOrUrl: string }>}
+ */
+const imageBlobMapping = new Map();
+
+/**
+ * Shows or hides the Upload All button in the .upload-area container based on imageBlobMapping size.
+ * The button is created once and appended/removed as needed.
+ * @function
+ */
+let uploadAllBtn = null;
+function updateUploadAllBtn() {
+ const uploadArea = document.querySelector('.upload-area');
+ if (!uploadArea) return;
+ if (!uploadAllBtn) {
+ uploadAllBtn = document.createElement('button');
+ uploadAllBtn.type = 'button';
+ uploadAllBtn.textContent = 'Upload All';
+ uploadAllBtn.className = 'upload-all-btn';
+ /**
+ * TODO: Implement actual upload logic here
+ */
+ uploadAllBtn.onclick = function () {
+ alert('Upload All clicked!');
+ };
+ }
+ if (imageBlobMapping.size > 0) {
+ if (!uploadArea.contains(uploadAllBtn)) {
+ uploadArea.appendChild(uploadAllBtn);
+ }
+ } else {
+ if (uploadArea.contains(uploadAllBtn)) {
+ uploadArea.removeChild(uploadAllBtn);
+ }
+ }
+}
+
+/**
+ * Creates a preview element for an image (local or link).
+ * @param {string} key - The key for the image (filename or URL)
+ * @param {Blob} blob - The image blob
+ * @param {'local'|'link'} type - Type of image
+ * @param {string} nameOrUrl - Filename (local) or URL (link)
+ * @returns {HTMLDivElement}
+ */
+function createPreviewElement(key, blob, type, nameOrUrl) {
+ const previewElement = document.createElement('div');
+ previewElement.className = 'preview-area';
+
+ const previewImage = document.createElement('img');
+ previewImage.className = 'preview-image';
+ previewImage.src = URL.createObjectURL(blob);
+ previewElement.appendChild(previewImage);
+
+ const previewDetailsArea = document.createElement('div');
+ previewDetailsArea.className = 'preview-details';
+ previewElement.appendChild(previewDetailsArea);
+
+ if (type === 'link') {
+ const previewLink = document.createElement('a');
+ previewLink.className = 'preview-link';
+ previewLink.target = '_blank';
+ previewLink.href = nameOrUrl;
+ previewLink.textContent = nameOrUrl;
+ previewDetailsArea.appendChild(previewLink);
+ } else {
+ const previewFile = document.createElement('span');
+ previewFile.className = 'preview-link';
+ previewFile.textContent = nameOrUrl;
+ previewDetailsArea.appendChild(previewFile);
+ }
+
+ const previewRatingForm = document.createElement('form');
+ previewRatingForm.className = 'preview-rating-form';
+ ['Safe', 'Questionable', 'Sensitive', 'Explicit'].forEach((rating, idx) => {
+ const inputId = `rating-${rating.toLowerCase()}-${key}`;
+ const input = document.createElement('input');
+ input.type = 'radio';
+ input.name = `rating-${key}`;
+ input.value = rating.toLowerCase();
+ input.checked = idx === 0;
+ input.id = inputId;
+ const label = document.createElement('label');
+ label.textContent = rating;
+ label.setAttribute('for', inputId);
+ previewRatingForm.appendChild(input);
+ previewRatingForm.appendChild(label);
+ });
+ previewDetailsArea.appendChild(previewRatingForm);
+
+ const removeBtn = document.createElement('button');
+ removeBtn.type = 'button';
+ removeBtn.textContent = 'Remove';
+ removeBtn.className = 'preview-remove-btn';
+ removeBtn.onclick = () => {
+ previewElement.remove();
+ imageBlobMapping.delete(key);
+ updateUploadAllBtn();
+ };
+ previewDetailsArea.appendChild(removeBtn);
+
+ return previewElement;
+}
+
+window.onbeforeunload = function (_event) {
+ if (imageBlobMapping.size > 0) {
+ return 'Are you sure you want to leave? Data you have entered may not be saved.';
+ }
+};
+
+/**
+ * Shows or hides a retro loading indicator in the upload area.
+ * @param {boolean} show
+ */
+function setUploadLoadingIndicator(show) {
+ let indicator = document.getElementById('_uploadLoadingIndicator');
+ const dragBox = document.querySelector('.upload-drag-box');
+ if (!dragBox) return;
+ dragBox.style.position = 'relative';
+ if (!indicator) {
+ indicator = document.createElement('div');
+ indicator.id = '_uploadLoadingIndicator';
+ indicator.className = 'upload-loading-indicator';
+ indicator.innerHTML = '<span class="upload-loading-icon">✦</span>';
+ dragBox.appendChild(indicator);
+ }
+ indicator.style.display = show ? 'flex' : 'none';
+}
+
+/**
+ * Handles uploading an image via link using the backend proxy.
+ * Optimized and uses async/await.
+ * @returns {Promise<void>}
+ */
+async function uploadViaLink() {
+ const uploadViaLinkInputBox = document.getElementById('_uploadViaLink_InputBox');
+ const uploadViaLinkUploadPreviewsArea = document.getElementById('_uploadViaLink_UploadPreviewsArea');
+ const link = uploadViaLinkInputBox.value.trim();
+ hideError();
+
+ if (!link) {
+ showError('Please enter a valid image URL.');
+ return;
+ }
+ if (imageBlobMapping.has(link)) {
+ showError('This image has already been added.');
+ return;
+ }
+ setUploadLoadingIndicator(true);
+ try {
+ const proxyUrl = `/posts/new/ilinkfetch?url=${encodeURIComponent(link)}`;
+ const response = await fetch(proxyUrl);
+ if (!response.ok) {
+ let errorMsg = 'Failed to fetch the image from the provided URL.';
+ try {
+ const text = await response.text();
+ if (text && text !== 'Failed to fetch image') errorMsg = text;
+ } catch {
+ errorMsg = 'An error occurred while fetching the image.';
+ }
+ showError(errorMsg || 'An error occurred while fetching the image.');
+ return;
+ }
+ const contentType = response.headers.get('content-type') || '';
+ if (!contentType.startsWith('image/')) {
+ showError('The URL does not point to a valid image.');
+ return;
+ }
+ const blob = await response.blob();
+ if (!blob) {
+ showError('No image data received from the URL.');
+ return;
+ }
+ const previewElement = createPreviewElement(link, blob, 'link', link);
+ uploadViaLinkUploadPreviewsArea.appendChild(previewElement);
+ imageBlobMapping.set(link, { blob, rating: 'safe', previewElement, type: 'link', nameOrUrl: link });
+ updateUploadAllBtn();
+ } catch (error) {
+ console.error('Error fetching image:', error);
+ showError('An error occurred while fetching the image.');
+ } finally {
+ setUploadLoadingIndicator(false);
+ uploadViaLinkInputBox.value = '';
+ }
+}
+
+/**
+ * Handles drag-and-drop and click-to-select for local image files.
+ */
+function setupLocalImageUpload() {
+ const dragBox = document.querySelector('.upload-drag-box');
+ const previewsArea = document.getElementById('_uploadViaLink_UploadPreviewsArea');
+ const dragHeading = dragBox ? dragBox.querySelector('h1') : null;
+ if (!dragBox || !previewsArea || !dragHeading) return;
+
+ dragBox.addEventListener('dragover', function (e) {
+ e.preventDefault();
+ dragBox.classList.add('dragover');
+ dragHeading.textContent = 'Release to upload!';
+ });
+ dragBox.addEventListener('dragleave', function (e) {
+ e.preventDefault();
+ dragBox.classList.remove('dragover');
+ dragHeading.textContent = 'Drop files here or just click this box!';
+ });
+ dragBox.addEventListener('drop', async function (e) {
+ e.preventDefault();
+ dragBox.classList.remove('dragover');
+ dragHeading.textContent = 'Drop files here or just click this box!';
+ const files = e.dataTransfer.files;
+ if (files && files.length > 0) {
+ handleFiles(files);
+ } else {
+ // Try to get a URL from the drop
+ let url = '';
+ if (e.dataTransfer.items) {
+ for (let i = 0; i < e.dataTransfer.items.length; i++) {
+ const item = e.dataTransfer.items[i];
+ if (item.kind === 'string' && (item.type === 'text/uri-list' || item.type === 'text/plain')) {
+ item.getAsString(function (s) {
+ if (s && s.match(/^https?:\/\/.+/)) {
+ handleDroppedUrl(s);
+ }
+ });
+ return;
+ }
+ }
+ }
+ // Fallback for some browsers
+ url = e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain');
+ if (url && url.match(/^https?:\/\/.+/)) {
+ handleDroppedUrl(url);
+ }
+ }
+ });
+ dragBox.addEventListener('click', function () {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.multiple = true;
+ input.onchange = function () {
+ handleFiles(input.files);
+ };
+ input.click();
+ });
+}
+
+/**
+ * Handles dropped URLs, tries to upload if it's an image or fetch if not.
+ * @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') || '';
+ if (type.startsWith('image/')) {
+ uploadViaLinkDirect(url);
+ } else {
+ showError('Dropped URL is not a direct image. Try dragging the image itself, not the page.');
+ }
+ })
+ .catch(() => {
+ showError('Could not fetch dropped URL.');
+ });
+ }
+}
+
+/**
+ * Directly uploads an image via link (used for drag-drop URLs)
+ * @param {string} url
+ */
+async function uploadViaLinkDirect(url) {
+ setUploadLoadingIndicator(true);
+ const uploadViaLinkUploadPreviewsArea = document.getElementById('_uploadViaLink_UploadPreviewsArea');
+ if (imageBlobMapping.has(url)) {
+ showError('This image has already been added.');
+ return;
+ }
+ try {
+ const proxyUrl = `/posts/new/ilinkfetch?url=${encodeURIComponent(url)}`;
+ const response = await fetch(proxyUrl);
+ if (!response.ok) {
+ let errorMsg = 'Failed to fetch the image from the provided URL.';
+ try {
+ const text = await response.text();
+ if (text && text !== 'Failed to fetch image') errorMsg = text;
+ } catch {
+ errorMsg = 'An error occurred while fetching the image.';
+ }
+ showError(errorMsg || 'An error occurred while fetching the image.');
+ return;
+ }
+ const contentType = response.headers.get('content-type') || '';
+ if (!contentType.startsWith('image/')) {
+ showError('The URL does not point to a valid image.');
+ return;
+ }
+ const blob = await response.blob();
+ if (!blob) {
+ showError('No image data received from the URL.');
+ return;
+ }
+ const previewElement = createPreviewElement(url, blob, 'link', url);
+ uploadViaLinkUploadPreviewsArea.appendChild(previewElement);
+ imageBlobMapping.set(url, { blob, rating: 'safe', previewElement, type: 'link', nameOrUrl: url });
+ updateUploadAllBtn();
+ } catch (error) {
+ console.error('Error fetching image:', error);
+ showError('An error occurred while fetching the image.');
+ } finally {
+ setUploadLoadingIndicator(false);
+ }
+}
+
+/**
+ * Handles adding local files to the preview and mapping.
+ * @param {FileList} files
+ */
+function handleFiles(files) {
+ const previewsArea = document.getElementById('_uploadViaLink_UploadPreviewsArea');
+ for (const file of files) {
+ if (!file.type.startsWith('image/')) continue;
+ if (imageBlobMapping.has(file.name)) continue;
+ const previewElement = createPreviewElement(file.name, file, 'local', file.name);
+ previewsArea.appendChild(previewElement);
+ imageBlobMapping.set(file.name, { blob: file, rating: 'safe', previewElement, type: 'local', nameOrUrl: file.name });
+ }
+ updateUploadAllBtn();
+}
+
+setupLocalImageUpload();
+
diff --git a/templates/layouts/main.django b/templates/layouts/main.django
index 98bb733..8673b21 100644
--- a/templates/layouts/main.django
+++ b/templates/layouts/main.django
@@ -5,9 +5,9 @@
<title>{{ Title }} - {{ Appname }}</title>
<link rel="stylesheet" href="/static/css/main.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <link rel="apple-touch-icon" sizes="180x180" href="static/images/icons/apple-touch-icon.png" />
- <link rel="icon" type="image/png" sizes="32x32" href="static/images/icons/favicon-32x32.png" />
- <link rel="icon" type="image/png" sizes="16x16" href="static/images/icons/favicon-16x16.png" />
+ <link rel="apple-touch-icon" sizes="180x180" href="/static/images/icons/apple-touch-icon.png" />
+ <link rel="icon" type="image/png" sizes="32x32" href="/static/images/icons/favicon-32x32.png" />
+ <link rel="icon" type="image/png" sizes="16x16" href="/static/images/icons/favicon-16x16.png" />
<link rel="manifest" href="/static/extra/site.webmanifest" />
{{ PreferencesCSS|safe }}
</head>
diff --git a/templates/posts/new.django b/templates/posts/new.django
index d3a2e5e..15dae77 100644
--- a/templates/posts/new.django
+++ b/templates/posts/new.django
@@ -1,4 +1,24 @@
{% extends 'layouts/main.django' %}
{% block content %}
- Upload a new post here
+ <div class="centered-horizontal">
+ <div class="upload-area">
+ <div class="upload-drag-box">
+ <h1>Drop files here or just click this box!</h1>
+ <p>Supported formats: {{ AllowedTypes|join:', ' }}</p>
+ <small>Max size: {{ MaxSize }}</small>
+ </div>
+ <div class="upload-via-link">
+ <form id="uploadViaLinkForm" onsubmit="uploadViaLink(); return false;">
+ <input type="url" id="_uploadViaLink_InputBox" placeholder="alternatively, paste a URL here..." required />
+ <input type="submit" value="Add URL" />
+ </form>
+ </div>
+ <div class="error" style="display: none; margin: 8px 0; width: 512px;" id="error-message"></div>
+ <div id="_uploadViaLink_UploadPreviewsArea" class="upload-previews"></div>
+ </div>
+ </div>
+{% endblock %}
+{% block scripts %}
+ <script type="text/javascript" src="/static/scripts/errorControls.js" defer></script>
+ <script type="text/javascript" src="/static/scripts/upload.js" defer></script>
{% endblock %}
diff --git a/templates/preferences.django b/templates/preferences.django
index d8a153e..691cbdf 100644
--- a/templates/preferences.django
+++ b/templates/preferences.django
@@ -68,5 +68,6 @@
</div>
{% endblock %}
{% block scripts %}
+ <script type="text/javascript" src="/static/scripts/errorControls.js" defer></script>
<script type="text/javascript" src="/static/scripts/preferences.js" defer></script>
{% endblock %}
diff --git a/utils/format/format.go b/utils/format/format.go
index 53c813e..e57bc1f 100644
--- a/utils/format/format.go
+++ b/utils/format/format.go
@@ -13,7 +13,12 @@ func FileSize(size int64) string {
exp++
}
- return fmt.Sprintf("%.2f %sB", float64(size)/float64(div), "KMGTPE"[exp:exp+1])
+ val := float64(size) / float64(div)
+ unitStr := "KMGTPE"[exp : exp+1]
+ if val == float64(int64(val)) {
+ return fmt.Sprintf("%d %sB", int64(val), unitStr)
+ }
+ return fmt.Sprintf("%.2f %sB", val, unitStr)
}
func Count(count int64) string {
diff --git a/utils/validators/links.go b/utils/validators/links.go
new file mode 100644
index 0000000..cc9dd9b
--- /dev/null
+++ b/utils/validators/links.go
@@ -0,0 +1,12 @@
+package validators
+
+import "strings"
+
+func GetRefererForURL(url string) string {
+ switch {
+ case strings.Contains(url, "i.pximg.net") || strings.Contains(url, "pixiv.net"):
+ return "https://www.pixiv.net/"
+ default:
+ return ""
+ }
+}