✨ Reacting
This commit is contained in:
		| @@ -12,6 +12,8 @@ func RunMigration(source *gorm.DB) error { | ||||
| 		&models.Category{}, | ||||
| 		&models.Tag{}, | ||||
| 		&models.Post{}, | ||||
| 		&models.PostLike{}, | ||||
| 		&models.PostDislike{}, | ||||
| 	); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|   | ||||
| @@ -6,12 +6,14 @@ package models | ||||
| type Account struct { | ||||
| 	BaseModel | ||||
|  | ||||
| 	Name         string  `json:"name"` | ||||
| 	Avatar       string  `json:"avatar"` | ||||
| 	Description  string  `json:"description"` | ||||
| 	EmailAddress string  `json:"email_address"` | ||||
| 	PowerLevel   int     `json:"power_level"` | ||||
| 	Posts        []Post  `json:"posts" gorm:"foreignKey:AuthorID"` | ||||
| 	Realms       []Realm `json:"realms"` | ||||
| 	ExternalID   uint    `json:"external_id"` | ||||
| 	Name          string        `json:"name"` | ||||
| 	Avatar        string        `json:"avatar"` | ||||
| 	Description   string        `json:"description"` | ||||
| 	EmailAddress  string        `json:"email_address"` | ||||
| 	PowerLevel    int           `json:"power_level"` | ||||
| 	Posts         []Post        `json:"posts" gorm:"foreignKey:AuthorID"` | ||||
| 	LikedPosts    []PostLike    `json:"liked_posts"` | ||||
| 	DislikedPosts []PostDislike `json:"disliked_posts"` | ||||
| 	Realms        []Realm       `json:"realms"` | ||||
| 	ExternalID    uint          `json:"external_id"` | ||||
| } | ||||
|   | ||||
| @@ -5,13 +5,19 @@ import "time" | ||||
| type Post struct { | ||||
| 	BaseModel | ||||
|  | ||||
| 	Alias       string     `json:"alias" gorm:"uniqueIndex"` | ||||
| 	Title       string     `json:"title"` | ||||
| 	Content     string     `json:"content"` | ||||
| 	Tags        []Tag      `gorm:"many2many:post_tags"` | ||||
| 	Categories  []Category `gorm:"many2many:post_categories"` | ||||
| 	PublishedAt time.Time  `json:"published_at"` | ||||
| 	RealmID     *uint      `json:"realm_id"` | ||||
| 	AuthorID    uint       `json:"author_id"` | ||||
| 	Author      Account    `json:"author"` | ||||
| 	Alias            string        `json:"alias" gorm:"uniqueIndex"` | ||||
| 	Title            string        `json:"title"` | ||||
| 	Content          string        `json:"content"` | ||||
| 	Tags             []Tag         `gorm:"many2many:post_tags"` | ||||
| 	Categories       []Category    `gorm:"many2many:post_categories"` | ||||
| 	LikedAccounts    []PostLike    `json:"liked_accounts"` | ||||
| 	DislikedAccounts []PostDislike `json:"disliked_accounts"` | ||||
| 	PublishedAt      time.Time     `json:"published_at"` | ||||
| 	RealmID          *uint         `json:"realm_id"` | ||||
| 	AuthorID         uint          `json:"author_id"` | ||||
| 	Author           Account       `json:"author"` | ||||
|  | ||||
| 	// Dynamic Calculating Values | ||||
| 	LikeCount    int64 `json:"like_count" gorm:"-"` | ||||
| 	DislikeCount int64 `json:"dislike_count" gorm:"-"` | ||||
| } | ||||
|   | ||||
							
								
								
									
										19
									
								
								pkg/models/reactions.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								pkg/models/reactions.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| package models | ||||
