✨ Attachments
This commit is contained in:
parent
5e4d5f77c5
commit
86783316a1
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/uploads
|
@ -15,6 +15,7 @@ func RunMigration(source *gorm.DB) error {
|
||||
&models.Post{},
|
||||
&models.PostLike{},
|
||||
&models.PostDislike{},
|
||||
&models.Attachment{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ type Account struct {
|
||||
EmailAddress string `json:"email_address"`
|
||||
PowerLevel int `json:"power_level"`
|
||||
Posts []Post `json:"posts" gorm:"foreignKey:AuthorID"`
|
||||
Attachments []Attachment `json:"attachments" gorm:"foreignKey:AuthorID"`
|
||||
LikedPosts []PostLike `json:"liked_posts"`
|
||||
DislikedPosts []PostDislike `json:"disliked_posts"`
|
||||
Realms []Realm `json:"realms"`
|
||||
|
29
pkg/models/attachments.go
Normal file
29
pkg/models/attachments.go
Normal file
@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/viper"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Attachment struct {
|
||||
BaseModel
|
||||
|
||||
FileID string `json:"file_id"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
Filename string `json:"filename"`
|
||||
Mimetype string `json:"mimetype"`
|
||||
Post *Post `json:"post"`
|
||||
Author Account `json:"author"`
|
||||
PostID *uint `json:"post_id"`
|
||||
AuthorID uint `json:"author_id"`
|
||||
}
|
||||
|
||||
func (v Attachment) GetStoragePath() string {
|
||||
basepath := viper.GetString("content")
|
||||
return filepath.Join(basepath, v.FileID)
|
||||
}
|
||||
|
||||
func (v Attachment) GetAccessPath() string {
|
||||
return fmt.Sprintf("/api/attachments/o/%s", v.FileID)
|
||||
}
|
@ -10,6 +10,7 @@ type Post struct {
|
||||
Content string `json:"content"`
|
||||
Tags []Tag `json:"tags" gorm:"many2many:post_tags"`
|
||||
Categories []Category `json:"categories" gorm:"many2many:post_categories"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
LikedAccounts []PostLike `json:"liked_accounts"`
|
||||
DislikedAccounts []PostDislike `json:"disliked_accounts"`
|
||||
RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"`
|
||||
|
38
pkg/server/attachments_api.go
Normal file
38
pkg/server/attachments_api.go
Normal file
@ -0,0 +1,38 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/spf13/viper"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func openAttachment(c *fiber.Ctx) error {
|
||||
id := c.Params("fileId")
|
||||
basepath := viper.GetString("content")
|
||||
|
||||
return c.SendFile(filepath.Join(basepath, id))
|
||||
}
|
||||
|
||||
func uploadAttachment(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
file, err := c.FormFile("attachment")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attachment, err := services.NewAttachment(user, file)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
if err := c.SaveFile(file, attachment.GetStoragePath()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"info": attachment,
|
||||
"url": attachment.GetAccessPath(),
|
||||
})
|
||||
}
|
@ -36,7 +36,7 @@ func doLogin(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func doPostLogin(c *fiber.Ctx) error {
|
||||
func postLogin(c *fiber.Ctx) error {
|
||||
buildOauth2Config()
|
||||
code := c.Query("code")
|
||||
|
||||
|
@ -53,6 +53,7 @@ func createPost(c *fiber.Ctx) error {
|
||||
Content string `json:"content" validate:"required"`
|
||||
Tags []models.Tag `json:"tags"`
|
||||
Categories []models.Category `json:"categories"`
|
||||
Attachments []models.Attachment `json:"attachments"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
RepostTo uint `json:"repost_to"`
|
||||
ReplyTo uint `json:"reply_to"`
|
||||
@ -94,6 +95,7 @@ func createPost(c *fiber.Ctx) error {
|
||||
data.Alias,
|
||||
data.Title,
|
||||
data.Content,
|
||||
data.Attachments,
|
||||
data.Categories,
|
||||
data.Tags,
|
||||
data.PublishedAt,
|
||||
|
@ -56,14 +56,17 @@ func NewServer() {
|
||||
|
||||
api := A.Group("/api").Name("API")
|
||||
{
|
||||
api.Get("/auth", doLogin)
|
||||
api.Get("/auth/callback", postLogin)
|
||||
api.Post("/auth/refresh", doRefreshToken)
|
||||
|
||||
api.Get("/users/me", auth, getUserinfo)
|
||||
api.Get("/users/:accountId", getOthersInfo)
|
||||
api.Get("/users/:accountId/follow", auth, getAccountFollowed)
|
||||
api.Post("/users/:accountId/follow", auth, doFollowAccount)
|
||||
|
||||
api.Get("/auth", doLogin)
|
||||
api.Get("/auth/callback", doPostLogin)
|
||||
api.Post("/auth/refresh", doRefreshToken)
|
||||
api.Get("/attachments/o/:fileId", openAttachment)
|
||||
api.Post("/attachments", auth, uploadAttachment)
|
||||
|
||||
api.Get("/posts", listPost)
|
||||
api.Post("/posts", auth, createPost)
|
||||
|
40
pkg/services/attachments.go
Normal file
40
pkg/services/attachments.go
Normal file
@ -0,0 +1,40 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
|
||||
"github.com/google/uuid"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func NewAttachment(user models.Account, header *multipart.FileHeader) (models.Attachment, error) {
|
||||
attachment := models.Attachment{
|
||||
FileID: uuid.NewString(),
|
||||
Filesize: header.Size,
|
||||
Filename: header.Filename,
|
||||
Mimetype: "unknown/unknown",
|
||||
PostID: nil,
|
||||
AuthorID: user.ID,
|
||||
}
|
||||
|
||||
// Open file
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
return attachment, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Detect mimetype
|
||||
fileHeader := make([]byte, 512)
|
||||
_, err = file.Read(fileHeader)
|
||||
if err != nil {
|
||||
return attachment, err
|
||||
}
|
||||
attachment.Mimetype = http.DetectContentType(fileHeader)
|
||||
|
||||
// Save into database
|
||||
err = database.C.Save(&attachment).Error
|
||||
|
||||
return attachment, err
|
||||
}
|
@ -18,10 +18,13 @@ func ListPost(tx *gorm.DB, take int, offset int) ([]*models.Post, error) {
|
||||
Limit(take).
|
||||
Offset(offset).
|
||||
Preload("Author").
|
||||
Preload("Attachments").
|
||||
Preload("RepostTo").
|
||||
Preload("ReplyTo").
|
||||
Preload("RepostTo.Author").
|
||||
Preload("ReplyTo.Author").
|
||||
Preload("RepostTo.Attachments").
|
||||
Preload("ReplyTo.Attachments").
|
||||
Find(&posts).Error; err != nil {
|
||||
return posts, err
|
||||
}
|
||||
@ -66,6 +69,7 @@ WHERE t.id IN (?)`, prefix, prefix, prefix), postIds).Scan(&reactInfo)
|
||||
func NewPost(
|
||||
user models.Account,
|
||||
alias, title, content string,
|
||||
attachments []models.Attachment,
|
||||
categories []models.Category,
|
||||
tags []models.Tag,
|
||||
publishedAt *time.Time,
|
||||
@ -77,6 +81,7 @@ func NewPost(
|
||||
alias,
|
||||
title,
|
||||
content,
|
||||
attachments,
|
||||
categories,
|
||||
tags,
|
||||
publishedAt,
|
||||
@ -89,6 +94,7 @@ func NewPostWithRealm(
|
||||
user models.Account,
|
||||
realm *models.Realm,
|
||||
alias, title, content string,
|
||||
attachments []models.Attachment,
|
||||
categories []models.Category,
|
||||
tags []models.Tag,
|
||||
publishedAt *time.Time,
|
||||
@ -122,6 +128,7 @@ func NewPostWithRealm(
|
||||
Alias: alias,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Attachments: attachments,
|
||||
Tags: tags,
|
||||
Categories: categories,
|
||||
AuthorID: user.ID,
|
||||
|
@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||
"@solidjs/router": "^0.10.10",
|
||||
"medium-zoom": "^1.1.0",
|
||||
"solid-js": "^1.8.7",
|
||||
"universal-cookie": "^7.0.2"
|
||||
},
|
||||
|
3
pkg/view/src/components/PostAttachments.module.css
Normal file
3
pkg/view/src/components/PostAttachments.module.css
Normal file
@ -0,0 +1,3 @@
|
||||
.attachmentsControl {
|
||||
background-color: transparent !important;
|
||||
}
|
80
pkg/view/src/components/PostAttachments.tsx
Normal file
80
pkg/view/src/components/PostAttachments.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { createEffect, createMemo, createSignal, Match, Switch } from "solid-js";
|
||||
import mediumZoom from "medium-zoom";
|
||||
|
||||
import styles from "./PostAttachments.module.css";
|
||||
|
||||
export default function PostAttachments(props: { attachments: any[] }) {
|
||||
if (props.attachments.length <= 0) return null;
|
||||
|
||||
const [focus, setFocus] = createSignal(0);
|
||||
const item = createMemo(() => props.attachments[focus()]);
|
||||
|
||||
function getRenderType(item: any): string {
|
||||
return item.mimetype.split("/")[0];
|
||||
}
|
||||
|
||||
function getUrl(item: any): string {
|
||||
return `/api/attachments/o/${item.file_id}`;
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
mediumZoom(document.querySelectorAll(".attachment-image img"), {
|
||||
background: "var(--fallback-b1,oklch(var(--b1)/1))"
|
||||
});
|
||||
}, [focus()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p class="text-xs mt-3 mb-2">
|
||||
<i class="fa-solid fa-paperclip me-2"></i>
|
||||
Attached {props.attachments.length} file{props.attachments.length > 1 ? "s" : null}
|
||||
</p>
|
||||
<div class="border border-base-200">
|
||||
<Switch fallback={
|
||||
<div class="py-16 flex justify-center items-center">
|
||||
<div class="text-center">
|
||||
<i class="fa-solid fa-circle-question text-3xl"></i>
|
||||
<p class="mt-3">{item().filename}</p>
|
||||
|
||||
<div class="flex gap-3 w-full">
|
||||
<p class="text-sm">{item().filesize} Bytes</p>
|
||||
<p class="text-sm">{item().mimetype}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<a class="link" href={getUrl(item())} target="_blank">Open in browser</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<Match when={getRenderType(item()) === "image"}>
|
||||
<figure class="attachment-image">
|
||||
<img class="object-cover" src={getUrl(item())} alt={item().filename} />
|
||||
</figure>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<div id="attachments-control" class="flex justify-between border-t border-base-200">
|
||||
<div class="flex">
|
||||
<button class={`w-12 h-12 btn btn-ghost ${styles.attachmentsControl}`}
|
||||
disabled={focus() - 1 < 0}
|
||||
onClick={() => setFocus(focus() - 1)}>
|
||||
<i class="fa-solid fa-caret-left"></i>
|
||||
</button>
|
||||
<button class={`w-12 h-12 btn btn-ghost ${styles.attachmentsControl}`}
|
||||
disabled={focus() + 1 >= props.attachments.length}
|
||||
onClick={() => setFocus(focus() + 1)}>
|
||||
<i class="fa-solid fa-caret-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="h-12 px-5 py-3.5 text-sm">
|
||||
File {focus() + 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { getAtk, useUserinfo } from "../stores/userinfo.tsx";
|
||||
import PostAttachments from "./PostAttachments.tsx";
|
||||
|
||||
export default function PostItem(props: {
|
||||
post: any,
|
||||
@ -58,6 +59,8 @@ export default function PostItem(props: {
|
||||
<h2 class="card-title">{props.post.title}</h2>
|
||||
<article class="prose">{props.post.content}</article>
|
||||
|
||||
<PostAttachments attachments={props.post.attachments ?? []} />
|
||||
|
||||
<Show when={props.post.repost_to}>
|
||||
<p class="text-xs mt-3 mb-2">
|
||||
<i class="fa-solid fa-retweet me-2"></i>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { createEffect, createSignal, For, Show } from "solid-js";
|
||||
import { getAtk, useUserinfo } from "../stores/userinfo.tsx";
|
||||
|
||||
import styles from "./PostPublish.module.css";
|
||||
@ -15,6 +15,11 @@ export default function PostPublish(props: {
|
||||
const userinfo = useUserinfo();
|
||||
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
const [uploading, setUploading] = createSignal(false);
|
||||
|
||||
const [attachments, setAttachments] = createSignal<any[]>([]);
|
||||
|
||||
createEffect(() => setAttachments(props.editing?.attachments ?? []), [props.editing]);
|
||||
|
||||
async function doPost(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
@ -34,6 +39,7 @@ export default function PostPublish(props: {
|
||||
alias: data.alias ?? crypto.randomUUID().replace(/-/g, ""),
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
attachments: attachments(),
|
||||
published_at: data.published_at ? new Date(data.published_at as string) : new Date(),
|
||||
repost_to: props.reposting?.id,
|
||||
reply_to: props.replying?.id
|
||||
@ -55,6 +61,7 @@ export default function PostPublish(props: {
|
||||
const form = evt.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
if (!data.content) return;
|
||||
if (uploading()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
const res = await fetch(`/api/posts/${props.editing?.id}`, {
|
||||
@ -66,7 +73,9 @@ export default function PostPublish(props: {
|
||||
body: JSON.stringify({
|
||||
alias: data.alias ?? crypto.randomUUID().replace(/-/g, ""),
|
||||
title: data.title,
|
||||
content: data.content
|
||||
content: data.content,
|
||||
attachments: attachments(),
|
||||
published_at: data.published_at ? new Date(data.published_at as string) : new Date()
|
||||
})
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
@ -79,8 +88,36 @@ export default function PostPublish(props: {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function uploadAttachments(evt: SubmitEvent) {
|
||||
evt.preventDefault();
|
||||
|
||||
const data = new FormData(evt.target as HTMLFormElement);
|
||||
if (!data.get("attachment")) return;
|
||||
|
||||
setUploading(true);
|
||||
const res = await fetch("/api/attachments", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` },
|
||||
body: data
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
props.onError(await res.text());
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setAttachments(attachments().concat([data.info]));
|
||||
props.onError(null);
|
||||
}
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setAttachments([]);
|
||||
props.onReset();
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="publish" onSubmit={props.editing ? doEdit : doPost} onReset={props.onReset}>
|
||||
<>
|
||||
<form id="publish" onSubmit={props.editing ? doEdit : doPost} onReset={() => resetForm()}>
|
||||
<div id="publish-identity" class="flex border-y border-base-200">
|
||||
<div class="avatar pl-[20px]">
|
||||
<div class="w-12">
|
||||
@ -138,7 +175,7 @@ export default function PostPublish(props: {
|
||||
|
||||
<div id="publish-actions" class="flex justify-between border-y border-base-200">
|
||||
<div class="flex">
|
||||
<button type="button" class="btn btn-ghost w-12">
|
||||
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#attachments")}>
|
||||
<i class="fa-solid fa-paperclip"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#planning-publish")}>
|
||||
@ -177,5 +214,45 @@ export default function PostPublish(props: {
|
||||
</div>
|
||||
</dialog>
|
||||
</form>
|
||||
|
||||
|
||||
<dialog id="attachments" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mx-1">Attachments</h3>
|
||||
<form class="w-full mt-3" onSubmit={uploadAttachments}>
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text">Pick a file</span>
|
||||
</div>
|
||||
<div class="join">
|
||||
<input required type="file" name="attachment" class="join-item file-input file-input-bordered w-full" />
|
||||
<button type="submit" class="join-item btn btn-primary" disabled={uploading()}>
|
||||
<i class="fa-solid fa-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Click upload to add this file into list</span>
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
<h3 class="font-bold mt-3 mx-1">Attachment list</h3>
|
||||
<ol class="mt-2 mx-1 text-sm">
|
||||
<For each={attachments()}>
|
||||
{item => <li>
|
||||
<i class="fa-regular fa-file me-2"></i>
|
||||
{item.filename}
|
||||
</li>}
|
||||
</For>
|
||||
</ol>
|
||||
</Show>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onClick={() => closeModel("#attachments")}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
@ -6,3 +6,11 @@ html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.medium-zoom-image--opened {
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.medium-zoom-overlay {
|
||||
z-index: 10;
|
||||
}
|
Loading…
Reference in New Issue
Block a user