♻️ Interactive v2 #1
| @@ -22,11 +22,11 @@ func contextArticle() *services.PostTypeContext[models.Article] { | |||||||
| } | } | ||||||
|  |  | ||||||
| func getArticle(c *fiber.Ctx) error { | func getArticle(c *fiber.Ctx) error { | ||||||
| 	id, _ := c.ParamsInt("articleId", 0) | 	alias := c.Params("articleId") | ||||||
|  |  | ||||||
| 	mx := contextArticle().FilterPublishedAt(time.Now()) | 	mx := contextArticle().FilterPublishedAt(time.Now()) | ||||||
|  |  | ||||||
| 	item, err := mx.Get(uint(id)) | 	item, err := mx.GetViaAlias(alias) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -23,11 +23,11 @@ func contextComment() *services.PostTypeContext[models.Comment] { | |||||||
| } | } | ||||||
|  |  | ||||||
| func getComment(c *fiber.Ctx) error { | func getComment(c *fiber.Ctx) error { | ||||||
| 	id, _ := c.ParamsInt("commentId", 0) | 	alias := c.Params("commentId") | ||||||
|  |  | ||||||
| 	mx := contextComment().FilterPublishedAt(time.Now()) | 	mx := contextComment().FilterPublishedAt(time.Now()) | ||||||
|  |  | ||||||
| 	item, err := mx.Get(uint(id)) | 	item, err := mx.GetViaAlias(alias) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ import ( | |||||||
| type FeedItem struct { | type FeedItem struct { | ||||||
| 	models.BaseModel | 	models.BaseModel | ||||||
|  |  | ||||||
|  | 	Alias         string `json:"alias"` | ||||||
| 	Title         string `json:"title"` | 	Title         string `json:"title"` | ||||||
| 	Description   string `json:"description"` | 	Description   string `json:"description"` | ||||||
| 	Content       string `json:"content"` | 	Content       string `json:"content"` | ||||||
| @@ -25,8 +26,8 @@ type FeedItem struct { | |||||||
| } | } | ||||||
|  |  | ||||||
| const ( | const ( | ||||||
| 	queryArticle = "id, created_at, updated_at, title, content, description, realm_id, author_id, 'article' as model_type" | 	queryArticle = "id, created_at, updated_at, alias, title, NULL as content, description, realm_id, author_id, 'article' as model_type" | ||||||
| 	queryMoment  = "id, created_at, updated_at, NULL as title, content, NULL as description, realm_id, author_id, 'moment' as model_type" | 	queryMoment  = "id, created_at, updated_at, alias, NULL as title, content, NULL as description, realm_id, author_id, 'moment' as model_type" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func listFeed(c *fiber.Ctx) error { | func listFeed(c *fiber.Ctx) error { | ||||||
| @@ -83,5 +84,14 @@ func listFeed(c *fiber.Ctx) error { | |||||||
| 		offset, | 		offset, | ||||||
| 	).Scan(&result) | 	).Scan(&result) | ||||||
|  |  | ||||||
| 	return c.JSON(result) | 	var count int64 | ||||||
|  | 	database.C.Raw(`SELECT COUNT(*) FROM (? UNION ALL ?) as feed`, | ||||||
|  | 		database.C.Select(queryArticle).Model(&models.Article{}), | ||||||
|  | 		database.C.Select(queryMoment).Model(&models.Moment{}), | ||||||
|  | 	).Scan(&count) | ||||||
|  |  | ||||||
|  | 	return c.JSON(fiber.Map{ | ||||||
|  | 		"count": count, | ||||||
|  | 		"data":  result, | ||||||
|  | 	}) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -22,11 +22,11 @@ func contextMoment() *services.PostTypeContext[models.Moment] { | |||||||
| } | } | ||||||
|  |  | ||||||
| func getMoment(c *fiber.Ctx) error { | func getMoment(c *fiber.Ctx) error { | ||||||
| 	id, _ := c.ParamsInt("momentId", 0) | 	alias := c.Params("momentId") | ||||||
|  |  | ||||||
| 	mx := contextMoment().FilterPublishedAt(time.Now()) | 	mx := contextMoment().FilterPublishedAt(time.Now()) | ||||||
|  |  | ||||||
| 	item, err := mx.Get(uint(id)) | 	item, err := mx.GetViaAlias(alias) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -37,8 +37,7 @@ func (v *PostTypeContext[T]) Preload() *PostTypeContext[T] { | |||||||
| 	v.Tx.Preload("Author"). | 	v.Tx.Preload("Author"). | ||||||
| 		Preload("Attachments"). | 		Preload("Attachments"). | ||||||
| 		Preload("Categories"). | 		Preload("Categories"). | ||||||
| 		Preload("Hashtags"). | 		Preload("Hashtags") | ||||||
| 		Preload("Reactions") |  | ||||||
|  |  | ||||||
| 	if v.CanReply { | 	if v.CanReply { | ||||||
| 		v.Tx.Preload("ReplyTo") | 		v.Tx.Preload("ReplyTo") | ||||||
| @@ -99,6 +98,15 @@ func (v *PostTypeContext[T]) SortCreatedAt(order string) *PostTypeContext[T] { | |||||||
| 	return v | 	return v | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func (v *PostTypeContext[T]) GetViaAlias(alias string) (T, error) { | ||||||
|  | 	var item T | ||||||
|  | 	if err := v.Preload().Tx.Where("alias = ?", alias).First(&item).Error; err != nil { | ||||||
|  | 		return item, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return item, nil | ||||||
|  | } | ||||||
|  |  | ||||||
| func (v *PostTypeContext[T]) Get(id uint) (T, error) { | func (v *PostTypeContext[T]) Get(id uint) (T, error) { | ||||||
| 	var item T | 	var item T | ||||||
| 	if err := v.Preload().Tx.Where("id = ?", id).First(&item).Error; err != nil { | 	if err := v.Preload().Tx.Where("id = ?", id).First(&item).Error; err != nil { | ||||||
|   | |||||||
							
								
								
									
										33
									
								
								pkg/views/src/components/posts/ArticleContent.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								pkg/views/src/components/posts/ArticleContent.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <section v-if="!props.contentOnly" class="mb-2"> | ||||||
|  |       <h1 class="text-lg font-bold">{{ props.item?.title }}</h1> | ||||||
|  |       <div class="text-sm">{{ props.item?.description }}</div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  |     <div v-if="props.brief"> | ||||||
|  |       <router-link | ||||||
|  |         :to="{ name: 'posts.details', params: { postType: 'articles', alias: props.item?.alias ?? 'not-found' } }" | ||||||
|  |         append-icon="mdi-arrow-right" | ||||||
|  |         class="link underline text-primary font-medium" | ||||||
|  |       > | ||||||
|  |         Read more... | ||||||
|  |       </router-link> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div v-else> | ||||||
|  |       <article class="prose max-w-none" v-html="parseContent(props.item?.content ?? '')" /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import dompurify from "dompurify"; | ||||||
|  | import { parse } from "marked"; | ||||||
|  |  | ||||||
|  | const props = defineProps<{ item: any, brief?: boolean, contentOnly?: boolean }>(); | ||||||
|  |  | ||||||
|  | function parseContent(src: string): string { | ||||||
|  |   return dompurify().sanitize(parse(src) as string); | ||||||
|  | } | ||||||
|  | </script> | ||||||
							
								
								
									
										20
									
								
								pkg/views/src/components/posts/MomentContent.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								pkg/views/src/components/posts/MomentContent.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | <template> | ||||||
|  |   <article class="prose prose-moment" 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-moment, p { | ||||||
|  |   margin: 0 !important; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| <template> | <template> | ||||||
|   <v-card> |   <v-card :loading="props.loading"> | ||||||
|     <template #text> |     <template #text> | ||||||
|       <div class="flex gap-3"> |       <div class="flex gap-3"> | ||||||
|         <div> |         <div> | ||||||
| @@ -11,9 +11,12 @@ | |||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <div> |         <div class="flex-grow-1"> | ||||||
|           <div class="font-bold">{{ props.item?.author.nick }}</div> |           <div class="font-bold">{{ props.item?.author.nick }}</div> | ||||||
|           <div class="prose prose-post" v-html="parseContent(props.item.content)"></div> |  | ||||||
|  |           <div v-if="props.item?.modal_type === 'article'" class="text-xs text-grey-darken-4 mb-2">Published an article</div> | ||||||
|  |  | ||||||
|  |           <component :is="renderer[props.item?.model_type]" v-bind="props" /> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </template> |     </template> | ||||||
| @@ -21,14 +24,16 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import dompurify from "dompurify"; | import type { Component } from "vue"; | ||||||
| import { parse } from "marked"; | import ArticleContent from "@/components/posts/ArticleContent.vue"; | ||||||
|  | import MomentContent from "@/components/posts/MomentContent.vue"; | ||||||
|  |  | ||||||
| const props = defineProps<{ item: any }>(); | const props = defineProps<{ item: any, brief?: boolean, loading?: boolean }>(); | ||||||
|  |  | ||||||
| function parseContent(src: string): string { | const renderer: { [id: string]: Component } = { | ||||||
|   return dompurify().sanitize(parse(src) as string); |   article: ArticleContent, | ||||||
| } |   moment: MomentContent | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| @@ -36,9 +41,3 @@ function parseContent(src: string): string { | |||||||
|   border-radius: 8px; |   border-radius: 8px; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
| <style> |  | ||||||
| .prose.prose-post, p { |  | ||||||
|   margin: 0 !important; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -7,7 +7,7 @@ | |||||||
|     <v-infinite-scroll :items="props.posts" :onLoad="props.loader"> |     <v-infinite-scroll :items="props.posts" :onLoad="props.loader"> | ||||||
|       <template v-for="item in props.posts" :key="item"> |       <template v-for="item in props.posts" :key="item"> | ||||||
|         <div class="mb-3 px-1"> |         <div class="mb-3 px-1"> | ||||||
|           <post-item :item="item" /> |           <post-item :item="item" brief /> | ||||||
|         </div> |         </div> | ||||||
|       </template> |       </template> | ||||||
|     </v-infinite-scroll> |     </v-infinite-scroll> | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <template> | <template> | ||||||
|   <v-dialog v-model="editor.show" class="max-w-[540px]"> |   <v-dialog v-model="editor.show.moment" class="max-w-[540px]"> | ||||||
|     <v-card title="New post"> |     <v-card title="Record a moment"> | ||||||
|       <v-form> |       <v-form> | ||||||
|         <v-card-text> |         <v-card-text> | ||||||
|           <v-textarea |           <v-textarea | ||||||
| @@ -32,7 +32,7 @@ | |||||||
|         <v-card-actions> |         <v-card-actions> | ||||||
|           <v-spacer></v-spacer> |           <v-spacer></v-spacer> | ||||||
|  |  | ||||||
|           <v-btn type="reset" color="grey" @click="editor.show = false">Cancel</v-btn> |           <v-btn type="reset" color="grey-darken-3" @click="editor.show.moment = false">Cancel</v-btn> | ||||||
|           <v-btn type="submit" @click.prevent>Publish</v-btn> |           <v-btn type="submit" @click.prevent>Publish</v-btn> | ||||||
|         </v-card-actions> |         </v-card-actions> | ||||||
|       </v-form> |       </v-form> | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ | |||||||
|  |  | ||||||
|       <v-tooltip v-for="item in navigationMenu" :text="item.name" location="bottom"> |       <v-tooltip v-for="item in navigationMenu" :text="item.name" location="bottom"> | ||||||
|         <template #activator="{ props }"> |         <template #activator="{ props }"> | ||||||
|           <v-btn flat v-bind="props" :to="{ name: item.to }" size="small" :icon="item.icon" /> |           <v-btn flat exact v-bind="props" :to="{ name: item.to }" size="small" :icon="item.icon" /> | ||||||
|         </template> |         </template> | ||||||
|       </v-tooltip> |       </v-tooltip> | ||||||
|     </div> |     </div> | ||||||
| @@ -26,14 +26,30 @@ | |||||||
|     <router-view /> |     <router-view /> | ||||||
|   </v-main> |   </v-main> | ||||||
|  |  | ||||||
|  |   <v-menu | ||||||
|  |     open-on-hover | ||||||
|  |     open-on-click | ||||||
|  |     :open-delay="0" | ||||||
|  |     :close-delay="1850" | ||||||
|  |     location="top" | ||||||
|  |     transition="scroll-y-reverse-transition" | ||||||
|  |   > | ||||||
|  |     <template v-slot:activator="{ props }"> | ||||||
|       <v-fab |       <v-fab | ||||||
|  |         v-bind="props" | ||||||
|         class="editor-fab" |         class="editor-fab" | ||||||
|         icon="mdi-pencil" |         icon="mdi-pencil" | ||||||
|         color="primary" |         color="primary" | ||||||
|         size="64" |         size="64" | ||||||
|         appear |         appear | ||||||
|     @click="editor.show = true" |  | ||||||
|       /> |       /> | ||||||
|  |     </template> | ||||||
|  |  | ||||||
|  |     <div class="flex flex-col items-center gap-4 mb-4"> | ||||||
|  |       <v-btn variant="elevated" color="secondary" icon="mdi-newspaper-variant" @click="editor.show.article = true" /> | ||||||
|  |       <v-btn variant="elevated" color="accent" icon="mdi-camera-iris" @click="editor.show.moment = true" /> | ||||||
|  |     </div> | ||||||
|  |   </v-menu> | ||||||
|  |  | ||||||
|   <post-editor /> |   <post-editor /> | ||||||
| </template> | </template> | ||||||
| @@ -43,7 +59,7 @@ import { ref } from "vue"; | |||||||
| import { useEditor } from "@/stores/editor"; | import { useEditor } from "@/stores/editor"; | ||||||
| import PostEditor from "@/components/publish/PostEditor.vue"; | import PostEditor from "@/components/publish/PostEditor.vue"; | ||||||
|  |  | ||||||
| const editor = useEditor() | const editor = useEditor(); | ||||||
| const navigationMenu = [ | const navigationMenu = [ | ||||||
|   { name: "Explore", icon: "mdi-compass", to: "explore" } |   { name: "Explore", icon: "mdi-compass", to: "explore" } | ||||||
| ]; | ]; | ||||||
|   | |||||||
| @@ -12,6 +12,12 @@ const router = createRouter({ | |||||||
|           path: "/", |           path: "/", | ||||||
|           name: "explore", |           name: "explore", | ||||||
|           component: () => import("@/views/explore.vue") |           component: () => import("@/views/explore.vue") | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         { | ||||||
|  |           path: "/p/:postType/:alias", | ||||||
|  |           name: "posts.details", | ||||||
|  |           component: () => import("@/views/posts/details.vue") | ||||||
|         } |         } | ||||||
|       ] |       ] | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,8 +1,11 @@ | |||||||
| import { defineStore } from "pinia"; | import { defineStore } from "pinia"; | ||||||
| import { ref } from "vue"; | import { reactive, ref } from "vue"; | ||||||
|  |  | ||||||
| export const useEditor = defineStore("editor", () => { | export const useEditor = defineStore("editor", () => { | ||||||
|   const show = ref(false); |   const show = reactive({ | ||||||
|  |     moment: false, | ||||||
|  |     article: false, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   return { show }; |   return { show }; | ||||||
| }); | }); | ||||||
| @@ -4,7 +4,7 @@ | |||||||
|       <post-list :loading="loading" :posts="posts" :loader="readMore" /> |       <post-list :loading="loading" :posts="posts" :loader="readMore" /> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <div class="aside sticky top-0 w-full h-fit md:min-w-[280px] md:max-w-[320px]"> |     <div class="aside sticky top-0 w-full h-fit md:min-w-[280px] md:max-w-[320px] max-md:order-first"> | ||||||
|       <v-card title="Categories"> |       <v-card title="Categories"> | ||||||
|         <v-list density="compact"> |         <v-list density="compact"> | ||||||
|         </v-list> |         </v-list> | ||||||
| @@ -18,28 +18,27 @@ import PostList from "@/components/posts/PostList.vue"; | |||||||
| import { reactive, ref } from "vue"; | import { reactive, ref } from "vue"; | ||||||
| import { request } from "@/scripts/request"; | import { request } from "@/scripts/request"; | ||||||
|  |  | ||||||
| const error = ref<string | null>(null); |  | ||||||
| const loading = ref(false); | const loading = ref(false); | ||||||
|  | const error = ref<string | null>(null); | ||||||
| const pagination = reactive({ page: 1, pageSize: 10, total: 0 }); | const pagination = reactive({ page: 1, pageSize: 10, total: 0 }); | ||||||
|  |  | ||||||
| const posts = ref<any[]>([]); | const posts = ref<any[]>([]); | ||||||
|  |  | ||||||
| async function readPosts() { | async function readPosts() { | ||||||
|   loading.value = true; |   loading.value = true; | ||||||
|   const res = await request(`/api/posts?` + new URLSearchParams({ |   const res = await request(`/api/feed?` + new URLSearchParams({ | ||||||
|     take: pagination.pageSize.toString(), |     take: pagination.pageSize.toString(), | ||||||
|     offset: ((pagination.page - 1) * pagination.pageSize).toString() |     offset: ((pagination.page - 1) * pagination.pageSize).toString() | ||||||
|   })); |   })); | ||||||
|   if (res.status !== 200) { |   if (res.status !== 200) { | ||||||
|     loading.value = false; |  | ||||||
|     error.value = await res.text(); |     error.value = await res.text(); | ||||||
|   } else { |   } else { | ||||||
|     error.value = null; |     error.value = null; | ||||||
|     loading.value = false; |  | ||||||
|     const data = await res.json(); |     const data = await res.json(); | ||||||
|     pagination.total = data["count"]; |     pagination.total = data["count"]; | ||||||
|     posts.value.push(...data["data"]); |     posts.value.push(...data["data"]); | ||||||
|   } |   } | ||||||
|  |   loading.value = false; | ||||||
| } | } | ||||||
|  |  | ||||||
| async function readMore({ done }: any) { | async function readMore({ done }: any) { | ||||||
|   | |||||||
							
								
								
									
										58
									
								
								pkg/views/src/views/posts/details.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								pkg/views/src/views/posts/details.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | |||||||
|  | <template> | ||||||
|  |   <v-container class="flex max-md:flex-col gap-3 overflow-auto max-h-[calc(100vh-64px)] no-scrollbar"> | ||||||
|  |     <div class="timeline flex-grow-1 max-w-[75ch]"> | ||||||
|  |       <v-card :loading="loading"> | ||||||
|  |         <article> | ||||||
|  |           <v-card-title>{{ post?.title }}</v-card-title> | ||||||
|  |  | ||||||
|  |           <v-card-text> | ||||||
|  |             <div class="text-sm">{{ post?.description }}</div> | ||||||
|  |  | ||||||
|  |             <v-divider class="mt-5 mx-[-16px] border-opacity-50" /> | ||||||
|  |  | ||||||
|  |             <article-content :item="post" content-only /> | ||||||
|  |           </v-card-text> | ||||||
|  |         </article> | ||||||
|  |       </v-card> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="aside sticky top-0 w-full h-fit md:min-w-[280px]"> | ||||||
|  |       <v-card title="Comments"> | ||||||
|  |         <v-list density="compact"> | ||||||
|  |         </v-list> | ||||||
|  |       </v-card> | ||||||
|  |  | ||||||
|  |       <v-card title="Reactions" class="mt-3"> | ||||||
|  |         <v-list density="compact"> | ||||||
|  |         </v-list> | ||||||
|  |       </v-card> | ||||||
|  |     </div> | ||||||
|  |   </v-container> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { ref } from "vue"; | ||||||
|  | import { request } from "@/scripts/request"; | ||||||
|  | import { useRoute } from "vue-router"; | ||||||
|  | import ArticleContent from "@/components/posts/ArticleContent.vue"; | ||||||
|  |  | ||||||
|  | const loading = ref(false); | ||||||
|  | const error = ref<string | null>(null); | ||||||
|  | const post = ref<any>(null); | ||||||
|  |  | ||||||
|  | const route = useRoute(); | ||||||
|  |  | ||||||
|  | async function readPost() { | ||||||
|  |   loading.value = true; | ||||||
|  |   const res = await request(`/api/${route.params.postType}/${route.params.alias}?`); | ||||||
|  |   if (res.status !== 200) { | ||||||
|  |     error.value = await res.text(); | ||||||
|  |   } else { | ||||||
|  |     error.value = null; | ||||||
|  |     post.value = await res.json(); | ||||||
|  |   } | ||||||
|  |   loading.value = false; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | readPost(); | ||||||
|  | </script> | ||||||
		Reference in New Issue
	
	Block a user