Add video support

This commit is contained in:
Dominic Ferrando
2025-04-22 00:06:11 -04:00
parent fac517b28d
commit 741ac24e3e
15 changed files with 236 additions and 102 deletions
+2 -2
View File
@@ -2,5 +2,5 @@ internal/database/comfychan.db
**/*_templ.go **/*_templ.go
tmp tmp
web/static/img/posts/ web/static/media/posts/
web/static/img/posts/ web/static/media/posts/
+1 -1
View File
@@ -20,4 +20,4 @@ db/seed/f:
rm ./internal/database/comfychan.db && sqlite3 ./internal/database/comfychan.db < ./internal/database/seed.sql rm ./internal/database/comfychan.db && sqlite3 ./internal/database/comfychan.db < ./internal/database/seed.sql
img/clear: img/clear:
rm -rf ./web/static/img/posts || true rm -rf ./web/static/media/posts || true
+11 -11
View File
@@ -93,7 +93,7 @@ func GetThread(db *sql.DB, threadId int) (Thread, error) {
func GetPosts(db *sql.DB, threadId int) ([]Post, error) { func GetPosts(db *sql.DB, threadId int) ([]Post, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, thread_id, author, body, created_at, media_path, ip_hash, number SELECT id, thread_id, author, body, created_at, media_path, ip_hash, number, thumb_path
FROM posts FROM posts
WHERE thread_id = ?`, threadId) WHERE thread_id = ?`, threadId)
@@ -105,7 +105,7 @@ func GetPosts(db *sql.DB, threadId int) ([]Post, error) {
var result []Post var result []Post
for rows.Next() { for rows.Next() {
var p Post var p Post
err := rows.Scan(&p.Id, &p.ThreadId, &p.Author, &p.Body, &p.CreatedAt, &p.MediaPath, &p.IpHash, &p.Number) err := rows.Scan(&p.Id, &p.ThreadId, &p.Author, &p.Body, &p.CreatedAt, &p.MediaPath, &p.IpHash, &p.Number, &p.ThumbPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -117,20 +117,20 @@ func GetPosts(db *sql.DB, threadId int) ([]Post, error) {
func GetOriginalPost(db *sql.DB, threadId int) (Post, error) { func GetOriginalPost(db *sql.DB, threadId int) (Post, error) {
row := db.QueryRow(` row := db.QueryRow(`
SELECT id, thread_id, author, body, created_at, media_path, ip_hash, number SELECT id, thread_id, author, body, created_at, media_path, ip_hash, number, thumb_path
FROM posts FROM posts
WHERE thread_id = ? WHERE thread_id = ?
ORDER BY created_at ASC LIMIT 1`, threadId) ORDER BY created_at ASC LIMIT 1`, threadId)
var r Post var r Post
err := row.Scan(&r.Id, &r.ThreadId, &r.Author, &r.Body, &r.CreatedAt, &r.MediaPath, &r.IpHash, &r.Number) err := row.Scan(&r.Id, &r.ThreadId, &r.Author, &r.Body, &r.CreatedAt, &r.MediaPath, &r.IpHash, &r.Number, &r.ThumbPath)
if err != nil { if err != nil {
return Post{}, err return Post{}, err
} }
return r, row.Err() return r, row.Err()
} }
func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaPath string, ip_hash string) error { func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaPath string, thumbPath string, ip_hash string) error {
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return err return err
@@ -150,7 +150,7 @@ func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaP
return err return err
} }
if err := PutPost(tx, boardSlug, int(threadId), body, mediaPath, ip_hash); err != nil { if err := PutPost(tx, boardSlug, int(threadId), body, mediaPath, thumbPath, ip_hash); err != nil {
return err return err
} }
@@ -188,7 +188,7 @@ func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaP
return nil return nil
} }
func PutPost(db Queryer, boardSlug string, threadId int, body string, mediaPath string, ip_hash string) error { func PutPost(db Queryer, boardSlug string, threadId int, body string, mediaPath string, thumbPath string, ip_hash string) error {
row := db.QueryRow(` row := db.QueryRow(`
SELECT MAX(p.number) SELECT MAX(p.number)
FROM posts p FROM posts p
@@ -206,8 +206,8 @@ func PutPost(db Queryer, boardSlug string, threadId int, body string, mediaPath
} }
_, err := db.Exec(` _, err := db.Exec(`
INSERT INTO posts (thread_id, body, media_path, ip_hash, number) INSERT INTO posts (thread_id, body, media_path, ip_hash, number, thumb_path)
VALUES (?, ?, ?, ?, ?)`, threadId, body, mediaPath, ip_hash, newPostNumber) VALUES (?, ?, ?, ?, ?, ?)`, threadId, body, mediaPath, ip_hash, newPostNumber, thumbPath)
if err != nil { if err != nil {
return err return err
} }
@@ -241,10 +241,10 @@ func DeleteThread(db Queryer, threadId int) error {
} }
for _, pruneMediaPath := range pruneMediaPaths { for _, pruneMediaPath := range pruneMediaPaths {
if err := os.Remove(path.Join(util.POST_IMG_FULL_PATH, pruneMediaPath)); err != nil && !os.IsNotExist(err) { if err := os.Remove(path.Join(util.POST_MEDIA_FULL_PATH, pruneMediaPath)); err != nil && !os.IsNotExist(err) {
return err return err
} }
if err := os.Remove(path.Join(util.POST_IMG_THUMB_PATH, pruneMediaPath)); err != nil && !os.IsNotExist(err) { if err := os.Remove(path.Join(util.POST_MEDIA_THUMB_PATH, pruneMediaPath)); err != nil && !os.IsNotExist(err) {
return err return err
} }
} }
+1
View File
@@ -24,6 +24,7 @@ type Post struct {
Body string Body string
CreatedAt time.Time CreatedAt time.Time
MediaPath string MediaPath string
ThumbPath string
IpHash string IpHash string
Number int Number int
} }
+1
View File
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS posts (
body TEXT NOT NULL, body TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
media_path TEXT NOT NULL DEFAULT '', media_path TEXT NOT NULL DEFAULT '',
thumb_path TEXT NOT NULL DEFAULT '',
ip_hash TEXT NOT NULL, ip_hash TEXT NOT NULL,
FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
); );
+99 -31
View File
@@ -7,7 +7,9 @@ import (
"io" "io"
"log" "log"
"mime/multipart" "mime/multipart"
"net/http"
"os" "os"
"os/exec"
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -17,10 +19,12 @@ import (
// 10 MB memory limit // 10 MB memory limit
const FILE_MEM_LIMIT int64 = 10 << 20 const FILE_MEM_LIMIT int64 = 10 << 20
const POST_IMG_FULL_PATH = "web/static/img/posts/full" const POST_MEDIA_FULL_PATH = "web/static/media/posts/full"
const POST_IMG_THUMB_PATH = "web/static/img/posts/thumb" const POST_MEDIA_THUMB_PATH = "web/static/media/posts/thumb"
const MAX_THREAD_COUNT = 50 const MAX_THREAD_COUNT = 50
var SUPPORTED_VID_FORMATS = []string{".mp4", ".webm", ".ogg"}
func EnrichPost(body string) string { func EnrichPost(body string) string {
var b strings.Builder var b strings.Builder
for _, rawLine := range strings.Split(body, "\n") { for _, rawLine := range strings.Split(body, "\n") {
@@ -58,86 +62,142 @@ func EnrichPost(body string) string {
return b.String() return b.String()
} }
func SavePostFile(file *multipart.File, filename string) error { func SavePostFile(file multipart.File, fileName string) (error, string, string) {
dstPathFull := filepath.Join(POST_IMG_FULL_PATH, filename) // read file type
dstPathThumb := filepath.Join(POST_IMG_THUMB_PATH, filename) buffer := make([]byte, 512)
file.Read(buffer)
file.Seek(0, 0)
fileType := http.DetectContentType(buffer)
isFileVideo := strings.HasPrefix(fileType, "video/")
fileExt := strings.ToLower(filepath.Ext(fileName))
// FULL // FULL
if err := os.MkdirAll(POST_IMG_FULL_PATH, 0755); err != nil { if err := os.MkdirAll(POST_MEDIA_FULL_PATH, 0755); err != nil {
log.Printf("MkdirAll (full): %v", err) log.Printf("MkdirAll (full): %v", err)
return err return err, "", ""
} }
dstPathFull := filepath.Join(POST_MEDIA_FULL_PATH, fileName)
dstFull, err := os.Create(dstPathFull) dstFull, err := os.Create(dstPathFull)
if err != nil { if err != nil {
log.Printf("os.Create (full): %v", err) log.Printf("os.Create (full): %v", err)
return err return err, "", ""
} }
defer dstFull.Close() defer dstFull.Close()
if _, err := io.Copy(dstFull, *file); err != nil { if _, err := io.Copy(dstFull, file); err != nil {
log.Printf("io.Copy (full): %v", err) log.Printf("io.Copy (full): %v", err)
return err return err, "", ""
} }
if _, err := (*file).Seek(0, 0); err != nil { // rewind file if _, err := (file).Seek(0, 0); err != nil { // rewind file
log.Printf("seek file (thumb): %v", err) log.Printf("seek file (thumb): %v", err)
return err return err, "", ""
} }
// THUMBNAIL // THUMBNAIL
if err := os.MkdirAll(POST_IMG_THUMB_PATH, 0755); err != nil { dstPathThumb := filepath.Join(POST_MEDIA_THUMB_PATH, fileName)
if err := os.MkdirAll(POST_MEDIA_THUMB_PATH, 0755); err != nil {
log.Printf("MkdirAll (thumb): %v", err) log.Printf("MkdirAll (thumb): %v", err)
return err return err, "", ""
} }
img, _, err := image.Decode(*file) var thumbFileName string
if isFileVideo {
fileNameNoExt := strings.TrimSuffix(fileName, fileExt)
thumbFileName = fileNameNoExt + ".jpg"
inputPath := filepath.Join(POST_MEDIA_FULL_PATH, fileName)
outputPath := filepath.Join(POST_MEDIA_THUMB_PATH, thumbFileName)
cmd := exec.Command(
"ffmpeg",
"-i", inputPath,
"-ss", "00:00:01.000",
"-vframes", "1",
"-vf", "scale=300:-1",
outputPath,
)
if err := cmd.Run(); err != nil {
log.Printf("ffmpeg error: %v", err)
return err, "", ""
}
} else {
thumbFileName = fileName
img, _, err := image.Decode(file)
if err != nil { if err != nil {
log.Printf("image.Decode: %v", err) log.Printf("image.Decode: %v", err)
return err return err, "", ""
}
var thumb image.Image
if img.Bounds().Dx() > 300 {
thumb = imaging.Resize(img, 300, 0, imaging.Lanczos)
} else {
thumb = img
} }
thumb := imaging.Resize(img, 300, 0, imaging.Lanczos)
if err = imaging.Save(thumb, dstPathThumb); err != nil { if err = imaging.Save(thumb, dstPathThumb); err != nil {
log.Printf("imaging.Save: %v", err) log.Printf("imaging.Save: %v", err)
return err return err, "", ""
}
} }
return nil return nil, fileName, thumbFileName
} }
type PostImageInfo struct { type PostFileInfo struct {
Size int64 Size int64
Height int Height int
Width int Width int
IsVideo bool
} }
func GetPostImageInfo(mediaPath string) PostImageInfo { func GetPostFileInfo(mediaPath string) PostFileInfo {
var result PostImageInfo var result PostFileInfo
imagePath := path.Join(POST_IMG_FULL_PATH, mediaPath) filePath := path.Join(POST_MEDIA_FULL_PATH, mediaPath)
fileInfo, err := os.Stat(imagePath) fileInfo, err := os.Stat(filePath)
if err != nil { if err != nil {
log.Printf("Failed to get image file info at %s: %v", mediaPath, err) log.Printf("Failed to get file info at %s: %v", mediaPath, err)
return PostImageInfo{} return PostFileInfo{}
} }
file, err := os.Open(imagePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
log.Printf("Failed to open image at %s: %v", mediaPath, err) log.Printf("Failed to open file at %s: %v", mediaPath, err)
return PostImageInfo{} return PostFileInfo{}
} }
defer file.Close() defer file.Close()
// read file type
buffer := make([]byte, 512)
file.Read(buffer)
file.Seek(0, 0)
fileType := http.DetectContentType(buffer)
isFileVideo := strings.HasPrefix(fileType, "video/")
result.Size = fileInfo.Size()
if !isFileVideo {
cfg, _, err := image.DecodeConfig(file) cfg, _, err := image.DecodeConfig(file)
if err != nil { if err != nil {
log.Printf("Failed to decode image at %s: %v", mediaPath, err) log.Printf("Failed to decode image at %s: %v", mediaPath, err)
return PostImageInfo{} return PostFileInfo{}
} }
result.Size = fileInfo.Size()
result.Width = cfg.Width result.Width = cfg.Width
result.Height = cfg.Height result.Height = cfg.Height
result.IsVideo = false
} else {
result.IsVideo = true
}
return result return result
} }
@@ -163,3 +223,11 @@ func FormatBytes(bytes int64) string {
return fmt.Sprintf("%d B", bytes) return fmt.Sprintf("%d B", bytes)
} }
} }
func FormatFileInfo(fileInfo PostFileInfo) string {
humanSize := FormatBytes(fileInfo.Size)
if fileInfo.IsVideo {
return fmt.Sprintf("(%s)", humanSize)
}
return fmt.Sprintf("(%s, %dx%d)", humanSize, fileInfo.Width, fileInfo.Height)
}
+37 -15
View File
@@ -7,6 +7,7 @@ import (
"log" "log"
"net/http" "net/http"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -155,13 +156,14 @@ func main() {
defer file.Close() defer file.Close()
filename := strconv.FormatInt(time.Now().UnixNano(), 10) + filepath.Ext(header.Filename) filename := strconv.FormatInt(time.Now().UnixNano(), 10) + filepath.Ext(header.Filename)
if err := util.SavePostFile(&file, filename); err != nil { err, savedMediaPath, savedThumbPath := util.SavePostFile(file, filename)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError) http.Error(w, "Failed to save file", http.StatusInternalServerError)
log.Printf("savePostFile: %v", err) log.Printf("savePostFile: %v", err)
return return
} }
if err := database.PutThread(db, slug, subject, body, filename, util.HashIp(ip)); err != nil { if err := database.PutThread(db, slug, subject, body, savedMediaPath, savedThumbPath, util.HashIp(ip)); err != nil {
http.Error(w, "Failed to create thread", http.StatusInternalServerError) http.Error(w, "Failed to create thread", http.StatusInternalServerError)
log.Printf("PutThread: %v", err) log.Printf("PutThread: %v", err)
return return
@@ -180,13 +182,6 @@ func main() {
return return
} }
threadIdStr := chi.URLParam(r, "threadId")
threadId, err := strconv.Atoi(threadIdStr)
if err != nil {
http.Error(w, "Invalid thread id", http.StatusBadRequest)
return
}
if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil { if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest) http.Error(w, "Failed to parse form", http.StatusBadRequest)
log.Printf("ParseMultipartForm: %v", err) log.Printf("ParseMultipartForm: %v", err)
@@ -195,6 +190,7 @@ func main() {
body := strings.TrimSpace(r.FormValue("body")) body := strings.TrimSpace(r.FormValue("body"))
mediaPath := "" mediaPath := ""
thumbPath := ""
if body == "" { if body == "" {
http.Error(w, "Malformed post body", http.StatusBadRequest) http.Error(w, "Malformed post body", http.StatusBadRequest)
@@ -204,22 +200,48 @@ func main() {
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
if !errors.Is(err, http.ErrMissingFile) { if !errors.Is(err, http.ErrMissingFile) {
http.Error(w, "Failed to retrive file from form", http.StatusBadRequest) http.Error(w, "Failed to retrieve file from form", http.StatusBadRequest)
log.Printf("FormFile: %v", err) log.Printf("FormFile: %v", err)
return return
} }
} else { } else {
defer file.Close() defer file.Close()
filename := strconv.FormatInt(time.Now().UnixNano(), 10) + filepath.Ext(header.Filename)
if err := util.SavePostFile(&file, filename); err != nil { isFileVideo := false
fileExt := strings.ToLower(filepath.Ext(header.Filename))
// file type
buffer := make([]byte, 512)
file.Read(buffer)
file.Seek(0, 0)
fileType := http.DetectContentType(buffer)
isFileVideo = strings.HasPrefix(fileType, "video/")
if isFileVideo && !slices.Contains(util.SUPPORTED_VID_FORMATS, fileExt) {
http.Error(w, "Unsupported file format", http.StatusBadRequest)
return
}
filename := strconv.FormatInt(time.Now().UnixNano(), 10) + fileExt
err, savedMediaPath, savedThumbPath := util.SavePostFile(file, filename)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError) http.Error(w, "Failed to save file", http.StatusInternalServerError)
log.Printf("savePostFile: %v", err) log.Printf("savePostFile: %v", err)
return return
} }
mediaPath = filename mediaPath = savedMediaPath
thumbPath = savedThumbPath
} }
if err := database.PutPost(db, slug, threadId, body, mediaPath, util.HashIp(ip)); err != nil { threadIdStr := chi.URLParam(r, "threadId")
threadId, err := strconv.Atoi(threadIdStr)
if err != nil {
http.Error(w, "Invalid thread id", http.StatusBadRequest)
return
}
if err := database.PutPost(db, slug, threadId, body, mediaPath, thumbPath, util.HashIp(ip)); err != nil {
http.Error(w, "Failed to create post", http.StatusInternalServerError) http.Error(w, "Failed to create post", http.StatusInternalServerError)
log.Printf("PutPost: %v", err) log.Printf("PutPost: %v", err)
return return
@@ -262,7 +284,7 @@ func main() {
Subject: thread.Subject, Subject: thread.Subject,
Body: op.Body, Body: op.Body,
ThreadURL: fmt.Sprintf("/%s/threads/%d", slug, thread.Id), ThreadURL: fmt.Sprintf("/%s/threads/%d", slug, thread.Id),
MediaPath: op.MediaPath, ThumbPath: op.ThumbPath,
ReplyCount: len(posts), ReplyCount: len(posts),
IpCount: SumUniquePostIps(posts), IpCount: SumUniquePostIps(posts),
}) })
+8
View File
@@ -220,6 +220,14 @@ body {
max-width: 100%; max-width: 100%;
} }
.post-vid {
display: block;
float: left;
margin: 4px 12px 4px 0;
height: auto;
max-width: 100%;
}
.post-img-info { .post-img-info {
display: inline-block; display: inline-block;
font-size: 10px; font-size: 10px;

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

+24 -7
View File
@@ -36,15 +36,32 @@ function insertHeaderReplies() {
} }
} }
function togglePostImage(img) { function togglePostFile(imgEl) {
const filename = img.src.split("/").at(-1); const isVideo = imgEl.dataset.full.endsWith(".mp4") || imgEl.dataset.full.endsWith(".webm") || imgEl.dataset.full.endsWith(".ogg");
if (img.classList.contains("post-img-full")) {
img.classList.remove("post-img-full");
img.src = "/static/img/posts/thumb/" + filename; if (isVideo) {
const vidEl = imgEl.parentElement.querySelector("video");
if (vidEl.style.display === "none") {
vidEl.src = "/static/media/posts/full/" + imgEl.dataset.full;
vidEl.style.display = "";
imgEl.style.display = "none";
} }
else { else {
img.classList.add("post-img-full"); vidEl.src = "";
img.src = "/static/img/posts/full/" + filename; vidEl.style.display = "none";
imgEl.style.display = "";
}
}
else {
if (imgEl.classList.contains("post-img-full")) {
imgEl.classList.remove("post-img-full");
imgEl.src = "/static/media/posts/thumb/" + imgEl.dataset.thumb;
}
else {
imgEl.classList.add("post-img-full");
imgEl.src = "/static/media/posts/full/" + imgEl.dataset.full;
}
} }
} }
+13 -5
View File
@@ -42,7 +42,15 @@ templ NewThreadForm(board database.Board) {
</tr> </tr>
<tr class="new-post-form-field"> <tr class="new-post-form-field">
<th>File</th> <th>File</th>
<td><input id="newPostFile" name="file" type="file" required/></td> <td>
<input
accept="image/*,video/mp4,video/webm,video/ogg"
id="newPostFile"
name="file"
type="file"
required
/>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -52,7 +60,7 @@ templ NewThreadForm(board database.Board) {
templ BoardHeader(board database.Board) { templ BoardHeader(board database.Board) {
<header class="board-header"> <header class="board-header">
<img src={ fmt.Sprintf("/static/img/banners/%s.png", board.Slug) }/> <img src={ fmt.Sprintf("/static/media/banners/%s.png", board.Slug) }/>
<h1> <h1>
{ fmt.Sprintf("/%s/ - %s", board.Slug, board.Name) } { fmt.Sprintf("/%s/ - %s", board.Slug, board.Name) }
</h1> </h1>
@@ -91,7 +99,7 @@ type CatalogThreadPreview struct {
Subject string Subject string
Body string Body string
ThreadURL string ThreadURL string
MediaPath string ThumbPath string
ReplyCount int ReplyCount int
IpCount int IpCount int
} }
@@ -106,8 +114,8 @@ templ ThreadsCatalog(previews []CatalogThreadPreview) {
<img <img
loading="lazy" loading="lazy"
class="catalog-preview-img" class="catalog-preview-img"
src={ fmt.Sprintf("/static/img/posts/thumb/%s", src={ fmt.Sprintf("/static/media/posts/thumb/%s",
preview.MediaPath) } preview.ThumbPath) }
/> />
</a> </a>
<strong class="catalog-preview-counts">R: { replyCount } / I: { ipCount }</strong> <strong class="catalog-preview-counts">R: { replyCount } / I: { ipCount }</strong>
+1 -1
View File
@@ -5,7 +5,7 @@ import "github.com/dominicf2001/comfychan/web/views/shared"
templ Index() { templ Index() {
@shared.Layout() { @shared.Layout() {
<section class="container"> <section class="container">
<img id="clavis" src="/static/img/clavis.png"/> <img id="clavis" src="/static/media/clavis.png"/>
<div class="box"> <div class="box">
<h2>Welcome to Comfychan</h2> <h2>Welcome to Comfychan</h2>
<div> <div>
+23 -14
View File
@@ -9,24 +9,25 @@ import (
) )
templ PostOriginal(post database.Post, thread *database.Thread) { templ PostOriginal(post database.Post, thread *database.Thread) {
{{ imgInfo := util.GetPostImageInfo(post.MediaPath) }}
<article id={ fmt.Sprintf("post-%d", post.Number) } class="post-op"> <article id={ fmt.Sprintf("post-%d", post.Number) } class="post-op">
<div> <div>
File: File:
<a href={ templ.URL("/static/img/posts/full/" + post.MediaPath) } class="post-filename"> <a href={ templ.URL("/static/media/posts/full/" + post.MediaPath) } class="post-filename">
{ post.MediaPath } { post.MediaPath }
</a> </a>
<div class="post-img-info"> <div class="post-img-info">
(<span>{ util.FormatBytes(imgInfo.Size) }</span>, <span>{ util.FormatFileInfo(util.GetPostFileInfo(post.MediaPath)) }</span>
<span>{ strconv.Itoa(imgInfo.Width) }</span>x<span>{ strconv.Itoa(imgInfo.Height) }</span>)
</div> </div>
</div> </div>
<img <img
onclick="togglePostImage(this)" onclick="togglePostFile(this)"
loading="lazy" loading="lazy"
src={ fmt.Sprintf("/static/img/posts/thumb/%s", post.MediaPath) } src={ fmt.Sprintf("/static/media/posts/thumb/%s", post.ThumbPath) }
data-full={ post.MediaPath }
data-thumb={ post.ThumbPath }
class="post-img" class="post-img"
/> />
<video controls style="display: none;" class="post-vid"></video>
<header style="margin-top: 10px;" class="post-header"> <header style="margin-top: 10px;" class="post-header">
<h1 class="thread-subject">{ thread.Subject } -</h1> <h1 class="thread-subject">{ thread.Subject } -</h1>
<span class="post-author">{ post.Author }</span> <span class="post-author">{ post.Author }</span>
@@ -61,25 +62,26 @@ templ PostReply(post database.Post) {
<span class="post-replies"></span> <span class="post-replies"></span>
</header> </header>
if post.MediaPath != "" { if post.MediaPath != "" {
{{ imgInfo := util.GetPostImageInfo(post.MediaPath) }}
<div> <div>
<div style="margin-bottom: 2px;"> <div style="margin-bottom: 2px;">
File: File:
<a href={ templ.URL("/static/img/posts/full/" + post.MediaPath) } class="post-filename"> <a href={ templ.URL("/static/media/posts/full/" + post.MediaPath) } class="post-filename">
{ post.MediaPath } { post.MediaPath }
</a> </a>
<div class="post-img-info"> <div class="post-img-info">
(<span>{ util.FormatBytes(imgInfo.Size) }</span>, <span>{ util.FormatFileInfo(util.GetPostFileInfo(post.MediaPath)) }</span>
<span>{ strconv.Itoa(imgInfo.Width) }</span>x<span>{ strconv.Itoa(imgInfo.Height) }</span>)
</div> </div>
</div> </div>
<img <img
onclick="togglePostImage(this)" onclick="togglePostFile(this)"
loading="lazy" loading="lazy"
src={ fmt.Sprintf("/static/img/posts/thumb/%s", src={ fmt.Sprintf("/static/media/posts/thumb/%s",
post.MediaPath) } post.ThumbPath) }
data-full={ post.MediaPath }
data-thumb={ post.ThumbPath }
class="post-img" class="post-img"
/> />
<video controls style="display: none;" class="post-vid"></video>
</div> </div>
} }
<p class="post-body"> <p class="post-body">
@@ -128,7 +130,14 @@ templ Thread(board database.Board, thread database.Thread, posts []database.Post
</tr> </tr>
<tr class="new-post-form-field"> <tr class="new-post-form-field">
<th>File</th> <th>File</th>
<td><input id="newPostFile" name="file" type="file"/></td> <td>
<input
accept="image/*,video/mp4,video/webm,video/ogg"
id="newPostFile"
name="file"
type="file"
/>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>