♻️ Interactive v2 #1
| @@ -22,11 +22,11 @@ func contextArticle() *services.PostTypeContext[models.Article] { | ||||
| } | ||||
|  | ||||
| func getArticle(c *fiber.Ctx) error { | ||||
| 	id, _ := c.ParamsInt("articleId", 0) | ||||
| 	alias := c.Params("articleId") | ||||
|  | ||||
| 	mx := contextArticle().FilterPublishedAt(time.Now()) | ||||
|  | ||||
| 	item, err := mx.Get(uint(id)) | ||||
| 	item, err := mx.GetViaAlias(alias) | ||||
| 	if err != nil { | ||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||
| 	} | ||||
|   | ||||
| @@ -23,11 +23,11 @@ func contextComment() *services.PostTypeContext[models.Comment] { | ||||
| } | ||||
|  | ||||
| func getComment(c *fiber.Ctx) error { | ||||
| 	id, _ := c.ParamsInt("commentId", 0) | ||||
| 	alias := c.Params("commentId") | ||||
|  | ||||
| 	mx := contextComment().FilterPublishedAt(time.Now()) | ||||
|  | ||||
| 	item, err := mx.Get(uint(id)) | ||||
| 	item, err := mx.GetViaAlias(alias) | ||||
| 	if err != nil { | ||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||
| 	} | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import ( | ||||
| type FeedItem struct { | ||||
| 	models.BaseModel | ||||
|  | ||||
| 	Alias         string `json:"alias"` | ||||
| 	Title         string `json:"title"` | ||||
| 	Description   string `json:"description"` | ||||
| 	Content       string `json:"content"` | ||||
| @@ -25,8 +26,8 @@ type FeedItem struct { | ||||
| } | ||||
|  | ||||
| const ( | ||||
| 	queryArticle = "id, created_at, updated_at, title, 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" | ||||
| 	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, alias, NULL as title, content, NULL as description, realm_id, author_id, 'moment' as model_type" | ||||
| ) | ||||
|  | ||||
| func listFeed(c *fiber.Ctx) error { | ||||
| @@ -83,5 +84,14 @@ func listFeed(c *fiber.Ctx) error { | ||||
| 		offset, | ||||
| 	).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 { | ||||
| 	id, _ := c.ParamsInt("momentId", 0) | ||||
| 	alias := c.Params("momentId") | ||||
|  | ||||
| 	mx := contextMoment().FilterPublishedAt(time.Now()) | ||||
|  | ||||
| 	item, err := mx.Get(uint(id)) | ||||
| 	item, err := mx.GetViaAlias(alias) | ||||
| 	if err != nil { | ||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||
| 	} | ||||
|   | ||||
| @@ -37,8 +37,7 @@ func (v *PostTypeContext[T]) Preload() *PostTypeContext[T] { | ||||
| 	v.Tx.Preload("Author"). | ||||
| 		Preload("Attachments"). | ||||
| 		Preload("Categories"). | ||||
| 		Preload("Hashtags"). | ||||
| 		Preload("Reactions") | ||||
| 		Preload("Hashtags") | ||||
|  | ||||
| 	if v.CanReply { | ||||
| 		v.Tx.Preload("ReplyTo") | ||||
| @@ -99,6 +98,15 @@ func (v *PostTypeContext[T]) SortCreatedAt(order string) *PostTypeContext[T] { | ||||
| 	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) { | ||||
| 	var item T | ||||
| 	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> | ||||
|   <v-card> | ||||
|   <v-card :loading="props.loading"> | ||||
|     <template #text> | ||||
|       <div class="flex gap-3"> | ||||
|         <div> | ||||
| @@ -11,9 +11,12 @@ | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div> | ||||
|         <div class="flex-grow-1"> | ||||
|           <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> | ||||
|     </template> | ||||
| @@ -21,14 +24,16 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import dompurify from "dompurify"; | ||||
| import { parse } from "marked"; | ||||
| import type { Component } from "vue"; | ||||
| 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 { | ||||
|   return dompurify().sanitize(parse(src) as string); | ||||
| } | ||||
| const renderer: { [id: string]: Component } = { | ||||
|   article: ArticleContent, | ||||
|   moment: MomentContent | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| @@ -36,9 +41,3 @@ function parseContent(src: string): string { | ||||
|   border-radius: 8px; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| <style> | ||||
| .prose.prose-post, p { | ||||
|   margin: 0 !important; | ||||
| } | ||||
| </style> | ||||
| @@ -7,7 +7,7 @@ | ||||
|     <v-infinite-scroll :items="props.posts" :onLoad="props.loader"> | ||||
|       <template v-for="item in props.posts" :key="item"> | ||||
|         <div class="mb-3 px-1"> | ||||
|           <post-item :item="item" /> | ||||
|           <post-item :item="item" brief /> | ||||
|         </div> | ||||
|       </template> | ||||
|     </v-infinite-scroll> | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <template> | ||||
|   <v-dialog v-model="editor.show" class="max-w-[540px]"> | ||||
|     <v-card title="New post"> | ||||
|   <v-dialog v-model="editor.show.moment" class="max-w-[540px]"> | ||||
|     <v-card title="Record a moment"> | ||||
|       <v-form> | ||||
|         <v-card-text> | ||||
|           <v-textarea | ||||
| @@ -32,7 +32,7 @@ | ||||
|         <v-card-actions> | ||||
|           <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-card-actions> | ||||
|       </v-form> | ||||
|   | ||||
| @@ -16,7 +16,7 @@ | ||||
|  | ||||
|       <v-tooltip v-for="item in navigationMenu" :text="item.name" location="bottom"> | ||||
|         <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> | ||||
|       </v-tooltip> | ||||
|     </div> | ||||
| @@ -26,14 +26,30 @@ | ||||
|     <router-view /> | ||||
|   </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-bind="props" | ||||
|         class="editor-fab" | ||||
|         icon="mdi-pencil" | ||||
|         color="primary" | ||||
|         size="64" | ||||
|         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 /> | ||||
| </template> | ||||
| @@ -43,7 +59,7 @@ import { ref } from "vue"; | ||||
| import { useEditor } from "@/stores/editor"; | ||||
| import PostEditor from "@/components/publish/PostEditor.vue"; | ||||
|  | ||||
| const editor = useEditor() | ||||
| const editor = useEditor(); | ||||
| const navigationMenu = [ | ||||
|   { name: "Explore", icon: "mdi-compass", to: "explore" } | ||||
| ]; | ||||
|   | ||||
| @@ -12,6 +12,12 @@ const router = createRouter({ | ||||
|           path: "/", | ||||
|           name: "explore", | ||||
|           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 { ref } from "vue"; | ||||
| import { reactive, ref } from "vue"; | ||||
|  | ||||
| export const useEditor = defineStore("editor", () => { | ||||
|   const show = ref(false); | ||||
|   const show = reactive({ | ||||
|     moment: false, | ||||
|     article: false, | ||||
|   }); | ||||
|  | ||||
|   return { show }; | ||||
| }); | ||||
| @@ -4,7 +4,7 @@ | ||||
|       <post-list :loading="loading" :posts="posts" :loader="readMore" /> | ||||
|     </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-list density="compact"> | ||||
|         </v-list> | ||||
| @@ -18,28 +18,27 @@ import PostList from "@/components/posts/PostList.vue"; | ||||
| import { reactive, ref } from "vue"; | ||||
| import { request } from "@/scripts/request"; | ||||
|  | ||||
| const error = ref<string | null>(null); | ||||
| const loading = ref(false); | ||||
| const error = ref<string | null>(null); | ||||
| const pagination = reactive({ page: 1, pageSize: 10, total: 0 }); | ||||
|  | ||||
| const posts = ref<any[]>([]); | ||||
|  | ||||
| async function readPosts() { | ||||
|   loading.value = true; | ||||
|   const res = await request(`/api/posts?` + new URLSearchParams({ | ||||
|   const res = await request(`/api/feed?` + new URLSearchParams({ | ||||
|     take: pagination.pageSize.toString(), | ||||
|     offset: ((pagination.page - 1) * pagination.pageSize).toString() | ||||
|   })); | ||||
|   if (res.status !== 200) { | ||||
|     loading.value = false; | ||||
|     error.value = await res.text(); | ||||
|   } else { | ||||
|     error.value = null; | ||||
|     loading.value = false; | ||||
|     const data = await res.json(); | ||||
|     pagination.total = data["count"]; | ||||
|     posts.value.push(...data["data"]); | ||||
|   } | ||||
|   loading.value = false; | ||||
| } | ||||
|  | ||||
| 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