✨ 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.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") | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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,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> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -6,3 +6,11 @@ html, body { | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| .medium-zoom-image--opened { | ||||
|     z-index: 15; | ||||
| } | ||||
|  | ||||
| .medium-zoom-overlay { | ||||
|     z-index: 10; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user