Compare commits
9 Commits
92a4899e7c
...
c62ed191f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
c62ed191f3
|
|||
|
8b77f0e0ad
|
|||
|
2b56c6f1e5
|
|||
|
ef02265ccd
|
|||
|
f4505d2ecc
|
|||
|
9d2242d331
|
|||
|
c806365a81
|
|||
|
bd1715c9a3
|
|||
|
0b0598712e
|
@@ -211,7 +211,11 @@ message PostAward {
|
||||
// ====================================
|
||||
|
||||
message GetPostRequest {
|
||||
string id = 1;
|
||||
oneof identifier {
|
||||
string id = 1;
|
||||
string slug = 2;
|
||||
}
|
||||
google.protobuf.StringValue publisher_id = 3;
|
||||
}
|
||||
|
||||
message GetPostBatchRequest {
|
||||
|
||||
@@ -10,10 +10,26 @@ public class PostServiceGrpc(AppDatabase db, PostService ps) : Shared.Proto.Post
|
||||
{
|
||||
public override async Task<Shared.Proto.Post> GetPost(GetPostRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.Id, out var id))
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid post id"));
|
||||
var postQuery = db.Posts.AsQueryable();
|
||||
|
||||
var post = await db.Posts
|
||||
switch (request.IdentifierCase)
|
||||
{
|
||||
case GetPostRequest.IdentifierOneofCase.Id:
|
||||
if (!Guid.TryParse(request.Id, out var id))
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid post id"));
|
||||
postQuery = postQuery.Where(p => p.Id == id);
|
||||
break;
|
||||
case GetPostRequest.IdentifierOneofCase.Slug:
|
||||
postQuery = postQuery.Where(p => p.Slug == request.Slug);
|
||||
break;
|
||||
default:
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid identifier case"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.PublisherId) && Guid.TryParse(request.PublisherId, out var pid))
|
||||
postQuery = postQuery.Where(p => p.PublisherId == pid);
|
||||
|
||||
var post = await postQuery
|
||||
.Include(p => p.Publisher)
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.Categories)
|
||||
@@ -21,7 +37,7 @@ public class PostServiceGrpc(AppDatabase db, PostService ps) : Shared.Proto.Post
|
||||
.Include(p => p.ForwardedPost)
|
||||
.Include(p => p.FeaturedRecords)
|
||||
.FilterWithVisibility(null, [], [])
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (post == null) throw new RpcException(new Status(StatusCode.NotFound, "post not found"));
|
||||
|
||||
|
||||
@@ -4,8 +4,69 @@
|
||||
|
||||
@{
|
||||
Layout = "_LayoutContained";
|
||||
|
||||
var pageTitle = "About";
|
||||
var pageDescription = "Information page.";
|
||||
var ogType = "website";
|
||||
string? ogImageUrl = null;
|
||||
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
|
||||
var siteName = Model.Site?.Name ?? "Solar Network";
|
||||
|
||||
if (Model.UserAccount != null)
|
||||
{
|
||||
pageTitle = $"About {Model.UserAccount.Nick ?? Model.UserAccount.Name}";
|
||||
pageDescription = !string.IsNullOrWhiteSpace(Model.UserAccount.Profile?.Bio) ? Model.UserAccount.Profile.Bio : $"Profile of {Model.UserAccount.Nick ?? Model.UserAccount.Name} on {siteName}";
|
||||
|
||||
ogType = "profile";
|
||||
ogImageUrl = Model.UserBackgroundUrl;
|
||||
if (!string.IsNullOrEmpty(ogImageUrl) && !ogImageUrl.StartsWith("http"))
|
||||
ogImageUrl = $"{Request.Scheme}://{Request.Host}{ogImageUrl}";
|
||||
}
|
||||
else if (Model.Site != null)
|
||||
{
|
||||
pageTitle = $"About {Model.Site.Name}";
|
||||
if (!string.IsNullOrWhiteSpace(Model.Site.Description))
|
||||
{
|
||||
pageDescription = Model.Site.Description;
|
||||
}
|
||||
|
||||
ogType = "website";
|
||||
ogImageUrl = null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pageDescription) && pageDescription.Length > 160)
|
||||
{
|
||||
pageDescription = pageDescription.Substring(0, 157) + "...";
|
||||
}
|
||||
|
||||
ViewData["Title"] = $"{pageTitle} - {siteName}";
|
||||
}
|
||||
|
||||
@section Head {
|
||||
|
||||
<meta name="description" content="@pageDescription"/>
|
||||
<link rel="canonical" href="@canonicalUrl"/>
|
||||
|
||||
<meta property="og:title" content="@pageTitle"/>
|
||||
<meta property="og:description" content="@pageDescription"/>
|
||||
<meta property="og:type" content="@ogType"/>
|
||||
<meta property="og:url" content="@canonicalUrl"/>
|
||||
@if (!string.IsNullOrEmpty(ogImageUrl))
|
||||
{
|
||||
<meta property="og:image" content="@ogImageUrl"/>
|
||||
}
|
||||
<meta property="og:site_name" content="@siteName"/>
|
||||
|
||||
<meta name="twitter:card" content="summary"/>
|
||||
<meta name="twitter:title" content="@pageTitle"/>
|
||||
<meta name="twitter:description" content="@pageDescription"/>
|
||||
@if (!string.IsNullOrEmpty(ogImageUrl))
|
||||
{
|
||||
<meta name="twitter:image" content="@ogImageUrl"/>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@if (Model.UserAccount != null)
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(Model.UserBackgroundUrl))
|
||||
@@ -243,60 +304,6 @@
|
||||
About the site
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
<div class="card bg-base-100 border">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title"><span class="mdi mdi-home"></span> Info</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="flex gap-2">
|
||||
<span class="mdi mdi-label"></span>
|
||||
Name
|
||||
</span>
|
||||
<span class="text-right">@Model.Site.Name</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="flex gap-2">
|
||||
<span class="mdi mdi-tag"></span>
|
||||
Slug
|
||||
</span>
|
||||
<span class="text-right font-mono">@Model.Site.Slug</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
@if (!string.IsNullOrEmpty(Model.Site.Description))
|
||||
{
|
||||
<div class="flex-1">
|
||||
<div class="card bg-base-100 border">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
<span class="mdi mdi-information"></span> Description
|
||||
</h3>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
@Model.Site.Description
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-col p-5 opacity-80 text-sm">
|
||||
<p>Proudly powered by the Solar Network Pages</p>
|
||||
<p>Hosted on the Solar Network</p>
|
||||
<p class="mt-2">Networking with Cloudflare</p>
|
||||
<p>Therefore, if the site is down, 99% is Cloudflare's fault</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<partial name="Shared/_SiteInfo" model="Model.Site" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,15 +1,134 @@
|
||||
@page
|
||||
@page
|
||||
@model IndexModel
|
||||
|
||||
@{
|
||||
Layout = "_LayoutContained";
|
||||
ViewData["Title"] = "Solar Network Pages";
|
||||
|
||||
var pageTitle = "Home";
|
||||
var pageDescription = "Welcome to the Solar Network Pages.";
|
||||
var ogType = "website";
|
||||
string? ogImageUrl = null;
|
||||
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
|
||||
var siteName = Model.Site?.Name ?? "Solar Network";
|
||||
|
||||
if (Model.UserAccount != null)
|
||||
{
|
||||
pageTitle = $"{Model.UserAccount.Nick ?? Model.UserAccount.Name}'s Page";
|
||||
pageDescription = !string.IsNullOrWhiteSpace(Model.UserAccount.Profile?.Bio) ? Model.UserAccount.Profile.Bio : $"Profile of {Model.UserAccount.Nick ?? Model.UserAccount.Name} on {siteName}";
|
||||
ogType = "profile";
|
||||
ogImageUrl = Model.UserBackgroundUrl;
|
||||
if (!string.IsNullOrEmpty(ogImageUrl) && !ogImageUrl.StartsWith("http"))
|
||||
ogImageUrl = $"{Request.Scheme}://{Request.Host}{ogImageUrl}";
|
||||
}
|
||||
else if (Model.Site != null)
|
||||
{
|
||||
pageTitle = Model.Site.Name;
|
||||
if (!string.IsNullOrWhiteSpace(Model.Site.Description))
|
||||
{
|
||||
pageDescription = Model.Site.Description;
|
||||
}
|
||||
ogType = "website";
|
||||
ogImageUrl = null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pageDescription) && pageDescription.Length > 160)
|
||||
{
|
||||
pageDescription = pageDescription.Substring(0, 157) + "...";
|
||||
}
|
||||
|
||||
ViewData["Title"] = $"{pageTitle} - {siteName}";
|
||||
}
|
||||
|
||||
<div class="h-full flex justify-center items-center">
|
||||
<div class="text-center max-w-96">
|
||||
<img src="~/favicon.png" width="80" height="80" alt="Logo" class="mb-1 mx-auto"/>
|
||||
<h1 class="text-2xl">Hello World 👋</h1>
|
||||
<p>Here are the Solar Network Pages</p>
|
||||
@section Head {
|
||||
<meta name="description" content="@pageDescription"/>
|
||||
<link rel="canonical" href="@canonicalUrl"/>
|
||||
|
||||
<meta property="og:title" content="@pageTitle"/>
|
||||
<meta property="og:description" content="@pageDescription"/>
|
||||
<meta property="og:type" content="@ogType"/>
|
||||
<meta property="og:url" content="@canonicalUrl"/>
|
||||
@if (!string.IsNullOrEmpty(ogImageUrl))
|
||||
{
|
||||
<meta property="og:image" content="@ogImageUrl"/>
|
||||
}
|
||||
<meta property="og:site_name" content="@siteName"/>
|
||||
|
||||
<meta name="twitter:card" content="summary"/>
|
||||
<meta name="twitter:title" content="@pageTitle"/>
|
||||
<meta name="twitter:description" content="@pageDescription"/>
|
||||
@if (!string.IsNullOrEmpty(ogImageUrl))
|
||||
{
|
||||
<meta name="twitter:image" content="@ogImageUrl"/>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="container mx-auto px-8 py-8">
|
||||
@if (Model.FeaturedPosts.Any())
|
||||
{
|
||||
<h2 class="text-xl flex gap-2 mb-5 px-3">
|
||||
<span class="mdi mdi-star"></span> Featured Posts
|
||||
</h2>
|
||||
<div class="carousel carousel-center w-full max-h-120 p-4 space-x-4 rounded-box mb-8 shadow-lg bg-base-300">
|
||||
@for (int i = 0; i < Model.FeaturedPosts.Count; i++)
|
||||
{
|
||||
var post = Model.FeaturedPosts[i];
|
||||
<div id="item@(i + 1)" class="carousel-item card card-side lg:card-side w-full bg-base-100 shadow-xl">
|
||||
<figure class="max-w-3/5">
|
||||
@if (post.Attachments.Any(a => a.MimeType!.StartsWith("image")))
|
||||
{
|
||||
var imageAttachment = post.Attachments.First(a => a.MimeType!.StartsWith("image"));
|
||||
<img src="/drive/files/@imageAttachment.Id" alt="@imageAttachment.Name" class="h-full object-cover"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img src="https://placehold.co/600x400?text=No+Image" alt="No Image" class="h-full object-cover"/>
|
||||
}
|
||||
</figure>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<a asp-page="/Posts/Details" asp-route-slug="@(post.Slug ?? post.Id.ToString())">@post.Title</a>
|
||||
</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(post.Description))
|
||||
{
|
||||
<p class="text-sm">@post.Description</p>
|
||||
}
|
||||
<div class="card-actions justify-end">
|
||||
<a asp-page="/Posts/Details" asp-route-slug="@(post.Slug ?? post.Id.ToString())" class="btn btn-primary btn-sm">Read More</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.FeaturedPosts.Count > 1)
|
||||
{
|
||||
<div class="flex justify-center w-full py-2 gap-2 mb-8">
|
||||
@for (var idx = 0; idx < Model.FeaturedPosts.Count; idx++)
|
||||
{
|
||||
<a href="#item@(idx + 1)" class="btn btn-xs">@(idx + 1)</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
@if (Model.UserAccount != null)
|
||||
{
|
||||
<div>
|
||||
<h2 class="text-xl flex gap-2 mb-5 px-3">
|
||||
<span class="mdi mdi-account-circle"></span> About me
|
||||
</h2>
|
||||
<partial name="Shared/_UserInfo" model="Model"/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Site != null)
|
||||
{
|
||||
<div>
|
||||
<h2 class="text-xl flex gap-2 mb-5 px-3">
|
||||
<span class="mdi mdi-sitemap"></span> About the Site
|
||||
</h2>
|
||||
<partial name="Shared/_SiteInfo" model="Model.Site"/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,10 +1,109 @@
|
||||
using Markdig;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Zone.Publication;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Zone.Pages;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
public class IndexModel(PostService.PostServiceClient postClient, RemotePublisherService rps, RemoteAccountService ras) : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
public SnPublicationSite? Site { get; set; }
|
||||
public SnPublisher? Publisher { get; set; }
|
||||
public Account? UserAccount { get; set; }
|
||||
public List<SnPost> FeaturedPosts { get; set; } = [];
|
||||
|
||||
public string? UserPictureUrl => UserAccount?.Profile?.Picture?.Id != null
|
||||
? $"/drive/files/{UserAccount.Profile.Picture.Id}"
|
||||
: null;
|
||||
|
||||
public string? UserBackgroundUrl => UserAccount?.Profile?.Background?.Id != null
|
||||
? $"/drive/files/{UserAccount.Profile.Background.Id}?original=true"
|
||||
: null;
|
||||
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
Site = HttpContext.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
|
||||
|
||||
if (Site != null)
|
||||
{
|
||||
// Fetch Publisher Information
|
||||
Publisher = await rps.GetPublisher(id: Site!.PublisherId.ToString());
|
||||
|
||||
// Fetch User Account Information if available
|
||||
UserAccount = await ras.GetAccount(Site.AccountId);
|
||||
|
||||
// Fetch Featured Posts (e.g., top 5 by views)
|
||||
var request = new ListPostsRequest
|
||||
{
|
||||
OrderBy = "views", // Assuming 'views' is a valid order-by option for popularity
|
||||
OrderDesc = true,
|
||||
PageSize = 5,
|
||||
PublisherId = Site!.PublisherId.ToString()
|
||||
};
|
||||
|
||||
var response = await postClient.ListPostsAsync(request);
|
||||
|
||||
if (response?.Posts != null)
|
||||
{
|
||||
FeaturedPosts = response.Posts.Select(SnPost.FromProtoValue).ToList();
|
||||
|
||||
// Convert the markdown content to HTML
|
||||
foreach (var post in FeaturedPosts.Where(post => !string.IsNullOrEmpty(post.Content)))
|
||||
post.Content = Markdown.ToHtml(post.Content!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int CalculateAge(Instant birthday)
|
||||
{
|
||||
var birthDate = birthday.ToDateTimeOffset();
|
||||
var today = DateTimeOffset.Now;
|
||||
var age = today.Year - birthDate.Year;
|
||||
if (birthDate > today.AddYears(-age)) age--;
|
||||
return age;
|
||||
}
|
||||
|
||||
public string GetOffsetUtcString(string targetTimeZone)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(targetTimeZone);
|
||||
var offset = tz.GetUtcOffset(DateTimeOffset.Now);
|
||||
var sign = offset >= TimeSpan.Zero ? "+" : "-";
|
||||
return $"{Math.Abs(offset.Hours):D2}:{Math.Abs(offset.Minutes):D2}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "00:00";
|
||||
}
|
||||
}
|
||||
|
||||
public string GetCurrentTimeInTimeZone(string targetTimeZone)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(targetTimeZone);
|
||||
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.Now, tz);
|
||||
return now.ToString("t", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DateTime.Now.ToString("t", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public (string Name, string Color) GetPerkInfo(string identifier)
|
||||
{
|
||||
return identifier switch
|
||||
{
|
||||
"solian.stellar.primary" => ("Stellar", "#2196f3"),
|
||||
"solian.stellar.nova" => ("Nova", "#39c5bb"),
|
||||
"solian.stellar.supernova" => ("Supernova", "#ffc109"),
|
||||
_ => ("Unknown", "#2196f3")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,64 @@
|
||||
|
||||
@page
|
||||
@model DysonNetwork.Zone.Pages.PostsModel
|
||||
@using DysonNetwork.Shared.Models
|
||||
@{
|
||||
Layout = "_LayoutContained";
|
||||
const string defaultAvatar = "https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp";
|
||||
|
||||
var pageTitle = "Posts";
|
||||
var pageDescription = "A collection of posts.";
|
||||
string? ogImageUrl = null;
|
||||
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
|
||||
var siteName = Model.Site?.Name ?? "Solar Network";
|
||||
|
||||
if (Model.Publisher != null)
|
||||
{
|
||||
pageTitle = $"Posts";
|
||||
pageDescription = $"Browse posts written by {Model.Publisher.Nick}.";
|
||||
if (Model.Publisher.Background != null)
|
||||
ogImageUrl = $"{Request.Scheme}://{Request.Host}/drive/files/{Model.Publisher.Background.Id}";
|
||||
}
|
||||
ViewData["Title"] = $"{pageTitle} - {siteName}";
|
||||
}
|
||||
|
||||
<div class="container mx-auto px-8 pb-8">
|
||||
<h1 class="text-3xl font-bold mb-8">Posts</h1>
|
||||
@section Head {
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<div class="w-full md:w-2/3">
|
||||
<meta name="description" content="@pageDescription" />
|
||||
<link rel="canonical" href="@canonicalUrl" />
|
||||
|
||||
<meta property="og:title" content="@pageTitle" />
|
||||
<meta property="og:description" content="@pageDescription" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="@canonicalUrl" />
|
||||
@if (!string.IsNullOrEmpty(ogImageUrl))
|
||||
{
|
||||
<meta property="og:image" content="@ogImageUrl" />
|
||||
}
|
||||
<meta property="og:site_name" content="@siteName" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="@pageTitle" />
|
||||
<meta name="twitter:description" content="@pageDescription" />
|
||||
@if (!string.IsNullOrEmpty(ogImageUrl))
|
||||
{
|
||||
<meta name="twitter:image" content="@ogImageUrl" />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
<div class="container mx-auto px-8 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8 px-5">
|
||||
<span class="mdi mdi-note-text-outline"></span> Posts
|
||||
</h1>
|
||||
|
||||
<div class="w-full grid grid-cols-3 gap-4">
|
||||
<div class="col-span-3 md:col-span-2">
|
||||
@if (Model.Posts.Any())
|
||||
{
|
||||
<div class="space-y-8">
|
||||
@foreach (var post in Model.Posts)
|
||||
{
|
||||
<div class="card bg-base-100 border">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">@post.Title</h2>
|
||||
<p>@post.Content</p>
|
||||
<div class="card-actions justify-end">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Posted on @post.CreatedAt.ToDateTimeOffset().ToString("yyyy-MM-dd")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<partial name="Shared/_PostItem" model="post" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -40,23 +72,26 @@
|
||||
@if (Model.TotalPages > 1)
|
||||
{
|
||||
<div class="flex justify-center mt-8">
|
||||
<div class="btn-group">
|
||||
@for (var i = 1; i <= Model.TotalPages; i++)
|
||||
<div class="join">
|
||||
@for (var idx = 1; idx <= Model.TotalPages; idx++)
|
||||
{
|
||||
<a href="/posts?currentPage=@i" class="btn @(i == Model.CurrentPage ? "btn-active" : "")">@i</a>
|
||||
var pageIdx = idx;
|
||||
<a asp-page="/Posts"
|
||||
asp-route-currentPage="@pageIdx"
|
||||
class="join-item btn @(pageIdx == Model.CurrentPage ? "btn-active" : "")">
|
||||
@pageIdx
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="card bg-base-100 border">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Publisher Info</h3>
|
||||
<p>This is where publisher information will be displayed.</p>
|
||||
</div>
|
||||
@if (Model.Publisher != null)
|
||||
{
|
||||
<div class="col-span-3 md:col-span-1">
|
||||
<partial name="Shared/_PublisherInfo" model="Model.Publisher" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,48 +1,49 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Markdig;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Zone.Publication;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace DysonNetwork.Zone.Pages
|
||||
namespace DysonNetwork.Zone.Pages;
|
||||
|
||||
public class PostsModel(PostService.PostServiceClient postClient, RemotePublisherService rps) : PageModel
|
||||
{
|
||||
public class PostsModel : PageModel
|
||||
public SnPublicationSite? Site { get; set; }
|
||||
public SnPublisher? Publisher { get; set; }
|
||||
public List<SnPost> Posts { get; set; } = [];
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
public int CurrentPage { get; set; }
|
||||
public int PageSize { get; set; } = 10;
|
||||
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
||||
|
||||
public async Task OnGetAsync(int currentPage = 1)
|
||||
{
|
||||
private readonly PostService.PostServiceClient _postClient;
|
||||
Site = HttpContext.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
|
||||
CurrentPage = currentPage;
|
||||
|
||||
public PostsModel(PostService.PostServiceClient postClient)
|
||||
Publisher = await rps.GetPublisher(id: Site!.PublisherId.ToString());
|
||||
|
||||
var request = new ListPostsRequest
|
||||
{
|
||||
_postClient = postClient;
|
||||
}
|
||||
OrderBy = "date",
|
||||
OrderDesc = true,
|
||||
PageSize = PageSize,
|
||||
PageToken = ((CurrentPage - 1) * PageSize).ToString(),
|
||||
PublisherId = Site!.PublisherId.ToString()
|
||||
};
|
||||
|
||||
public List<SnPost> Posts { get; set; } = new();
|
||||
public int TotalCount { get; set; }
|
||||
public int CurrentPage { get; set; }
|
||||
public int PageSize { get; set; } = 10;
|
||||
public int TotalPages => (int)System.Math.Ceiling(TotalCount / (double)PageSize);
|
||||
var response = await postClient.ListPostsAsync(request);
|
||||
|
||||
public async Task OnGetAsync(int currentPage = 1)
|
||||
if (response?.Posts != null)
|
||||
{
|
||||
CurrentPage = currentPage;
|
||||
Posts = response.Posts.Select(SnPost.FromProtoValue).ToList();
|
||||
TotalCount = response.TotalSize;
|
||||
|
||||
var request = new ListPostsRequest
|
||||
{
|
||||
OrderBy = "date",
|
||||
OrderDesc = true,
|
||||
PageSize = PageSize,
|
||||
PageToken = ((CurrentPage - 1) * PageSize).ToString()
|
||||
};
|
||||
|
||||
var response = await _postClient.ListPostsAsync(request);
|
||||
|
||||
if (response?.Posts != null)
|
||||
{
|
||||
Posts = response.Posts.Select(SnPost.FromProtoValue).ToList();
|
||||
TotalCount = response.TotalSize;
|
||||
}
|
||||
// Convert the markdown content to HTML
|
||||
foreach (var post in Posts.Where(post => !string.IsNullOrEmpty(post.Content)))
|
||||
post.Content = Markdown.ToHtml(post.Content!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
182
DysonNetwork.Zone/Pages/Posts/Details.cshtml
Normal file
182
DysonNetwork.Zone/Pages/Posts/Details.cshtml
Normal file
@@ -0,0 +1,182 @@
|
||||
@page "/p/{slug}"
|
||||
@using NodaTime
|
||||
@model DysonNetwork.Zone.Pages.Posts.DetailsModel
|
||||
@{
|
||||
Layout = "_LayoutContained";
|
||||
|
||||
var post = Model.Post;
|
||||
var pageTitle = post?.Title ?? "Post";
|
||||
var pageDescription = post?.Description;
|
||||
string? ogImageUrl = null;
|
||||
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
|
||||
|
||||
if (post != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pageDescription) && !string.IsNullOrWhiteSpace(post.Content))
|
||||
{
|
||||
var plainText = System.Text.RegularExpressions.Regex.Replace(post.Content, "<.*?>", string.Empty);
|
||||
pageDescription = plainText.Length > 160 ? plainText.Substring(0, 157) + "..." : plainText;
|
||||
}
|
||||
|
||||
var imageAttachment = post.Attachments.FirstOrDefault(a => a.MimeType != null && a.MimeType.StartsWith("image"));
|
||||
if (imageAttachment != null)
|
||||
{
|
||||
ogImageUrl = $"{Request.Scheme}://{Request.Host}/drive/files/{imageAttachment.Id}";
|
||||
}
|
||||
}
|
||||
ViewData["Title"] = pageTitle;
|
||||
}
|
||||
|
||||
@section Head {
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(pageDescription))
|
||||
{
|
||||
<meta name="description" content="@pageDescription" />
|
||||
<meta property="og:description" content="@pageDescription" />
|
||||
<meta name="twitter:description" content="@pageDescription" />
|
||||
}
|
||||
<link rel="canonical" href="@canonicalUrl" />
|
||||
|
||||
<meta property="og:title" content="@pageTitle" />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:url" content="@canonicalUrl" />
|
||||
@if (!string.IsNullOrEmpty(ogImageUrl))
|
||||
{
|
||||
<meta property="og:image" content="@ogImageUrl" />
|
||||
}
|
||||
|
||||
@if(post != null)
|
||||
{
|
||||
<meta property="article:published_time" content="@post.CreatedAt.ToString()" />
|
||||
@if (post.EditedAt.HasValue)
|
||||
{
|
||||
<meta property="article:modified_time" content="@post.EditedAt.Value.ToString()" />
|
||||
}
|
||||
|
||||
@foreach (var tag in post.Tags)
|
||||
{
|
||||
var tagName = !string.IsNullOrEmpty(tag.Name) ? tag.Name : tag.Slug;
|
||||
if(!string.IsNullOrEmpty(tagName))
|
||||
{
|
||||
<meta property="article:tag" content="@tagName" />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="@pageTitle" />
|
||||
@if (!string.IsNullOrEmpty(ogImageUrl))
|
||||
{
|
||||
<meta name="twitter:image" content="@ogImageUrl" />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
<div class="container max-w-4xl mx-auto px-8 py-8">
|
||||
<article>
|
||||
@if (Model.Post != null)
|
||||
{
|
||||
<section class="mb-8 mt-2 px-5">
|
||||
<h1 class="text-3xl font-bold">
|
||||
@(Model.Post.Title ?? "Post Details")
|
||||
</h1>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Post.Description))
|
||||
{
|
||||
<p>@Model.Post.Description</p>
|
||||
}
|
||||
|
||||
<div class="mt-2 flex gap-3 flex-wrap text-sm text-base-content/60">
|
||||
<div class="flex gap-1">
|
||||
<span class="mdi mdi-history"></span>
|
||||
<span>
|
||||
Posted on @Model.Post.CreatedAt.ToDateTimeOffset().ToString("yyyy-MM-dd HH:mm")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (Model.Post.EditedAt.HasValue)
|
||||
{
|
||||
<div class="flex gap-1">
|
||||
<span class="mdi mdi-pencil"></span>
|
||||
<span>
|
||||
Updated on @Model.Post.EditedAt.Value.ToDateTimeOffset().ToString("yyyy-MM-dd HH:mm")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="w-full flex flex-col gap-5">
|
||||
<div class="card bg-base-200 shadow-md">
|
||||
<div class="card-body flex flex-col gap-5">
|
||||
<div class="prose max-w-none">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Post.Description))
|
||||
{
|
||||
<p>@Model.Post.Description</p>
|
||||
}
|
||||
@Html.Raw(Model.Post.Content)
|
||||
</div>
|
||||
|
||||
@if (Model.Post.Attachments.Any())
|
||||
{
|
||||
<div class="flex flex-col gap-4">
|
||||
@foreach (var attachment in Model.Post.Attachments)
|
||||
{
|
||||
<div>
|
||||
@if (attachment.MimeType!.StartsWith("image"))
|
||||
{
|
||||
<figure>
|
||||
<img src="/drive/files/@attachment.Id" alt="@attachment.Name"
|
||||
class="max-w-full h-auto rounded-lg"/>
|
||||
</figure>
|
||||
}
|
||||
else if (attachment.MimeType!.StartsWith("video"))
|
||||
{
|
||||
<video controls src="/drive/files/@attachment.Id"
|
||||
class="max-w-full h-auto rounded-lg"></video>
|
||||
}
|
||||
else if (attachment.MimeType!.StartsWith("audio"))
|
||||
{
|
||||
<audio controls src="/drive/files/@attachment.Id"
|
||||
class="max-w-full h-auto rounded-lg"></audio>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/drive/files/@attachment.Id" target="_blank"
|
||||
class="text-blue-500 hover:underline">@attachment.Name</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Post.Categories.Any() || (Model.Post.Tags.Any()))
|
||||
{
|
||||
<div>
|
||||
@foreach (var category in Model.Post.Categories)
|
||||
{
|
||||
<span class="badge badge-primary">
|
||||
<span class="mdi mdi-shape"></span>
|
||||
@(!string.IsNullOrEmpty(category.Name) ? category.Name : category.Slug)
|
||||
</span>
|
||||
}
|
||||
@foreach (var tag in Model.Post.Tags)
|
||||
{
|
||||
<span class="badge badge-info">
|
||||
<span class="mdi mdi-tag"></span>
|
||||
@(!string.IsNullOrEmpty(tag.Name) ? tag.Name : tag.Slug)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-16">
|
||||
<p class="text-lg">Post not found.</p>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</div>
|
||||
42
DysonNetwork.Zone/Pages/Posts/Details.cshtml.cs
Normal file
42
DysonNetwork.Zone/Pages/Posts/Details.cshtml.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Markdig;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Zone.Publication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace DysonNetwork.Zone.Pages.Posts;
|
||||
|
||||
public class DetailsModel(PostService.PostServiceClient postClient) : PageModel
|
||||
{
|
||||
[FromRoute] public string Slug { get; set; } = null!;
|
||||
|
||||
public SnPublicationSite? Site { get; set; }
|
||||
public SnPost? Post { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
Site = HttpContext.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
|
||||
|
||||
if (string.IsNullOrEmpty(Slug))
|
||||
return NotFound();
|
||||
|
||||
var request = new GetPostRequest { PublisherId = Site!.PublisherId.ToString() };
|
||||
if (Guid.TryParse(Slug, out var guid)) request.Id = guid.ToString();
|
||||
else request.Slug = Slug;
|
||||
var response = await postClient.GetPostAsync(request);
|
||||
|
||||
if (response == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Post = SnPost.FromProtoValue(response);
|
||||
|
||||
// Convert markdown content to HTML
|
||||
if (Post != null && !string.IsNullOrEmpty(Post.Content))
|
||||
Post.Content = Markdown.ToHtml(Post.Content);
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
<link rel="stylesheet" href="~/DysonNetwork.Zone.styles.css" asp-append-version="true"/>
|
||||
|
||||
<link rel="icon" type="image/png" href="~/favicon.png" />
|
||||
|
||||
@await RenderSectionAsync("Head", required: false)
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -7,32 +7,43 @@
|
||||
}
|
||||
|
||||
<div class="navbar backdrop-blur-md bg-white/1 shadow-xl px-5">
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl" asp-page="/Index">@siteDisplayName</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a asp-page="/Posts">Posts</a></li>
|
||||
<li><a asp-page="/About">About</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl" asp-page="/Index">@siteDisplayName</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a asp-page="/Posts">Posts</a></li>
|
||||
<li><a asp-page="/About">About</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="content-main">
|
||||
@RenderBody()
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
height: 64px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
.content-main {
|
||||
height: calc(100vh - 64px);
|
||||
margin-top: 64px;
|
||||
}
|
||||
</style>
|
||||
@section Scripts
|
||||
{
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
}
|
||||
|
||||
@section Head
|
||||
{
|
||||
@await RenderSectionAsync("Head", required: false)
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
height: 64px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.content-main {
|
||||
height: calc(100vh - 64px);
|
||||
margin-top: 64px;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
89
DysonNetwork.Zone/Pages/Shared/_PostItem.cshtml
Normal file
89
DysonNetwork.Zone/Pages/Shared/_PostItem.cshtml
Normal file
@@ -0,0 +1,89 @@
|
||||
@using DysonNetwork.Shared.Models
|
||||
@model DysonNetwork.Shared.Models.SnPost
|
||||
|
||||
<div class="card bg-base-200 shadow-md">
|
||||
<div class="card-body flex flex-col gap-5">
|
||||
<div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Title) || !string.IsNullOrWhiteSpace(Model.Description))
|
||||
{
|
||||
<section class="mb-1">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Title))
|
||||
{
|
||||
<h2 class="text-lg font-bold">
|
||||
<a asp-page="/Posts/Details"
|
||||
asp-route-slug="@(Model.Slug ?? Model.Id.ToString())">@Model.Title</a>
|
||||
</h2>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Description))
|
||||
{
|
||||
<p>@Model.Description</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
<p>@Html.Raw(Model.Content)</p>
|
||||
</div>
|
||||
|
||||
@if (Model.Attachments.Any())
|
||||
{
|
||||
<div class="flex flex-col gap-4">
|
||||
@foreach (var attachment in Model.Attachments)
|
||||
{
|
||||
<div>
|
||||
@if (attachment.MimeType!.StartsWith("image"))
|
||||
{
|
||||
<figure>
|
||||
<img src="/drive/files/@attachment.Id" alt="@attachment.Name"
|
||||
class="max-w-full h-auto rounded-lg"/>
|
||||
</figure>
|
||||
}
|
||||
else if (attachment.MimeType!.StartsWith("video"))
|
||||
{
|
||||
<video controls src="/drive/files/@attachment.Id"
|
||||
class="max-w-full h-auto rounded-lg"></video>
|
||||
}
|
||||
else if (attachment.MimeType!.StartsWith("audio"))
|
||||
{
|
||||
<audio controls src="/drive/files/@attachment.Id"
|
||||
class="max-w-full h-auto rounded-lg"></audio>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/drive/files/@attachment.Id" target="_blank"
|
||||
class="text-blue-500 hover:underline">@attachment.Name</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Categories.Any() || Model.Tags.Any())
|
||||
{
|
||||
<div>
|
||||
@foreach (var category in Model.Categories)
|
||||
{
|
||||
<span class="badge badge-primary">
|
||||
<span class="mdi mdi-shape"></span>
|
||||
@(!string.IsNullOrEmpty(category.Name) ? category.Name : category.Slug)
|
||||
</span>
|
||||
}
|
||||
@foreach (var tag in Model.Tags)
|
||||
{
|
||||
<span class="badge badge-info">
|
||||
<span class="mdi mdi-tag"></span>
|
||||
@(!string.IsNullOrEmpty(tag.Name) ? tag.Name : tag.Slug)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card-actions justify-end items-center">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Posted on @Model.CreatedAt.ToDateTimeOffset().ToString("yyyy-MM-dd")
|
||||
</div>
|
||||
<a asp-page="/Posts/Details" asp-route-slug="@(Model.Slug ?? Model.Id.ToString())"
|
||||
class="btn btn-sm btn-ghost btn-circle">
|
||||
<span class="mdi mdi-arrow-right text-lg"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
36
DysonNetwork.Zone/Pages/Shared/_PublisherInfo.cshtml
Normal file
36
DysonNetwork.Zone/Pages/Shared/_PublisherInfo.cshtml
Normal file
@@ -0,0 +1,36 @@
|
||||
@using DysonNetwork.Shared.Models
|
||||
@model DysonNetwork.Shared.Models.SnPublisher
|
||||
|
||||
@{
|
||||
const string defaultAvatar = "https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp";
|
||||
}
|
||||
|
||||
<div class="card bg-base-200 shadow-md">
|
||||
@if (Model.Background != null)
|
||||
{
|
||||
<figure class="relative">
|
||||
<img
|
||||
src="/drive/files/@Model.Background.Id"
|
||||
alt="Background"/>
|
||||
</figure>
|
||||
}
|
||||
<div class="card-body">
|
||||
<div class="flex gap-4 card-title">
|
||||
<div class="avatar">
|
||||
<div class="w-16 rounded-full">
|
||||
<img
|
||||
src="@(Model.Picture is null ? defaultAvatar : "/drive/files/" + Model.Picture!.Id)"
|
||||
alt="Avatar"/>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="flex-1 flex flex-col">
|
||||
@Model.Nick
|
||||
<span class="text-xs">@@@Model.Name</span>
|
||||
</h2>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Bio))
|
||||
{
|
||||
<p>@Model.Bio</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
58
DysonNetwork.Zone/Pages/Shared/_SiteInfo.cshtml
Normal file
58
DysonNetwork.Zone/Pages/Shared/_SiteInfo.cshtml
Normal file
@@ -0,0 +1,58 @@
|
||||
@using DysonNetwork.Shared.Models
|
||||
@model DysonNetwork.Shared.Models.SnPublicationSite
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
<div class="card bg-base-100 border">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title"><span class="mdi mdi-home"></span> Info</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="flex gap-2">
|
||||
<span class="mdi mdi-label"></span>
|
||||
Name
|
||||
</span>
|
||||
<span class="text-right">@Model.Name</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="flex gap-2">
|
||||
<span class="mdi mdi-tag"></span>
|
||||
Slug
|
||||
</span>
|
||||
<span class="text-right font-mono">@Model.Slug</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
{
|
||||
<div class="flex-1">
|
||||
<div class="card bg-base-100 border">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
<span class="mdi mdi-information"></span> Description
|
||||
</h3>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
@Model.Description
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-col p-5 opacity-80 text-sm">
|
||||
<p>Proudly powered by the Solar Network Pages</p>
|
||||
<p>Hosted on the Solar Network</p>
|
||||
<p class="mt-2">Network powered by Cloudflare</p>
|
||||
<p>Therefore, if the site is down, 99% is Cloudflare's fault</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
70
DysonNetwork.Zone/Pages/Shared/_UserInfo.cshtml
Normal file
70
DysonNetwork.Zone/Pages/Shared/_UserInfo.cshtml
Normal file
@@ -0,0 +1,70 @@
|
||||
@model DysonNetwork.Zone.Pages.IndexModel
|
||||
|
||||
@if (Model.UserAccount != null)
|
||||
{
|
||||
<div class="card bg-base-100 border">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-6 mb-8">
|
||||
@if (!string.IsNullOrEmpty(Model.UserPictureUrl))
|
||||
{
|
||||
<img src="@Model.UserPictureUrl" class="w-20 h-20 rounded-full object-cover" alt="Avatar"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="w-20 h-20 rounded-full bg-gray-300 flex items-center justify-center">
|
||||
<span class="text-2xl">@(Model.UserAccount.Name != null ? Model.UserAccount.Name[0] : '?')</span>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<div class="text-2xl font-bold">
|
||||
@(Model.UserAccount.Nick ?? Model.UserAccount.Name)
|
||||
</div>
|
||||
<div class="text-body-2 text-base-content/60">@@@Model.UserAccount.Name</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@if (!string.IsNullOrEmpty(Model.UserAccount.Profile?.TimeZone))
|
||||
{
|
||||
<div class="flex justify-between">
|
||||
<span class="flex gap-2">
|
||||
<span class="mdi mdi-map-clock"></span>
|
||||
Time Zone
|
||||
</span>
|
||||
<span class="text-right">
|
||||
@Model.GetCurrentTimeInTimeZone(Model.UserAccount.Profile.TimeZone)
|
||||
<span class="text-base-content/50">·</span>
|
||||
@Model.GetOffsetUtcString(Model.UserAccount.Profile.TimeZone)
|
||||
<span class="text-base-content/50">·</span>
|
||||
@Model.UserAccount.Profile.TimeZone
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.UserAccount.Profile?.Location))
|
||||
{
|
||||
<div class="flex justify-between">
|
||||
<span class="flex gap-2">
|
||||
<span class="mdi mdi-map-marker"></span> Location
|
||||
</span>
|
||||
<span>@Model.UserAccount.Profile.Location</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.UserAccount.Profile?.FirstName) || !string.IsNullOrEmpty(Model.UserAccount.Profile?.LastName))
|
||||
{
|
||||
<div class="flex justify-between">
|
||||
<span class="flex gap-2">
|
||||
<span class="mdi mdi-card-bulleted-outline"></span> Name
|
||||
</span>
|
||||
<span>
|
||||
@string.Join(" ", new[]
|
||||
{
|
||||
Model.UserAccount.Profile.FirstName,
|
||||
Model.UserAccount.Profile.MiddleName,
|
||||
Model.UserAccount.Profile.LastName
|
||||
}.Where(s => !string.IsNullOrEmpty(s)))
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace DysonNetwork.Zone.Publication;
|
||||
|
||||
@@ -120,6 +121,11 @@ public class PublicationSiteManager(
|
||||
return GetFullPath(siteId, relativePath);
|
||||
}
|
||||
|
||||
public string GetSiteDirectory(Guid siteId)
|
||||
{
|
||||
return Path.Combine(_basePath, siteId.ToString());
|
||||
}
|
||||
|
||||
public async Task UpdateFile(Guid siteId, string relativePath, string newContent)
|
||||
{
|
||||
await EnsureSiteDirectory(siteId);
|
||||
@@ -136,4 +142,42 @@ public class PublicationSiteManager(
|
||||
else if (Directory.Exists(fullPath))
|
||||
Directory.Delete(fullPath, true);
|
||||
}
|
||||
|
||||
public async Task PurgeSite(Guid siteId)
|
||||
{
|
||||
await EnsureSiteDirectory(siteId); // This ensures site exists and is self-managed
|
||||
var siteDirectory = Path.Combine(_basePath, siteId.ToString());
|
||||
if (Directory.Exists(siteDirectory))
|
||||
{
|
||||
Directory.Delete(siteDirectory, true); // true for recursive delete
|
||||
Directory.CreateDirectory(siteDirectory); // Recreate empty directory
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeployZip(Guid siteId, IFormFile zipFile)
|
||||
{
|
||||
await EnsureSiteDirectory(siteId);
|
||||
var siteDirectory = Path.Combine(_basePath, siteId.ToString());
|
||||
|
||||
// Create a temporary file for the uploaded zip
|
||||
var tempFilePath = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
await using (var stream = new FileStream(tempFilePath, FileMode.Create))
|
||||
{
|
||||
await zipFile.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
// Extract the zip file to the site's directory, overwriting existing files
|
||||
await ZipFile.ExtractToDirectoryAsync(tempFilePath, siteDirectory, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up the temporary file
|
||||
if (File.Exists(tempFilePath))
|
||||
{
|
||||
File.Delete(tempFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,17 @@ public class PublicationSiteMiddleware(RequestDelegate next)
|
||||
return;
|
||||
}
|
||||
|
||||
if (Directory.Exists(hostedFilePath))
|
||||
{
|
||||
var indexPath = Path.Combine(hostedFilePath, "index.html");
|
||||
if (File.Exists(indexPath))
|
||||
{
|
||||
context.Response.ContentType = "text/html";
|
||||
await context.Response.SendFileAsync(indexPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var hostedNotFoundPath = psm.GetValidatedFullPath(site.Id, "404.html");
|
||||
if (File.Exists(hostedNotFoundPath))
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.IO.Compression;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
@@ -24,7 +25,8 @@ public class SiteManagerController(
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var isMember = await remotePublisherService.IsMemberWithRole(site.PublisherId, accountId, PublisherMemberRole.Editor);
|
||||
var isMember =
|
||||
await remotePublisherService.IsMemberWithRole(site.PublisherId, accountId, PublisherMemberRole.Editor);
|
||||
return !isMember ? Forbid() : null;
|
||||
}
|
||||
|
||||
@@ -82,6 +84,130 @@ public class SiteManagerController(
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("deploy")]
|
||||
[Authorize]
|
||||
[Consumes("multipart/form-data")]
|
||||
public async Task<IActionResult> Deploy(
|
||||
Guid siteId,
|
||||
[FromForm(Name = "file")] IFormFile? zipFile,
|
||||
[FromQuery] bool smart = true
|
||||
)
|
||||
{
|
||||
var check = await CheckAccess(siteId);
|
||||
if (check != null) return check;
|
||||
|
||||
if (zipFile == null || zipFile.Length == 0)
|
||||
return BadRequest("No file provided.");
|
||||
|
||||
if (Path.GetExtension(zipFile.FileName).ToLowerInvariant() != ".zip")
|
||||
return BadRequest("Only .zip files are allowed for deployment.");
|
||||
|
||||
// Define size limits
|
||||
const long maxZipFileSize = 52428800; // 50MB for the zip file itself
|
||||
const long maxTotalSiteSizeAfterExtract = 104857600; // 100MB total size after extraction
|
||||
|
||||
if (zipFile.Length > maxZipFileSize)
|
||||
return BadRequest($"Zip file size exceeds {maxZipFileSize / (1024 * 1024)}MB limit.");
|
||||
|
||||
try
|
||||
{
|
||||
// For now, we'll only check the zip file size.
|
||||
// A more robust solution might involve extracting to a temp location
|
||||
// and checking the uncompressed size before moving, but that's more complex.
|
||||
|
||||
// Get current site size before deployment
|
||||
long currentTotal = await fileManager.GetTotalSiteSize(siteId);
|
||||
|
||||
// This is a rough check. The actual uncompressed size might be much larger.
|
||||
// Consider adding a more sophisticated check if this is a concern.
|
||||
if (currentTotal + zipFile.Length * 3 > maxTotalSiteSizeAfterExtract) // Heuristic: assume 3x expansion
|
||||
return BadRequest(
|
||||
$"Deployment would exceed total site size limit of {maxTotalSiteSizeAfterExtract / (1024 * 1024)}MB.");
|
||||
|
||||
var siteDir = fileManager.GetSiteDirectory(siteId);
|
||||
Directory.CreateDirectory(siteDir); // Ensure site directory exists
|
||||
|
||||
if (smart)
|
||||
{
|
||||
// Smart mode: Extract to temp directory and flatten if single folder wrapper
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
await using (var archive = new ZipArchive(zipFile.OpenReadStream(), ZipArchiveMode.Read))
|
||||
{
|
||||
await archive.ExtractToDirectoryAsync(tempDir, true);
|
||||
}
|
||||
|
||||
// Check if temp directory has exactly one subdirectory and no files at root
|
||||
var rootEntries = Directory.GetFileSystemEntries(tempDir);
|
||||
if (rootEntries.Length == 1 && Directory.Exists(rootEntries[0]))
|
||||
{
|
||||
var innerDir = rootEntries[0];
|
||||
// Flatten: move contents of innerDir to siteDir
|
||||
foreach (var file in Directory.GetFiles(innerDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string relPath = Path.GetRelativePath(innerDir, file);
|
||||
string destFile = Path.Combine(siteDir, relPath);
|
||||
string? destDir = Path.GetDirectoryName(destFile);
|
||||
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
|
||||
Directory.CreateDirectory(destDir);
|
||||
System.IO.File.Move(file, destFile, true);
|
||||
}
|
||||
|
||||
// Also create empty directories
|
||||
foreach (var dir in Directory.GetDirectories(innerDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string relPath = Path.GetRelativePath(innerDir, dir);
|
||||
string destDirPath = Path.Combine(siteDir, relPath);
|
||||
Directory.CreateDirectory(destDirPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No smart flattening needed, extract directly to siteDir
|
||||
using (var archive = new ZipArchive(zipFile.OpenReadStream(), ZipArchiveMode.Read))
|
||||
{
|
||||
archive.ExtractToDirectory(siteDir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await fileManager.DeployZip(siteId, zipFile);
|
||||
}
|
||||
|
||||
return Ok("Deployment successful.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest($"Deployment failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("purge")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Purge(Guid siteId)
|
||||
{
|
||||
var check = await CheckAccess(siteId);
|
||||
if (check != null) return check;
|
||||
|
||||
try
|
||||
{
|
||||
await fileManager.PurgeSite(siteId);
|
||||
return Ok("Site content purged successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest($"Purge failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("content/{**relativePath}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<string>> GetFileContent(Guid siteId, string relativePath)
|
||||
@@ -195,4 +321,4 @@ public class SiteManagerController(
|
||||
public class UpdateFileRequest
|
||||
{
|
||||
public string NewContent { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user