Compare commits

..

2 Commits

Author SHA1 Message Date
7fc86441d1 Page data 2025-08-04 02:07:18 +08:00
1a05f16299 Post detail page 2025-08-04 01:46:26 +08:00
14 changed files with 215 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
export {}
declare global {
interface Window {
DyPrefetch?: any
}
}

View File

@@ -183,7 +183,7 @@ async function fetchUser() {
console.log('[Fetch] Using the API to load user data.')
try {
const resp = await fetch(`/api/accounts/${route.params.name}`)
const resp = await fetch(`/api/accounts/${route.params.slug}`)
user.value = await resp.json()
} catch (err) {
console.error(err)

View File

@@ -43,13 +43,11 @@ public class SubscriptionController(SubscriptionService subscriptions, AfdianPay
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var subs = await db.WalletSubscriptions
var subscription = await db.WalletSubscriptions
.Where(s => s.AccountId == currentUser.Id && s.IsActive)
.Where(s => EF.Functions.ILike(s.Identifier, prefix + "%"))
.OrderByDescending(s => s.BegunAt)
.ToListAsync();
if (subs.Count == 0) return NotFound();
var subscription = subs.FirstOrDefault(s => s.IsAvailable);
.FirstOrDefaultAsync();
if (subscription is null) return NotFound();
return Ok(subscription);

View File

@@ -400,9 +400,9 @@ public class SubscriptionService(
.OrderByDescending(s => s.BegunAt)
.FirstOrDefaultAsync();
// Cache the result if found (with 30 minutes expiry)
// Cache the result if found (with 5 minutes expiry)
if (subscription != null)
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30));
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(5));
return subscription;
}

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solar Network</title>
<app-data />
<og-data />
</head>
<body>
<div id="app"></div>

View File

@@ -0,0 +1,7 @@
export {}
declare global {
interface Window {
DyPrefetch?: any
}
}

View File

@@ -10,6 +10,11 @@ const router = createRouter({
name: 'index',
component: () => import('../views/index.vue'),
},
{
path: '/posts/:slug',
name: 'postDetail',
component: () => import('../views/posts.vue'),
},
],
})

View File

@@ -4,7 +4,11 @@
<n-gi span="3">
<n-infinite-scroll style="height: calc(100vh - 57px)" :distance="10" @load="fetchActivites">
<div v-for="activity in activites" :key="activity.id" class="mt-4">
<post-item v-if="activity.type == 'posts.new'" :item="activity.data" />
<post-item
v-if="activity.type.startsWith('posts')"
:item="activity.data"
@click="router.push('/posts/' + activity.id)"
/>
</div>
</n-infinite-scroll>
</n-gi>
@@ -40,11 +44,14 @@
<script setup lang="ts">
import { NCard, NInfiniteScroll, NGrid, NGi, NAlert, NA, NHr } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import PostEditor from '@/components/PostEditor.vue'
import PostItem from '@/components/PostItem.vue'
const router = useRouter()
const userStore = useUserStore()
const version = ref<any>(null)

View File

@@ -0,0 +1,100 @@
<template>
<div v-if="post" class="container max-w-5xl mx-auto mt-4">
<n-grid cols="1 l:5" responsive="screen" :x-gap="16">
<n-gi span="3">
<post-item :item="post" />
</n-gi>
<n-gi span="2">
<n-card title="About the author">
<div class="relative mb-7">
<img
class="object-cover rounded-lg"
style="aspect-ratio: 16/7"
:src="publisherBackground"
/>
<div class="absolute left-3 bottom-[-24px]">
<n-avatar :src="publisherAvatar" :size="64" round bordered />
</div>
</div>
<div class="flex flex-col">
<p class="flex gap-1 items-baseline">
<span class="font-bold">
{{ post.publisher.nick }}
</span>
<span class="text-sm"> @{{ post.publisher.name }} </span>
</p>
<div class="max-h-96 overflow-y-auto">
<div
class="prose prose-sm dark:prose-invert prose-slate"
v-if="publisherBio"
v-html="publisherBio"
></div>
</div>
</div>
</n-card>
</n-gi>
</n-grid>
</div>
<div v-else-if="notFound" class="flex justify-center items-center h-full">
<n-result
status="404"
title="Post not found"
description="The post you are looking cannot be found, it might be deleted, or you have no permission to view it or it just never been posted."
/>
</div>
<div v-else class="flex justify-center items-center h-full">
<n-spin />
</div>
</template>
<script setup lang="ts">
import { NGrid, NGi, NCard, NAvatar } from 'naive-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Marked } from 'marked'
import PostItem from '@/components/PostItem.vue'
const route = useRoute()
const post = ref<any>()
const notFound = ref(false)
async function fetchPost() {
if (window.DyPrefetch?.Post != null) {
console.log('[Fetch] Use the pre-rendered post data.')
post.value = window.DyPrefetch.Post
return
}
console.log('[Fetch] Using the API to load user data.')
try {
const resp = await fetch(`/api/posts/${route.params.slug}`)
post.value = await resp.json()
} catch (err) {
console.error(err)
notFound.value = true
}
}
onMounted(() => fetchPost())
const publisherAvatar = computed(() =>
post.value.publisher.picture ? `/cgi/drive/files/${post.value.publisher.picture.id}` : undefined,
)
const publisherBackground = computed(() =>
post.value.publisher.background
? `/cgi/drive/files/${post.value.publisher.background.id}`
: undefined,
)
const marked = new Marked()
const publisherBio = ref('')
watch(
post,
async (value) => {
if (value?.publisher?.bio) publisherBio.value = await marked.parse(value.publisher.bio)
},
{ immediate: true, deep: true },
)
</script>

