Frontend move to union feed

This commit is contained in:
LittleSheep 2024-03-03 21:24:08 +08:00
parent e1822e5363
commit 1725724758
15 changed files with 199 additions and 47 deletions

View File

@ -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())
} }

View File

@ -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())
} }

View File

@ -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,
})
} }

View File

@ -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())
} }

View File

@ -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 {

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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-fab <v-menu
class="editor-fab" open-on-hover
icon="mdi-pencil" open-on-click
color="primary" :open-delay="0"
size="64" :close-delay="1850"
appear location="top"
@click="editor.show = true" 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
/>
</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" }
]; ];

View File

@ -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")
} }
] ]
} }

View File

@ -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 };
}); });

View File

@ -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) {

View 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>