♻️ Interactive v2 #1
| @@ -25,7 +25,6 @@ func contextComment() *services.PostTypeContext { | |||||||
| func listComment(c *fiber.Ctx) error { | func listComment(c *fiber.Ctx) error { | ||||||
| 	take := c.QueryInt("take", 0) | 	take := c.QueryInt("take", 0) | ||||||
| 	offset := c.QueryInt("offset", 0) | 	offset := c.QueryInt("offset", 0) | ||||||
| 	noReact := c.QueryBool("noReact", false) |  | ||||||
|  |  | ||||||
| 	alias := c.Params("postId") | 	alias := c.Params("postId") | ||||||
|  |  | ||||||
| @@ -37,7 +36,7 @@ func listComment(c *fiber.Ctx) error { | |||||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	data, err := mx.ListComment(item.ID, take, offset, noReact) | 	data, err := mx.ListComment(item.ID, take, offset) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| package server | package server | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"fmt" | ||||||
| 	"time" | 	"time" | ||||||
|  |  | ||||||
| 	"code.smartsheep.studio/hydrogen/interactive/pkg/database" | 	"code.smartsheep.studio/hydrogen/interactive/pkg/database" | ||||||
| @@ -94,7 +95,6 @@ func listPost(c *fiber.Ctx) error { | |||||||
|  |  | ||||||
| func reactPost(c *fiber.Ctx) error { | func reactPost(c *fiber.Ctx) error { | ||||||
| 	user := c.Locals("principal").(models.Account) | 	user := c.Locals("principal").(models.Account) | ||||||
| 	id, _ := c.ParamsInt("articleId", 0) |  | ||||||
|  |  | ||||||
| 	var data struct { | 	var data struct { | ||||||
| 		Symbol   string                  `json:"symbol" validate:"required"` | 		Symbol   string                  `json:"symbol" validate:"required"` | ||||||
| @@ -107,16 +107,40 @@ func reactPost(c *fiber.Ctx) error { | |||||||
|  |  | ||||||
| 	mx := c.Locals(postContextKey).(*services.PostTypeContext) | 	mx := c.Locals(postContextKey).(*services.PostTypeContext) | ||||||
|  |  | ||||||
| 	item, err := mx.Get(uint(id), true) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	reaction := models.Reaction{ | 	reaction := models.Reaction{ | ||||||
| 		Symbol:    data.Symbol, | 		Symbol:    data.Symbol, | ||||||
| 		Attitude:  data.Attitude, | 		Attitude:  data.Attitude, | ||||||
| 		AccountID: user.ID, | 		AccountID: user.ID, | ||||||
| 		ArticleID: &item.ID, | 	} | ||||||
|  |  | ||||||
|  | 	postType := c.Params("postType") | ||||||
|  | 	alias := c.Params("postId") | ||||||
|  |  | ||||||
|  | 	var err error | ||||||
|  | 	var res models.Feed | ||||||
|  |  | ||||||
|  | 	switch postType { | ||||||
|  | 	case "moments": | ||||||
|  | 		err = database.C.Model(&models.Moment{}).Where("id = ?", alias).Select("id").First(&res).Error | ||||||
|  | 	case "articles": | ||||||
|  | 		err = database.C.Model(&models.Article{}).Where("id = ?", alias).Select("id").First(&res).Error | ||||||
|  | 	case "comments": | ||||||
|  | 		err = database.C.Model(&models.Comment{}).Where("id = ?", alias).Select("id").First(&res).Error | ||||||
|  | 	default: | ||||||
|  | 		return fiber.NewError(fiber.StatusBadRequest, "comment must belongs to a resource") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("belongs to resource was not found: %v", err)) | ||||||
|  | 	} else { | ||||||
|  | 		switch postType { | ||||||
|  | 		case "moments": | ||||||
|  | 			reaction.MomentID = &res.ID | ||||||
|  | 		case "articles": | ||||||
|  | 			reaction.ArticleID = &res.ID | ||||||
|  | 		case "comments": | ||||||
|  | 			reaction.CommentID = &res.ID | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if positive, reaction, err := mx.React(reaction); err != nil { | 	if positive, reaction, err := mx.React(reaction); err != nil { | ||||||
|   | |||||||
| @@ -20,7 +20,7 @@ func (v *PostTypeContext) ListComment(id uint, take int, offset int, noReact ... | |||||||
| 	userTable := viper.GetString("database.prefix") + "accounts" | 	userTable := viper.GetString("database.prefix") + "accounts" | ||||||
| 	if err := v.Tx. | 	if err := v.Tx. | ||||||
| 		Table(table). | 		Table(table). | ||||||
| 		Select("*, ? as model_type", "comments"). | 		Select("*, ? as model_type", "comment"). | ||||||
| 		Where(v.ColumnName+"_id = ?", id). | 		Where(v.ColumnName+"_id = ?", id). | ||||||
| 		Joins(fmt.Sprintf("INNER JOIN %s as author ON author_id = author.id", userTable)). | 		Joins(fmt.Sprintf("INNER JOIN %s as author ON author_id = author.id", userTable)). | ||||||
| 		Limit(take).Offset(offset).Find(&items).Error; err != nil { | 		Limit(take).Offset(offset).Find(&items).Error; err != nil { | ||||||
|   | |||||||
							
								
								
									
										57
									
								
								pkg/views/src/components/comments/CommentList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								pkg/views/src/components/comments/CommentList.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | |||||||
|  | <template> | ||||||
|  |   <div v-if="loading" class="text-center flex items-center justify-center"> | ||||||
|  |     <v-progress-circular indeterminate /> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div v-else class="flex flex-col gap-2 mt-3"> | ||||||
|  |     <div v-for="(item, idx) in props.comments" class="text-sm"> | ||||||
|  |       <post-item :item="item" @update:item="val => updateItem(idx, val)" /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { request } from "@/scripts/request" | ||||||
|  | import { reactive, ref } from "vue" | ||||||
|  | import PostItem from "@/components/posts/PostItem.vue" | ||||||
|  |  | ||||||
|  | const props = defineProps<{ | ||||||
|  |   comments: any[] | ||||||
|  |   model: any | ||||||
|  |   alias: any | ||||||
|  | }>() | ||||||
|  | const emits = defineEmits(["update:comments"]) | ||||||
|  |  | ||||||
|  | const loading = ref(false) | ||||||
|  | const error = ref<string | null>(null) | ||||||
|  |  | ||||||
|  | const pagination = reactive({ page: 0, pageSize: 10, total: 0 }) | ||||||
|  |  | ||||||
|  | async function readComments() { | ||||||
|  |   loading.value = true | ||||||
|  |   const res = await request( | ||||||
|  |     `/api/p/${props.model}/${props.alias}/comments?` + | ||||||
|  |       new URLSearchParams({ | ||||||
|  |         take: pagination.pageSize.toString(), | ||||||
|  |         offset: (pagination.page * pagination.pageSize).toString() | ||||||
|  |       }) | ||||||
|  |   ) | ||||||
|  |   if (res.status !== 200) { | ||||||
|  |     error.value = await res.text() | ||||||
|  |   } else { | ||||||
|  |     error.value = null | ||||||
|  |     const data = await res.json() | ||||||
|  |     pagination.total = data["total"] | ||||||
|  |     emits("update:comments", data["data"]) | ||||||
|  |   } | ||||||
|  |   loading.value = false | ||||||
|  | } | ||||||
|  |  | ||||||
|  | readComments() | ||||||
|  |  | ||||||
|  | function updateItem(idx: number, data: any) { | ||||||
|  |   const comments = JSON.parse(JSON.stringify(props.comments)); | ||||||
|  |   comments[idx] = data; | ||||||
|  |   emits("update:comments", comments); | ||||||
|  | } | ||||||
|  | </script> | ||||||
							
								
								
									
										20
									
								
								pkg/views/src/components/posts/CommentContent.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								pkg/views/src/components/posts/CommentContent.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | <template> | ||||||
|  |   <article class="prose prose-comment" v-html="parseContent(props.item.content)" /> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import dompurify from "dompurify"; | ||||||
|  | import { parse } from "marked"; | ||||||
|  |  | ||||||
|  | const props = defineProps<{ item: any }>(); | ||||||
|  |  | ||||||
|  | function parseContent(src: string): string { | ||||||
|  |   return dompurify().sanitize(parse(src) as string); | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style> | ||||||
|  | .prose.prose-comment p { | ||||||
|  |   margin: 0 !important; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -1,6 +1,4 @@ | |||||||
| <template> | <template> | ||||||
|   <v-card :loading="props.loading"> |  | ||||||
|     <template #text> |  | ||||||
|   <div class="flex gap-3"> |   <div class="flex gap-3"> | ||||||
|     <div> |     <div> | ||||||
|       <v-avatar |       <v-avatar | ||||||
| @@ -14,8 +12,8 @@ | |||||||
|     <div class="flex-grow-1"> |     <div class="flex-grow-1"> | ||||||
|       <div class="font-bold">{{ props.item?.author.nick }}</div> |       <div class="font-bold">{{ props.item?.author.nick }}</div> | ||||||
|  |  | ||||||
|           <div v-if="props.item?.model_type === 'article'" class="text-xs text-grey-darken-4 mb-2">Published an |       <div v-if="props.item?.model_type === 'article'" class="text-xs text-grey-darken-4 mb-2"> | ||||||
|             article |         Published an article | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|       <component :is="renderer[props.item?.model_type]" v-bind="props" /> |       <component :is="renderer[props.item?.model_type]" v-bind="props" /> | ||||||
| @@ -29,32 +27,35 @@ | |||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|     </template> |  | ||||||
|   </v-card> |  | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import type { Component } from "vue"; | import type { Component } from "vue" | ||||||
| import ArticleContent from "@/components/posts/ArticleContent.vue"; | import ArticleContent from "@/components/posts/ArticleContent.vue" | ||||||
| import MomentContent from "@/components/posts/MomentContent.vue"; | import MomentContent from "@/components/posts/MomentContent.vue" | ||||||
| import PostReaction from "@/components/posts/PostReaction.vue"; | import CommentContent from "@/components/posts/CommentContent.vue" | ||||||
|  | import PostReaction from "@/components/posts/PostReaction.vue" | ||||||
|  |  | ||||||
| const props = defineProps<{ item: any, brief?: boolean, loading?: boolean }>(); | const props = defineProps<{ item: any; brief?: boolean }>() | ||||||
| const emits = defineEmits(["update:item"]); | const emits = defineEmits(["update:item"]) | ||||||
|  |  | ||||||
| const renderer: { [id: string]: Component } = { | const renderer: { [id: string]: Component } = { | ||||||
|   article: ArticleContent, |   article: ArticleContent, | ||||||
|   moment: MomentContent |   moment: MomentContent, | ||||||
| }; |   comment: CommentContent | ||||||
|  | } | ||||||
|  |  | ||||||
| function updateReactions(symbol: string, num: number) { | function updateReactions(symbol: string, num: number) { | ||||||
|   const item = JSON.parse(JSON.stringify(props.item)); |   const item = JSON.parse(JSON.stringify(props.item)) | ||||||
|   if (item.reaction_list.hasOwnProperty(symbol)) { |   if (item.reaction_list == null) { | ||||||
|     item.reaction_list[symbol] += num; |     item.reaction_list = {} | ||||||
|   } else { |  | ||||||
|     item.reaction_list[symbol] = num; |  | ||||||
|   } |   } | ||||||
|   emits("update:item", item); |   if (item.reaction_list.hasOwnProperty(symbol)) { | ||||||
|  |     item.reaction_list[symbol] += num | ||||||
|  |   } else { | ||||||
|  |     item.reaction_list[symbol] = num | ||||||
|  |   } | ||||||
|  |   emits("update:item", item) | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,7 +7,11 @@ | |||||||
|     <v-infinite-scroll :items="props.posts" :onLoad="props.loader"> |     <v-infinite-scroll :items="props.posts" :onLoad="props.loader"> | ||||||
|       <template v-for="(item, idx) in props.posts" :key="item"> |       <template v-for="(item, idx) in props.posts" :key="item"> | ||||||
|         <div class="mb-3 px-1"> |         <div class="mb-3 px-1"> | ||||||
|  |           <v-card> | ||||||
|  |             <template #text> | ||||||
|               <post-item brief :item="item" @update:item="val => updateItem(idx, val)" /> |               <post-item brief :item="item" @update:item="val => updateItem(idx, val)" /> | ||||||
|  |             </template> | ||||||
|  |           </v-card> | ||||||
|         </div> |         </div> | ||||||
|       </template> |       </template> | ||||||
|     </v-infinite-scroll> |     </v-infinite-scroll> | ||||||
|   | |||||||
| @@ -43,7 +43,7 @@ const emits = defineEmits(["update"]); | |||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
|   size?: string, |   size?: string, | ||||||
|   readonly?: boolean, |   readonly?: boolean, | ||||||
|   model: string, |   model: any, | ||||||
|   item: any, |   item: any, | ||||||
|   reactions: { [id: string]: number } |   reactions: { [id: string]: number } | ||||||
| }>(); | }>(); | ||||||
| @@ -63,7 +63,7 @@ const status = reactive({ added: false, removed: false }); | |||||||
| const error = ref<string | null>(null); | const error = ref<string | null>(null); | ||||||
|  |  | ||||||
| async function reactPost(symbol: string, attitude: number) { | async function reactPost(symbol: string, attitude: number) { | ||||||
|   const res = await request(`/api/${props.model}/${props.item?.id}/react`, { |   const res = await request(`/api/p/${props.model}/${props.item?.id}/react`, { | ||||||
|     method: "POST", |     method: "POST", | ||||||
|     headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" }, |     headers: { "Authorization": `Bearer ${getAtk()}`, "Content-Type": "application/json" }, | ||||||
|     body: JSON.stringify({ symbol, attitude }) |     body: JSON.stringify({ symbol, attitude }) | ||||||
|   | |||||||
| @@ -11,6 +11,17 @@ | |||||||
|             <v-divider class="mt-5 mx-[-16px] border-opacity-50" /> |             <v-divider class="mt-5 mx-[-16px] border-opacity-50" /> | ||||||
|  |  | ||||||
|             <article-content :item="post" content-only /> |             <article-content :item="post" content-only /> | ||||||
|  |  | ||||||
|  |             <v-divider class="my-5 mx-[-16px] border-opacity-50" /> | ||||||
|  |  | ||||||
|  |             <div class="px-3"> | ||||||
|  |               <post-reaction | ||||||
|  |                 :item="post" | ||||||
|  |                 :model="route.params.postType" | ||||||
|  |                 :reactions="post?.reaction_list ?? {}" | ||||||
|  |                 @update="updateReactions" | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|           </v-card-text> |           </v-card-text> | ||||||
|         </article> |         </article> | ||||||
|       </v-card> |       </v-card> | ||||||
| @@ -18,14 +29,8 @@ | |||||||
|  |  | ||||||
|     <div class="aside sticky top-0 w-full h-fit md:min-w-[280px]"> |     <div class="aside sticky top-0 w-full h-fit md:min-w-[280px]"> | ||||||
|       <v-card title="Comments"> |       <v-card title="Comments"> | ||||||
|         <v-list density="compact"> |  | ||||||
|         </v-list> |  | ||||||
|       </v-card> |  | ||||||
|  |  | ||||||
|       <v-card title="Reactions" class="mt-3"> |  | ||||||
|         <div class="px-[1rem] pb-[0.825rem] mt-[-12px]"> |         <div class="px-[1rem] pb-[0.825rem] mt-[-12px]"> | ||||||
|           <post-reaction :item="post" :model="route.params.postType" :reactions="post?.reaction_list ?? {}" |           <comment-list v-model:comments="comments" :model="route.params.postType" :alias="route.params.alias" /> | ||||||
|             @update="updateReactions" /> |  | ||||||
|         </div> |         </div> | ||||||
|       </v-card> |       </v-card> | ||||||
|     </div> |     </div> | ||||||
| @@ -33,37 +38,40 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { ref } from "vue"; | import { ref } from "vue" | ||||||
| import { request } from "@/scripts/request"; | import { request } from "@/scripts/request" | ||||||
| import { useRoute } from "vue-router"; | import ArticleContent from "@/components/posts/ArticleContent.vue" | ||||||
| import ArticleContent from "@/components/posts/ArticleContent.vue"; | import PostReaction from "@/components/posts/PostReaction.vue" | ||||||
| import PostReaction from "@/components/posts/PostReaction.vue"; | import CommentList from "@/components/comments/CommentList.vue" | ||||||
|  | import { useRoute } from "vue-router" | ||||||
|  |  | ||||||
| const loading = ref(false); | const loading = ref(false) | ||||||
| const error = ref<string | null>(null); | const error = ref<string | null>(null) | ||||||
| const post = ref<any>(null); |  | ||||||
|  |  | ||||||
| const route = useRoute(); | const post = ref<any>(null) | ||||||
|  | const comments = ref<any[]>([]) | ||||||
|  |  | ||||||
|  | const route = useRoute() | ||||||
|  |  | ||||||
| async function readPost() { | async function readPost() { | ||||||
|   loading.value = true; |   loading.value = true | ||||||
|   const res = await request(`/api/p/${route.params.postType}/${route.params.alias}?`); |   const res = await request(`/api/p/${route.params.postType}/${route.params.alias}`) | ||||||
|   if (res.status !== 200) { |   if (res.status !== 200) { | ||||||
|     error.value = await res.text(); |     error.value = await res.text() | ||||||
|   } else { |   } else { | ||||||
|     error.value = null; |     error.value = null | ||||||
|     post.value = await res.json(); |     post.value = await res.json() | ||||||
|   } |   } | ||||||
|   loading.value = false; |   loading.value = false | ||||||
| } | } | ||||||
|  |  | ||||||
| readPost(); | readPost() | ||||||
|  |  | ||||||
| function updateReactions(symbol: string, num: number) { | function updateReactions(symbol: string, num: number) { | ||||||
|   if (post.value.reaction_list.hasOwnProperty(symbol)) { |   if (post.value.reaction_list.hasOwnProperty(symbol)) { | ||||||
|     post.value.reaction_list[symbol] += num; |     post.value.reaction_list[symbol] += num | ||||||
|   } else { |   } else { | ||||||
|     post.value.reaction_list[symbol] = num; |     post.value.reaction_list[symbol] = num | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user