View File

@@ -41,6 +41,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/>
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>

View File

@@ -0,0 +1,75 @@
using System.Net;
using DysonNetwork.Shared.PageData;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using Microsoft.EntityFrameworkCore;
using OpenGraphNet;
namespace DysonNetwork.Sphere.PageData;
public class PostPageData(
AppDatabase db,
AccountService.AccountServiceClient accounts,
PublisherService pub,
PostService ps,
IConfiguration configuration
)
: IPageDataProvider
{
private readonly string _siteUrl = configuration["SiteUrl"]!;
public bool CanHandlePath(PathString path) =>
path.StartsWithSegments("/posts");
public async Task<IDictionary<string, object?>> GetAppDataAsync(HttpContext context)
{
var path = context.Request.Path.Value!;
var startIndex = "/posts/".Length;
var endIndex = path.IndexOf('/', startIndex);
var slug = endIndex == -1 ? path[startIndex..] : path.Substring(startIndex, endIndex - startIndex);
slug = WebUtility.UrlDecode(slug);
var postId = Guid.TryParse(slug, out var postIdGuid) ? postIdGuid : Guid.Empty;
if (postId == Guid.Empty) return new Dictionary<string, object?>();
context.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
List<Guid> userFriends = [];
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
var post = await db.Posts
.Where(e => e.Id == postId)
.Include(e => e.Publisher)
.Include(e => e.Tags)
.Include(e => e.Categories)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
post = await ps.LoadPostInfo(post, currentUser);
// Track view - use the account ID as viewer ID if user is logged in
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
var og = OpenGraph.MakeGraph(
title: post.Title ?? $"Post from {post.Publisher.Name}",
type: "article",
image: $"{_siteUrl}/cgi/drive/files/{post.Publisher.Background?.Id}?original=true",
url: $"{_siteUrl}/@{slug}",
description: post.Description ?? post.Content?[..80] ?? "Posted with some media",
siteName: "Solar Network"
);
return new Dictionary<string, object?>()
{
["Post"] = post,
["OpenGraph"] = og
};
}
}

View File

@@ -1,7 +1,9 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.PageData;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.PageData;
using DysonNetwork.Sphere.Startup;
using Microsoft.EntityFrameworkCore;
@@ -33,6 +35,8 @@ builder.Services.AddAppBusinessServices(builder.Configuration);
// Add scheduled jobs
builder.Services.AddAppScheduledJobs();
builder.Services.AddTransient<IPageDataProvider, PostPageData>();
var app = builder.Build();
// Run database migrations

View File

@@ -1,6 +1,7 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5071",
"SiteUrl": "https://solian.app",
"Logging": {
"LogLevel": {
"Default": "Information",

View File

@@ -54,6 +54,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFieldMask_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F331aca3f6f414013b09964063341351379060_003F68_003Fc6da3cbf_003FFieldMask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F8c_003F9f6e3f4f_003FFileResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AForwardedHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcfe5737f9bb84738979cbfedd11822a8ea00_003F50_003F9a335f87_003FForwardedHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc181aff8c6ec418494a7efcfec578fc154e00_003Fd0_003Fcc905531_003FHttpContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpRequestHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb904f9896c4049fabd596decf1be9c381dc400_003F32_003F906beb77_003FHttpRequestHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpStatusCode_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb3f2e07d4b3f4b42a41fbcf3137e534f3be00_003Fe2_003F215f9441_003FHttpStatusCode_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpTransformer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003F8a_003Fd9fba048_003FHttpTransformer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>