✨ Reacting
This commit is contained in:
		| @@ -12,6 +12,8 @@ func RunMigration(source *gorm.DB) error { | |||||||
| 		&models.Category{}, | 		&models.Category{}, | ||||||
| 		&models.Tag{}, | 		&models.Tag{}, | ||||||
| 		&models.Post{}, | 		&models.Post{}, | ||||||
|  | 		&models.PostLike{}, | ||||||
|  | 		&models.PostDislike{}, | ||||||
| 	); err != nil { | 	); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -12,6 +12,8 @@ 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"` | ||||||
|  | 	LikedPosts    []PostLike    `json:"liked_posts"` | ||||||
|  | 	DislikedPosts []PostDislike `json:"disliked_posts"` | ||||||
| 	Realms        []Realm       `json:"realms"` | 	Realms        []Realm       `json:"realms"` | ||||||
| 	ExternalID    uint          `json:"external_id"` | 	ExternalID    uint          `json:"external_id"` | ||||||
| } | } | ||||||
|   | |||||||
| @@ -10,8 +10,14 @@ type Post struct { | |||||||
| 	Content          string        `json:"content"` | 	Content          string        `json:"content"` | ||||||
| 	Tags             []Tag         `gorm:"many2many:post_tags"` | 	Tags             []Tag         `gorm:"many2many:post_tags"` | ||||||
| 	Categories       []Category    `gorm:"many2many:post_categories"` | 	Categories       []Category    `gorm:"many2many:post_categories"` | ||||||
|  | 	LikedAccounts    []PostLike    `json:"liked_accounts"` | ||||||
|  | 	DislikedAccounts []PostDislike `json:"disliked_accounts"` | ||||||
| 	PublishedAt      time.Time     `json:"published_at"` | 	PublishedAt      time.Time     `json:"published_at"` | ||||||
| 	RealmID          *uint         `json:"realm_id"` | 	RealmID          *uint         `json:"realm_id"` | ||||||
| 	AuthorID         uint          `json:"author_id"` | 	AuthorID         uint          `json:"author_id"` | ||||||
| 	Author           Account       `json:"author"` | 	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/database" | ||||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/models" | 	"code.smartsheep.studio/hydrogen/interactive/pkg/models" | ||||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/services" | 	"code.smartsheep.studio/hydrogen/interactive/pkg/services" | ||||||
|  | 	"fmt" | ||||||
| 	"github.com/gofiber/fiber/v2" | 	"github.com/gofiber/fiber/v2" | ||||||
|  | 	"github.com/google/uuid" | ||||||
|  | 	"github.com/samber/lo" | ||||||
|  | 	"github.com/spf13/viper" | ||||||
|  | 	"strings" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func listPost(c *fiber.Ctx) error { | func listPost(c *fiber.Ctx) error { | ||||||
| @@ -14,12 +19,14 @@ func listPost(c *fiber.Ctx) error { | |||||||
| 	var count int64 | 	var count int64 | ||||||
| 	var posts []models.Post | 	var posts []models.Post | ||||||
| 	if err := database.C. | 	if err := database.C. | ||||||
|  | 		Where(&models.Post{RealmID: nil}). | ||||||
| 		Model(&models.Post{}). | 		Model(&models.Post{}). | ||||||
| 		Count(&count).Error; err != nil { | 		Count(&count).Error; err != nil { | ||||||
| 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := database.C. | 	if err := database.C. | ||||||
|  | 		Where(&models.Post{RealmID: nil}). | ||||||
| 		Limit(take). | 		Limit(take). | ||||||
| 		Offset(offset). | 		Offset(offset). | ||||||
| 		Order("created_at desc"). | 		Order("created_at desc"). | ||||||
| @@ -28,6 +35,34 @@ func listPost(c *fiber.Ctx) error { | |||||||
| 		return fiber.NewError(fiber.StatusInternalServerError, err.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{ | 	return c.JSON(fiber.Map{ | ||||||
| 		"count": count, | 		"count": count, | ||||||
| 		"data":  posts, | 		"data":  posts, | ||||||
| @@ -39,8 +74,8 @@ 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" validate:"required"` | 		Alias      string            `json:"alias"` | ||||||
| 		Title      string            `json:"title" validate:"required"` | 		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"` | ||||||
| @@ -48,6 +83,8 @@ func createPost(c *fiber.Ctx) error { | |||||||
|  |  | ||||||
| 	if err := BindAndValidate(c, &data); err != nil { | 	if err := BindAndValidate(c, &data); err != nil { | ||||||
| 		return err | 		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) | 	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) | 	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.Get("/posts", listPost) | ||||||
| 		api.Post("/posts", auth, createPost) | 		api.Post("/posts", auth, createPost) | ||||||
|  | 		api.Post("/posts/:postId/react/:reactType", auth, reactPost) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	A.Use("/", cache.New(cache.Config{ | 	A.Use("/", cache.New(cache.Config{ | ||||||
|   | |||||||
| @@ -3,6 +3,8 @@ package services | |||||||
| import ( | import ( | ||||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/database" | 	"code.smartsheep.studio/hydrogen/interactive/pkg/database" | ||||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/models" | 	"code.smartsheep.studio/hydrogen/interactive/pkg/models" | ||||||
|  | 	"errors" | ||||||
|  | 	"gorm.io/gorm" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func NewPost( | func NewPost( | ||||||
| @@ -57,3 +59,41 @@ func NewPostWithRealm( | |||||||
|  |  | ||||||
| 	return post, nil | 	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 { 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"; | import styles from "./feed.module.css"; | ||||||
|  |  | ||||||
| @@ -9,6 +9,7 @@ export default function DashboardPage() { | |||||||
|   const [error, setError] = createSignal<string | null>(null); |   const [error, setError] = createSignal<string | null>(null); | ||||||
|   const [loading, setLoading] = createSignal(true); |   const [loading, setLoading] = createSignal(true); | ||||||
|   const [submitting, setSubmitting] = createSignal(false); |   const [submitting, setSubmitting] = createSignal(false); | ||||||
|  |   const [reacting, setReacting] = createSignal(false); | ||||||
|  |  | ||||||
|   const [posts, setPosts] = createSignal<any[]>([]); |   const [posts, setPosts] = createSignal<any[]>([]); | ||||||
|   const [postCount, setPostCount] = createSignal(0); |   const [postCount, setPostCount] = createSignal(0); | ||||||
| @@ -32,6 +33,8 @@ export default function DashboardPage() { | |||||||
|     setLoading(false); |     setLoading(false); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   createEffect(() => readPosts(), [page()]); | ||||||
|  |  | ||||||
|   async function doPost(evt: SubmitEvent) { |   async function doPost(evt: SubmitEvent) { | ||||||
|     evt.preventDefault(); |     evt.preventDefault(); | ||||||
|  |  | ||||||
| @@ -62,7 +65,20 @@ export default function DashboardPage() { | |||||||
|     setSubmitting(false); |     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 ( |   return ( | ||||||
|     <div class={`${styles.wrapper} container mx-auto`}> |     <div class={`${styles.wrapper} container mx-auto`}> | ||||||
| @@ -118,7 +134,6 @@ export default function DashboardPage() { | |||||||
|         </form> |         </form> | ||||||
|  |  | ||||||
|         <div id="posts"> |         <div id="posts"> | ||||||
|           <Show when={!loading()} fallback={<span class="loading loading-lg loading-infinity"></span>}> |  | ||||||
|           <For each={posts()}> |           <For each={posts()}> | ||||||
|             {item => <div class="post-item"> |             {item => <div class="post-item"> | ||||||
|  |  | ||||||
| @@ -145,30 +160,43 @@ export default function DashboardPage() { | |||||||
|               </article> |               </article> | ||||||
|  |  | ||||||
|               <div class="grid grid-cols-4 border-y border-base-200"> |               <div class="grid grid-cols-4 border-y border-base-200"> | ||||||
|  |  | ||||||
|                 <div class="tooltip" data-tip="Daisuki"> |                 <div class="tooltip" data-tip="Daisuki"> | ||||||
|                     <button type="button" class="btn btn-ghost btn-block"> |                   <button type="button" class="btn btn-ghost btn-block" disabled={reacting()} | ||||||
|  |                           onClick={() => reactPost(item, "like")}> | ||||||
|                     <i class="fa-solid fa-thumbs-up"></i> |                     <i class="fa-solid fa-thumbs-up"></i> | ||||||
|                       <code class="font-mono">0</code> |                     <code class="font-mono">{item.like_count}</code> | ||||||
|                   </button> |                   </button> | ||||||
|                 </div> |                 </div> | ||||||
|  |  | ||||||
|                 <div class="tooltip" data-tip="Daikirai"> |                 <div class="tooltip" data-tip="Daikirai"> | ||||||
|                     <button type="button" class="btn btn-ghost btn-block"> |                   <button type="button" class="btn btn-ghost btn-block" disabled={reacting()} | ||||||
|  |                           onClick={() => reactPost(item, "dislike")}> | ||||||
|                     <i class="fa-solid fa-thumbs-down"></i> |                     <i class="fa-solid fa-thumbs-down"></i> | ||||||
|                       <code class="font-mono">0</code> |                     <code class="font-mono">{item.dislike_count}</code> | ||||||
|                   </button> |                   </button> | ||||||
|                 </div> |                 </div> | ||||||
|  |  | ||||||
|                 <button type="button" class="btn btn-ghost"> |                 <button type="button" class="btn btn-ghost"> | ||||||
|                   <i class="fa-solid fa-reply"></i> |                   <i class="fa-solid fa-reply"></i> | ||||||
|                   <span>Reply</span> |                   <span>Reply</span> | ||||||
|                 </button> |                 </button> | ||||||
|  |  | ||||||
|                 <button type="button" class="btn btn-ghost"> |                 <button type="button" class="btn btn-ghost"> | ||||||
|                   <i class="fa-solid fa-retweet"></i> |                   <i class="fa-solid fa-retweet"></i> | ||||||
|                   <span>Forward</span> |                   <span>Forward</span> | ||||||
|                 </button> |                 </button> | ||||||
|  |  | ||||||
|               </div> |               </div> | ||||||
|  |  | ||||||
|             </div>} |             </div>} | ||||||
|           </For> |           </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> |           </Show> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user