✨ Attachments
This commit is contained in:
		
							
								
								
									
										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.Post{}, | ||||||
| 		&models.PostLike{}, | 		&models.PostLike{}, | ||||||
| 		&models.PostDislike{}, | 		&models.PostDislike{}, | ||||||
|  | 		&models.Attachment{}, | ||||||
| 	); err != nil { | 	); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -14,6 +14,7 @@ type Account struct { | |||||||
| 	EmailAddress  string        `json:"email_address"` | 	EmailAddress  string        `json:"email_address"` | ||||||
| 	PowerLevel    int           `json:"power_level"` | 	PowerLevel    int           `json:"power_level"` | ||||||
| 	Posts         []Post        `json:"posts" gorm:"foreignKey:AuthorID"` | 	Posts         []Post        `json:"posts" gorm:"foreignKey:AuthorID"` | ||||||
|  | 	Attachments   []Attachment  `json:"attachments" gorm:"foreignKey:AuthorID"` | ||||||
| 	LikedPosts    []PostLike    `json:"liked_posts"` | 	LikedPosts    []PostLike    `json:"liked_posts"` | ||||||
| 	DislikedPosts []PostDislike `json:"disliked_posts"` | 	DislikedPosts []PostDislike `json:"disliked_posts"` | ||||||
| 	Realms        []Realm       `json:"realms"` | 	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"` | 	Content          string        `json:"content"` | ||||||
| 	Tags             []Tag         `json:"tags" gorm:"many2many:post_tags"` | 	Tags             []Tag         `json:"tags" gorm:"many2many:post_tags"` | ||||||
| 	Categories       []Category    `json:"categories" gorm:"many2many:post_categories"` | 	Categories       []Category    `json:"categories" gorm:"many2many:post_categories"` | ||||||
|  | 	Attachments      []Attachment  `json:"attachments"` | ||||||
| 	LikedAccounts    []PostLike    `json:"liked_accounts"` | 	LikedAccounts    []PostLike    `json:"liked_accounts"` | ||||||
| 	DislikedAccounts []PostDislike `json:"disliked_accounts"` | 	DislikedAccounts []PostDislike `json:"disliked_accounts"` | ||||||
| 	RepostTo         *Post         `json:"repost_to" gorm:"foreignKey:RepostID"` | 	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() | 	buildOauth2Config() | ||||||
| 	code := c.Query("code") | 	code := c.Query("code") | ||||||
|  |  | ||||||
|   | |||||||
| @@ -48,14 +48,15 @@ func createPost(c *fiber.Ctx) error { | |||||||
| 	user := c.Locals("principal").(models.Account) | 	user := c.Locals("principal").(models.Account) | ||||||
|  |  | ||||||
| 	var data struct { | 	var data struct { | ||||||
| 		Alias       string            `json:"alias"` | 		Alias       string              `json:"alias"` | ||||||
| 		Title       string            `json:"title"` | 		Title       string              `json:"title"` | ||||||
| 		Content     string            `json:"content" validate:"required"` | 		Content     string              `json:"content" validate:"required"` | ||||||
| 		Tags        []models.Tag      `json:"tags"` | 		Tags        []models.Tag        `json:"tags"` | ||||||
| 		Categories  []models.Category `json:"categories"` | 		Categories  []models.Category   `json:"categories"` | ||||||
| 		PublishedAt *time.Time        `json:"published_at"` | 		Attachments []models.Attachment `json:"attachments"` | ||||||
| 		RepostTo    uint              `json:"repost_to"` | 		PublishedAt *time.Time          `json:"published_at"` | ||||||
| 		ReplyTo     uint              `json:"reply_to"` | 		RepostTo    uint                `json:"repost_to"` | ||||||
|  | 		ReplyTo     uint                `json:"reply_to"` | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := BindAndValidate(c, &data); err != nil { | 	if err := BindAndValidate(c, &data); err != nil { | ||||||
| @@ -94,6 +95,7 @@ func createPost(c *fiber.Ctx) error { | |||||||
| 		data.Alias, | 		data.Alias, | ||||||
| 		data.Title, | 		data.Title, | ||||||
| 		data.Content, | 		data.Content, | ||||||
|  | 		data.Attachments, | ||||||
| 		data.Categories, | 		data.Categories, | ||||||
| 		data.Tags, | 		data.Tags, | ||||||
| 		data.PublishedAt, | 		data.PublishedAt, | ||||||
|   | |||||||
| @@ -56,14 +56,17 @@ func NewServer() { | |||||||
|  |  | ||||||
| 	api := A.Group("/api").Name("API") | 	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/me", auth, getUserinfo) | ||||||
| 		api.Get("/users/:accountId", getOthersInfo) | 		api.Get("/users/:accountId", getOthersInfo) | ||||||
| 		api.Get("/users/:accountId/follow", auth, getAccountFollowed) | 		api.Get("/users/:accountId/follow", auth, getAccountFollowed) | ||||||
| 		api.Post("/users/:accountId/follow", auth, doFollowAccount) | 		api.Post("/users/:accountId/follow", auth, doFollowAccount) | ||||||
|  |  | ||||||
| 		api.Get("/auth", doLogin) | 		api.Get("/attachments/o/:fileId", openAttachment) | ||||||
| 		api.Get("/auth/callback", doPostLogin) | 		api.Post("/attachments", auth, uploadAttachment) | ||||||
| 		api.Post("/auth/refresh", doRefreshToken) |  | ||||||
|  |  | ||||||
| 		api.Get("/posts", listPost) | 		api.Get("/posts", listPost) | ||||||
| 		api.Post("/posts", auth, createPost) | 		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). | 		Limit(take). | ||||||
| 		Offset(offset). | 		Offset(offset). | ||||||
| 		Preload("Author"). | 		Preload("Author"). | ||||||
|  | 		Preload("Attachments"). | ||||||
| 		Preload("RepostTo"). | 		Preload("RepostTo"). | ||||||
| 		Preload("ReplyTo"). | 		Preload("ReplyTo"). | ||||||
| 		Preload("RepostTo.Author"). | 		Preload("RepostTo.Author"). | ||||||
| 		Preload("ReplyTo.Author"). | 		Preload("ReplyTo.Author"). | ||||||
|  | 		Preload("RepostTo.Attachments"). | ||||||
|  | 		Preload("ReplyTo.Attachments"). | ||||||
| 		Find(&posts).Error; err != nil { | 		Find(&posts).Error; err != nil { | ||||||
| 		return posts, err | 		return posts, err | ||||||
| 	} | 	} | ||||||
| @@ -66,6 +69,7 @@ WHERE t.id IN (?)`, prefix, prefix, prefix), postIds).Scan(&reactInfo) | |||||||
| func NewPost( | func NewPost( | ||||||
| 	user models.Account, | 	user models.Account, | ||||||
| 	alias, title, content string, | 	alias, title, content string, | ||||||
|  | 	attachments []models.Attachment, | ||||||
| 	categories []models.Category, | 	categories []models.Category, | ||||||
| 	tags []models.Tag, | 	tags []models.Tag, | ||||||
| 	publishedAt *time.Time, | 	publishedAt *time.Time, | ||||||
| @@ -77,6 +81,7 @@ func NewPost( | |||||||
| 		alias, | 		alias, | ||||||
| 		title, | 		title, | ||||||
| 		content, | 		content, | ||||||
|  | 		attachments, | ||||||
| 		categories, | 		categories, | ||||||
| 		tags, | 		tags, | ||||||
| 		publishedAt, | 		publishedAt, | ||||||
| @@ -89,6 +94,7 @@ func NewPostWithRealm( | |||||||
| 	user models.Account, | 	user models.Account, | ||||||
| 	realm *models.Realm, | 	realm *models.Realm, | ||||||
| 	alias, title, content string, | 	alias, title, content string, | ||||||
|  | 	attachments []models.Attachment, | ||||||
| 	categories []models.Category, | 	categories []models.Category, | ||||||
| 	tags []models.Tag, | 	tags []models.Tag, | ||||||
| 	publishedAt *time.Time, | 	publishedAt *time.Time, | ||||||
| @@ -122,6 +128,7 @@ func NewPostWithRealm( | |||||||
| 		Alias:       alias, | 		Alias:       alias, | ||||||
| 		Title:       title, | 		Title:       title, | ||||||
| 		Content:     content, | 		Content:     content, | ||||||
|  | 		Attachments: attachments, | ||||||
| 		Tags:        tags, | 		Tags:        tags, | ||||||
| 		Categories:  categories, | 		Categories:  categories, | ||||||
| 		AuthorID:    user.ID, | 		AuthorID:    user.ID, | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ | |||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@fortawesome/fontawesome-free": "^6.5.1", |     "@fortawesome/fontawesome-free": "^6.5.1", | ||||||
|     "@solidjs/router": "^0.10.10", |     "@solidjs/router": "^0.10.10", | ||||||
|  |     "medium-zoom": "^1.1.0", | ||||||
|     "solid-js": "^1.8.7", |     "solid-js": "^1.8.7", | ||||||
|     "universal-cookie": "^7.0.2" |     "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 { createSignal, Show } from "solid-js"; | ||||||
| import { getAtk, useUserinfo } from "../stores/userinfo.tsx"; | import { getAtk, useUserinfo } from "../stores/userinfo.tsx"; | ||||||
|  | import PostAttachments from "./PostAttachments.tsx"; | ||||||
|  |  | ||||||
| export default function PostItem(props: { | export default function PostItem(props: { | ||||||
|   post: any, |   post: any, | ||||||
| @@ -58,6 +59,8 @@ export default function PostItem(props: { | |||||||
|         <h2 class="card-title">{props.post.title}</h2> |         <h2 class="card-title">{props.post.title}</h2> | ||||||
|         <article class="prose">{props.post.content}</article> |         <article class="prose">{props.post.content}</article> | ||||||
|  |  | ||||||
|  |         <PostAttachments attachments={props.post.attachments ?? []} /> | ||||||
|  |  | ||||||
|         <Show when={props.post.repost_to}> |         <Show when={props.post.repost_to}> | ||||||
|           <p class="text-xs mt-3 mb-2"> |           <p class="text-xs mt-3 mb-2"> | ||||||
|             <i class="fa-solid fa-retweet me-2"></i> |             <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 { getAtk, useUserinfo } from "../stores/userinfo.tsx"; | ||||||
|  |  | ||||||
| import styles from "./PostPublish.module.css"; | import styles from "./PostPublish.module.css"; | ||||||
| @@ -15,6 +15,11 @@ export default function PostPublish(props: { | |||||||
|   const userinfo = useUserinfo(); |   const userinfo = useUserinfo(); | ||||||
|  |  | ||||||
|   const [submitting, setSubmitting] = createSignal(false); |   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) { |   async function doPost(evt: SubmitEvent) { | ||||||
|     evt.preventDefault(); |     evt.preventDefault(); | ||||||
| @@ -34,6 +39,7 @@ export default function PostPublish(props: { | |||||||
|         alias: data.alias ?? crypto.randomUUID().replace(/-/g, ""), |         alias: data.alias ?? crypto.randomUUID().replace(/-/g, ""), | ||||||
|         title: data.title, |         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(), |         published_at: data.published_at ? new Date(data.published_at as string) : new Date(), | ||||||
|         repost_to: props.reposting?.id, |         repost_to: props.reposting?.id, | ||||||
|         reply_to: props.replying?.id |         reply_to: props.replying?.id | ||||||
| @@ -55,6 +61,7 @@ export default function PostPublish(props: { | |||||||
|     const form = evt.target as HTMLFormElement; |     const form = evt.target as HTMLFormElement; | ||||||
|     const data = Object.fromEntries(new FormData(form)); |     const data = Object.fromEntries(new FormData(form)); | ||||||
|     if (!data.content) return; |     if (!data.content) return; | ||||||
|  |     if (uploading()) return; | ||||||
|  |  | ||||||
|     setSubmitting(true); |     setSubmitting(true); | ||||||
|     const res = await fetch(`/api/posts/${props.editing?.id}`, { |     const res = await fetch(`/api/posts/${props.editing?.id}`, { | ||||||
| @@ -66,7 +73,9 @@ export default function PostPublish(props: { | |||||||
|       body: JSON.stringify({ |       body: JSON.stringify({ | ||||||
|         alias: data.alias ?? crypto.randomUUID().replace(/-/g, ""), |         alias: data.alias ?? crypto.randomUUID().replace(/-/g, ""), | ||||||
|         title: data.title, |         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) { |     if (res.status !== 200) { | ||||||
| @@ -79,103 +88,171 @@ export default function PostPublish(props: { | |||||||
|     setSubmitting(false); |     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 ( |   return ( | ||||||
|     <form id="publish" onSubmit={props.editing ? doEdit : doPost} onReset={props.onReset}> |     <> | ||||||
|       <div id="publish-identity" class="flex border-y border-base-200"> |       <form id="publish" onSubmit={props.editing ? doEdit : doPost} onReset={() => resetForm()}> | ||||||
|         <div class="avatar pl-[20px]"> |         <div id="publish-identity" class="flex border-y border-base-200"> | ||||||
|           <div class="w-12"> |           <div class="avatar pl-[20px]"> | ||||||
|             <Show when={userinfo?.profiles?.avatar} |             <div class="w-12"> | ||||||
|                   fallback={<span class="text-3xl">{userinfo?.displayName.substring(0, 1)}</span>}> |               <Show when={userinfo?.profiles?.avatar} | ||||||
|               <img alt="avatar" src={userinfo?.profiles?.avatar} /> |                     fallback={<span class="text-3xl">{userinfo?.displayName.substring(0, 1)}</span>}> | ||||||
|             </Show> |                 <img alt="avatar" src={userinfo?.profiles?.avatar} /> | ||||||
|           </div> |               </Show> | ||||||
|         </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> |  | ||||||
|             </div> |             </div> | ||||||
|             <input name="published_at" type="datetime-local" placeholder="Pick a date" |           </div> | ||||||
|                    class="input input-bordered w-full" /> |           <div class="flex flex-grow"> | ||||||
|             <div class="label"> |             <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"> |               <span class="label-text-alt"> | ||||||
|                 Before this time, your post will not be visible for everyone. |                 Before this time, your post will not be visible for everyone. | ||||||
|                 You can modify this plan on Creator Hub. |                 You can modify this plan on Creator Hub. | ||||||
|               </span> |               </span> | ||||||
|  |               </div> | ||||||
|  |             </label> | ||||||
|  |             <div class="modal-action"> | ||||||
|  |               <button type="button" class="btn" onClick={() => closeModel("#planning-publish")}>Close</button> | ||||||
|             </div> |             </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"> |           <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> | ||||||
|         </div> |         </div> | ||||||
|       </dialog> |       </dialog> | ||||||
|     </form> |     </> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| @@ -5,4 +5,12 @@ | |||||||
| html, body { | html, body { | ||||||
|     padding: 0; |     padding: 0; | ||||||
|     margin: 0; |     margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .medium-zoom-image--opened { | ||||||
|  |     z-index: 15; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .medium-zoom-overlay { | ||||||
|  |     z-index: 10; | ||||||
| } | } | ||||||
		Reference in New Issue
	
	Block a user