Attachments

This commit is contained in:
LittleSheep 2024-02-04 18:40:20 +08:00
parent 5e4d5f77c5
commit 86783316a1
17 changed files with 396 additions and 101 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/uploads

View File

@ -15,6 +15,7 @@ func RunMigration(source *gorm.DB) error {
&models.Post{},
&models.PostLike{},
&models.PostDislike{},
&models.Attachment{},
); err != nil {
return err
}

View File

@ -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
View 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)
}

View File

@ -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"`

View 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(),
})
}

View File

@ -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")

View File

@ -48,14 +48,15 @@ func createPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Alias string `json:"alias"`
Title string `json:"title"`
Content string `json:"content" validate:"required"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
PublishedAt *time.Time `json:"published_at"`
RepostTo uint `json:"repost_to"`
ReplyTo uint `json:"reply_to"`
Alias string `json:"alias"`
Title string `json:"title"`
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"`
}
if err := BindAndValidate(c, &data); err != nil {
@ -94,6 +95,7 @@ func createPost(c *fiber.Ctx) error {
data.Alias,
data.Title,
data.Content,
data.Attachments,
data.Categories,
data.Tags,
data.PublishedAt,

View File

@ -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)

View 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
}

View File

@ -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,

View File

@ -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"
},

View File

@ -0,0 +1,3 @@
.attachmentsControl {
background-color: transparent !important;
}

View 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>
</>
);
}

View File

@ -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>

View File

@ -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,103 +88,171 @@ 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}>
<div id="publish-identity" class="flex border-y border-base-200">
<div class="avatar pl-[20px]">
<div class="w-12">
<Show when={userinfo?.profiles?.avatar}
fallback={<span class="text-3xl">{userinfo?.displayName.substring(0, 1)}</span>}>
<img alt="avatar" src={userinfo?.profiles?.avatar} />
</Show>
</div>
</div>
<div class="flex flex-grow">
<input name="title" value={props.editing?.title ?? ""}
class={`${styles.publishInput} input w-full`}
placeholder="The describe for a long content (Optional)" />
</div>
</div>
<Show when={props.reposting}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are reposting a post from <b>{props.reposting?.author?.name}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.replying}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are replying a post from <b>{props.replying?.author?.name}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.editing}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are editing a post published at{" "}
<b>{new Date(props.editing?.created_at).toLocaleString()}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<textarea name="content" value={props.editing?.content ?? ""}
class={`${styles.publishInput} textarea w-full`}
placeholder="What's happend?!" />
<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">
<i class="fa-solid fa-paperclip"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#planning-publish")}>
<i class="fa-solid fa-calendar-day"></i>
</button>
</div>
<div>
<button type="submit" class="btn btn-primary" disabled={submitting()}>
<Show when={submitting()} fallback={props.editing ? "Save changes" : "Post a post"}>
<span class="loading"></span>
</Show>
</button>
</div>
</div>
<dialog id="planning-publish" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Planning Publish</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Published At</span>
<>
<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">
<Show when={userinfo?.profiles?.avatar}
fallback={<span class="text-3xl">{userinfo?.displayName.substring(0, 1)}</span>}>
<img alt="avatar" src={userinfo?.profiles?.avatar} />
</Show>
</div>
<input name="published_at" type="datetime-local" placeholder="Pick a date"
class="input input-bordered w-full" />
<div class="label">
</div>
<div class="flex flex-grow">
<input name="title" value={props.editing?.title ?? ""}
class={`${styles.publishInput} input w-full`}
placeholder="The describe for a long content (Optional)" />
</div>
</div>
<Show when={props.reposting}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are reposting a post from <b>{props.reposting?.author?.name}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.replying}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are replying a post from <b>{props.replying?.author?.name}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<Show when={props.editing}>
<div role="alert" class="bg-base-200 flex justify-between">
<div class="px-5 py-3">
<i class="fa-solid fa-circle-info me-3"></i>
You are editing a post published at{" "}
<b>{new Date(props.editing?.created_at).toLocaleString()}</b>
</div>
<button type="reset" class="btn btn-ghost w-12" disabled={submitting()}>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</Show>
<textarea name="content" value={props.editing?.content ?? ""}
class={`${styles.publishInput} textarea w-full`}
placeholder="What's happend?!" />
<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" onClick={() => openModel("#attachments")}>
<i class="fa-solid fa-paperclip"></i>
</button>
<button type="button" class="btn btn-ghost w-12" onClick={() => openModel("#planning-publish")}>
<i class="fa-solid fa-calendar-day"></i>
</button>
</div>
<div>
<button type="submit" class="btn btn-primary" disabled={submitting()}>
<Show when={submitting()} fallback={props.editing ? "Save changes" : "Post a post"}>
<span class="loading"></span>
</Show>
</button>
</div>
</div>
<dialog id="planning-publish" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mx-1">Planning Publish</h3>
<label class="form-control w-full mt-3">
<div class="label">
<span class="label-text">Published At</span>
</div>
<input name="published_at" type="datetime-local" placeholder="Pick a date"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">
Before this time, your post will not be visible for everyone.
You can modify this plan on Creator Hub.
</span>
</div>
</label>
<div class="modal-action">
<button type="button" class="btn" onClick={() => closeModel("#planning-publish")}>Close</button>
</div>
</label>
</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("#planning-publish")}>Close</button>
<button type="button" class="btn" onClick={() => closeModel("#attachments")}>Close</button>
</div>
</div>
</dialog>
</form>
</>
);
}

View File

@ -5,4 +5,12 @@
html, body {
padding: 0;
margin: 0;
}
.medium-zoom-image--opened {
z-index: 15;
}
.medium-zoom-overlay {
z-index: 10;
}