|  | ||||
| import "time" | ||||
|  | ||||
| type PostLike struct { | ||||
| 	ID        uint      `json:"id" gorm:"primaryKey"` | ||||
| 	CreatedAt time.Time `json:"created_at"` | ||||
| 	UpdatedAt time.Time `json:"updated_at"` | ||||
| 	PostID    uint      `json:"post_id"` | ||||
| 	AccountID uint      `json:"account_id"` | ||||
| } | ||||
|  | ||||
| type PostDislike struct { | ||||
| 	ID        uint      `json:"id" gorm:"primaryKey"` | ||||
| 	CreatedAt time.Time `json:"created_at"` | ||||
| 	UpdatedAt time.Time `json:"updated_at"` | ||||
| 	PostID    uint      `json:"post_id"` | ||||
| 	AccountID uint      `json:"account_id"` | ||||
| } | ||||
| @@ -4,7 +4,12 @@ import ( | ||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/database" | ||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/models" | ||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/services" | ||||
| 	"fmt" | ||||
| 	"github.com/gofiber/fiber/v2" | ||||
| 	"github.com/google/uuid" | ||||
| 	"github.com/samber/lo" | ||||
| 	"github.com/spf13/viper" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| func listPost(c *fiber.Ctx) error { | ||||
| @@ -14,12 +19,14 @@ func listPost(c *fiber.Ctx) error { | ||||
| 	var count int64 | ||||
| 	var posts []models.Post | ||||
| 	if err := database.C. | ||||
| 		Where(&models.Post{RealmID: nil}). | ||||
| 		Model(&models.Post{}). | ||||
| 		Count(&count).Error; err != nil { | ||||
| 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	if err := database.C. | ||||
| 		Where(&models.Post{RealmID: nil}). | ||||
| 		Limit(take). | ||||
| 		Offset(offset). | ||||
| 		Order("created_at desc"). | ||||
| @@ -28,6 +35,34 @@ func listPost(c *fiber.Ctx) error { | ||||
| 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	postIds := lo.Map(posts, func(item models.Post, _ int) uint { | ||||
| 		return item.ID | ||||
| 	}) | ||||
|  | ||||
| 	var reactInfo []struct { | ||||
| 		PostID       uint | ||||
| 		LikeCount    int64 | ||||
| 		DislikeCount int64 | ||||
| 	} | ||||
|  | ||||
| 	prefix := viper.GetString("database.prefix") | ||||
| 	database.C.Raw(fmt.Sprintf(`SELECT t.id                         as post_id, | ||||
|        COALESCE(l.like_count, 0)    AS like_count, | ||||
|        COALESCE(d.dislike_count, 0) AS dislike_count | ||||
| FROM %sposts t | ||||
|          LEFT JOIN (SELECT post_id, COUNT(*) AS like_count | ||||
|                     FROM %spost_likes | ||||
|                     GROUP BY post_id) l ON t.id = l.post_id | ||||
|          LEFT JOIN (SELECT post_id, COUNT(*) AS dislike_count | ||||
|                     FROM %spost_dislikes | ||||
|                     GROUP BY post_id) d ON t.id = d.post_id | ||||
| WHERE t.id IN (?)`, prefix, prefix, prefix), postIds).Scan(&reactInfo) | ||||
|  | ||||
| 	for idx, info := range reactInfo { | ||||
| 		posts[idx].LikeCount = info.LikeCount | ||||
| 		posts[idx].DislikeCount = info.DislikeCount | ||||
| 	} | ||||
|  | ||||
| 	return c.JSON(fiber.Map{ | ||||
| 		"count": count, | ||||
| 		"data":  posts, | ||||
| @@ -39,8 +74,8 @@ func createPost(c *fiber.Ctx) error { | ||||
| 	user := c.Locals("principal").(models.Account) | ||||
|  | ||||
| 	var data struct { | ||||
| 		Alias      string            `json:"alias" validate:"required"` | ||||
| 		Title      string            `json:"title" validate:"required"` | ||||
| 		Alias      string            `json:"alias"` | ||||
| 		Title      string            `json:"title"` | ||||
| 		Content    string            `json:"content" validate:"required"` | ||||
| 		Tags       []models.Tag      `json:"tags"` | ||||
| 		Categories []models.Category `json:"categories"` | ||||
| @@ -48,6 +83,8 @@ func createPost(c *fiber.Ctx) error { | ||||
|  | ||||
| 	if err := BindAndValidate(c, &data); err != nil { | ||||
| 		return err | ||||
| 	} else if len(data.Alias) == 0 { | ||||
| 		data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "") | ||||
| 	} | ||||
|  | ||||
| 	post, err := services.NewPost(user, data.Alias, data.Title, data.Content, data.Categories, data.Tags) | ||||
| @@ -57,3 +94,32 @@ func createPost(c *fiber.Ctx) error { | ||||
|  | ||||
| 	return c.JSON(post) | ||||
| } | ||||
|  | ||||
| func reactPost(c *fiber.Ctx) error { | ||||
| 	user := c.Locals("principal").(models.Account) | ||||
| 	id, _ := c.ParamsInt("postId", 0) | ||||
|  | ||||
| 	var post models.Post | ||||
| 	if err := database.C.Where(&models.Post{ | ||||
| 		BaseModel: models.BaseModel{ID: uint(id)}, | ||||
| 	}).First(&post).Error; err != nil { | ||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	switch strings.ToLower(c.Params("reactType")) { | ||||
| 	case "like": | ||||
| 		if positive, err := services.LikePost(user, post); err != nil { | ||||
| 			return fiber.NewError(fiber.StatusBadRequest, err.Error()) | ||||
| 		} else { | ||||
| 			return c.SendStatus(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent)) | ||||
| 		} | ||||
| 	case "dislike": | ||||
| 		if positive, err := services.DislikePost(user, post); err != nil { | ||||
| 			return fiber.NewError(fiber.StatusBadRequest, err.Error()) | ||||
| 		} else { | ||||
| 			return c.SendStatus(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent)) | ||||
| 		} | ||||
| 	default: | ||||
| 		return fiber.NewError(fiber.StatusBadRequest, "unsupported reaction") | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -64,6 +64,7 @@ func NewServer() { | ||||
|  | ||||
| 		api.Get("/posts", listPost) | ||||
| 		api.Post("/posts", auth, createPost) | ||||
| 		api.Post("/posts/:postId/react/:reactType", auth, reactPost) | ||||
| 	} | ||||
|  | ||||
| 	A.Use("/", cache.New(cache.Config{ | ||||
|   | ||||
| @@ -3,6 +3,8 @@ package services | ||||
| import ( | ||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/database" | ||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/models" | ||||
| 	"errors" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| func NewPost( | ||||
| @@ -57,3 +59,41 @@ func NewPostWithRealm( | ||||
|  | ||||
| 	return post, nil | ||||
| } | ||||
|  | ||||
| func LikePost(user models.Account, post models.Post) (bool, error) { | ||||
| 	var like models.PostLike | ||||
| 	if err := database.C.Where(&models.PostLike{ | ||||
| 		AccountID: user.ID, | ||||
| 		PostID:    post.ID, | ||||
| 	}).First(&like).Error; err != nil { | ||||
| 		if !errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			return true, err | ||||
| 		} | ||||
| 		like = models.PostLike{ | ||||
| 			AccountID: user.ID, | ||||
| 			PostID:    post.ID, | ||||
| 		} | ||||
| 		return true, database.C.Save(&like).Error | ||||
| 	} else { | ||||
| 		return false, database.C.Delete(&like).Error | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func DislikePost(user models.Account, post models.Post) (bool, error) { | ||||
| 	var dislike models.PostDislike | ||||
| 	if err := database.C.Where(&models.PostDislike{ | ||||
| 		AccountID: user.ID, | ||||
| 		PostID:    post.ID, | ||||
| 	}).First(&dislike).Error; err != nil { | ||||
| 		if !errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			return true, err | ||||
| 		} | ||||
| 		dislike = models.PostDislike{ | ||||
| 			AccountID: user.ID, | ||||
| 			PostID:    post.ID, | ||||
| 		} | ||||
| 		return true, database.C.Save(&dislike).Error | ||||
| 	} else { | ||||
| 		return false, database.C.Delete(&dislike).Error | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { getAtk, useUserinfo } from "../stores/userinfo.tsx"; | ||||
| import { createSignal, For, Show } from "solid-js"; | ||||
| import { createEffect, createSignal, For, Show } from "solid-js"; | ||||
|  | ||||
| import styles from "./feed.module.css"; | ||||
|  | ||||
| @@ -9,6 +9,7 @@ export default function DashboardPage() { | ||||
|   const [error, setError] = createSignal<string | null>(null); | ||||
|   const [loading, setLoading] = createSignal(true); | ||||
|   const [submitting, setSubmitting] = createSignal(false); | ||||
|   const [reacting, setReacting] = createSignal(false); | ||||
|  | ||||
|   const [posts, setPosts] = createSignal<any[]>([]); | ||||
|   const [postCount, setPostCount] = createSignal(0); | ||||
| @@ -32,6 +33,8 @@ export default function DashboardPage() { | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   createEffect(() => readPosts(), [page()]); | ||||
|  | ||||
|   async function doPost(evt: SubmitEvent) { | ||||
|     evt.preventDefault(); | ||||
|  | ||||
| @@ -62,7 +65,20 @@ export default function DashboardPage() { | ||||
|     setSubmitting(false); | ||||
|   } | ||||
|  | ||||
|   readPosts(); | ||||
|   async function reactPost(item: any, type: string) { | ||||
|     setReacting(true); | ||||
|     const res = await fetch(`/api/posts/${item.id}/react/${type}`, { | ||||
|       method: "POST", | ||||
|       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 201 && res.status !== 204) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       await readPosts(); | ||||
|       setError(null); | ||||
|     } | ||||
|     setReacting(false); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div class={`${styles.wrapper} container mx-auto`}> | ||||
| @@ -118,57 +134,69 @@ export default function DashboardPage() { | ||||
|         </form> | ||||
|  | ||||
|         <div id="posts"> | ||||
|           <Show when={!loading()} fallback={<span class="loading loading-lg loading-infinity"></span>}> | ||||
|             <For each={posts()}> | ||||
|               {item => <div class="post-item"> | ||||
|           <For each={posts()}> | ||||
|             {item => <div class="post-item"> | ||||
|  | ||||
|                 <div class="flex bg-base-200"> | ||||
|                   <div class="avatar"> | ||||
|                     <div class="w-12"> | ||||
|                       <Show when={item.author.avatar} | ||||
|                             fallback={<span class="text-3xl">{item.author.name.substring(0, 1)}</span>}> | ||||
|                         <img alt="avatar" src={item.author.avatar} /> | ||||
|                       </Show> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                   <div class="flex items-center px-5"> | ||||
|                     <div> | ||||
|                       <h3 class="font-bold text-sm">{item.author.name}</h3> | ||||
|                       <p class="text-xs">{item.author.description}</p> | ||||
|                     </div> | ||||
|               <div class="flex bg-base-200"> | ||||
|               <div class="avatar"> | ||||
|                   <div class="w-12"> | ||||
|                     <Show when={item.author.avatar} | ||||
|                           fallback={<span class="text-3xl">{item.author.name.substring(0, 1)}</span>}> | ||||
|                       <img alt="avatar" src={item.author.avatar} /> | ||||
|                     </Show> | ||||
|                   </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <article class="py-5 px-7"> | ||||
|                   <h2 class="card-title">{item.title}</h2> | ||||
|                   <article class="prose">{item.content}</article> | ||||
|                 </article> | ||||
|  | ||||
|                 <div class="grid grid-cols-4 border-y border-base-200"> | ||||
|                   <div class="tooltip" data-tip="Daisuki"> | ||||
|                     <button type="button" class="btn btn-ghost btn-block"> | ||||
|                       <i class="fa-solid fa-thumbs-up"></i> | ||||
|                       <code class="font-mono">0</code> | ||||
|                     </button> | ||||
|                 <div class="flex items-center px-5"> | ||||
|                   <div> | ||||
|                     <h3 class="font-bold text-sm">{item.author.name}</h3> | ||||
|                     <p class="text-xs">{item.author.description}</p> | ||||
|                   </div> | ||||
|                   <div class="tooltip" data-tip="Daikirai"> | ||||
|                     <button type="button" class="btn btn-ghost btn-block"> | ||||
|                       <i class="fa-solid fa-thumbs-down"></i> | ||||
|                       <code class="font-mono">0</code> | ||||
|                     </button> | ||||
|                   </div> | ||||
|                   <button type="button" class="btn btn-ghost"> | ||||
|                     <i class="fa-solid fa-reply"></i> | ||||
|                     <span>Reply</span> | ||||
|                   </button> | ||||
|                   <button type="button" class="btn btn-ghost"> | ||||
|                     <i class="fa-solid fa-retweet"></i> | ||||
|                     <span>Forward</span> | ||||
|                 </div> | ||||
|               </div> | ||||
|  | ||||
|               <article class="py-5 px-7"> | ||||
|                 <h2 class="card-title">{item.title}</h2> | ||||
|                 <article class="prose">{item.content}</article> | ||||
|               </article> | ||||
|  | ||||
|               <div class="grid grid-cols-4 border-y border-base-200"> | ||||
|  | ||||
|                 <div class="tooltip" data-tip="Daisuki"> | ||||
|                   <button type="button" class="btn btn-ghost btn-block" disabled={reacting()} | ||||
|                           onClick={() => reactPost(item, "like")}> | ||||
|                     <i class="fa-solid fa-thumbs-up"></i> | ||||
|                     <code class="font-mono">{item.like_count}</code> | ||||
|                   </button> | ||||
|                 </div> | ||||
|  | ||||
|               </div>} | ||||
|             </For> | ||||
|                 <div class="tooltip" data-tip="Daikirai"> | ||||
|                   <button type="button" class="btn btn-ghost btn-block" disabled={reacting()} | ||||
|                           onClick={() => reactPost(item, "dislike")}> | ||||
|                     <i class="fa-solid fa-thumbs-down"></i> | ||||
|                     <code class="font-mono">{item.dislike_count}</code> | ||||
|                   </button> | ||||
|                 </div> | ||||
|  | ||||
|                 <button type="button" class="btn btn-ghost"> | ||||
|                   <i class="fa-solid fa-reply"></i> | ||||
|                   <span>Reply</span> | ||||
|                 </button> | ||||
|  | ||||
|                 <button type="button" class="btn btn-ghost"> | ||||
|                   <i class="fa-solid fa-retweet"></i> | ||||
|                   <span>Forward</span> | ||||
|                 </button> | ||||
|  | ||||
|               </div> | ||||
|  | ||||
|             </div>} | ||||
|           </For> | ||||
|  | ||||
|           <Show when={loading()}> | ||||
|             <div class="w-full border-b border-base-200 pt-5 pb-7 text-center"> | ||||
|               <p class="loading loading-lg loading-infinity"></p> | ||||
|               <p>Creating fake news...</p> | ||||
|             </div> | ||||
|           </Show> | ||||
|         </div> | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user