BLOG-126 Post management (list and create) #139

Merged
squid merged 10 commits from BLOG-126_post_management into main 2025-10-15 04:21:15 +08:00
16 changed files with 56 additions and 41 deletions
Showing only changes of commit 4c4c5c357e - Show all commits

View File

@ -12,8 +12,8 @@ pub struct CreatePostRequestDto {
pub content: String, pub content: String,
pub label_ids: Vec<i32>, pub label_ids: Vec<i32>,
#[schema(format = Uri)] #[schema(required, format = Uri)]
pub preview_image_url: String, pub preview_image_url: Option<String>,
#[schema(required, format = DateTime)] #[schema(required, format = DateTime)]
pub published_time: Option<String>, pub published_time: Option<String>,

View File

@ -14,7 +14,7 @@ pub struct PostInfoResponseDto {
pub labels: Vec<LabelResponseDto>, pub labels: Vec<LabelResponseDto>,
#[schema(format = Uri)] #[schema(format = Uri)]
pub preview_image_url: String, pub preview_image_url: Option<String>,
#[schema(format = DateTime)] #[schema(format = DateTime)]
pub published_time: Option<String>, pub published_time: Option<String>,

View File

@ -12,8 +12,8 @@ pub struct UpdatePostRequestDto {
pub content: String, pub content: String,
pub label_ids: Vec<i32>, pub label_ids: Vec<i32>,
#[schema(format = Uri)] #[schema(required, format = Uri)]
pub preview_image_url: String, pub preview_image_url: Option<String>,
#[schema(required, format = DateTime)] #[schema(required, format = DateTime)]
pub published_time: Option<String>, pub published_time: Option<String>,

View File

@ -7,7 +7,7 @@ pub struct PostInfoMapper {
pub semantic_id: String, pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: Option<String>,
pub published_time: Option<NaiveDateTime>, pub published_time: Option<NaiveDateTime>,
pub labels: Vec<LabelMapper>, pub labels: Vec<LabelMapper>,
} }

View File

@ -10,7 +10,7 @@ pub struct PostInfo {
pub semantic_id: String, pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: Option<String>,
pub labels: Vec<Label>, pub labels: Vec<Label>,
pub published_time: Option<DateTime<Utc>>, pub published_time: Option<DateTime<Utc>>,
} }

View File

@ -6,7 +6,7 @@ pub struct PostInfoWithLabelRecord {
pub semantic_id: String, pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: Option<String>,
pub published_time: Option<NaiveDateTime>, pub published_time: Option<NaiveDateTime>,
pub label_id: Option<i32>, pub label_id: Option<i32>,

View File

@ -6,7 +6,7 @@ pub struct PostWithLabelRecord {
pub semantic_id: String, pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: Option<String>,
pub content: String, pub content: String,
pub published_time: Option<NaiveDateTime>, pub published_time: Option<NaiveDateTime>,

View File

@ -0,0 +1,2 @@
-- Revert post.preview_image_url to NOT NULL
ALTER TABLE "post" ALTER COLUMN "preview_image_url" SET NOT NULL;

View File

@ -0,0 +1,2 @@
-- Make post.preview_image_url nullable
ALTER TABLE "post" ALTER COLUMN "preview_image_url" DROP NOT NULL;

View File

@ -6,7 +6,7 @@ export class CreatePostRequestDto {
readonly description: string; readonly description: string;
readonly content: string; readonly content: string;
readonly labelIds: number[]; readonly labelIds: number[];
readonly previewImageUrl: URL; readonly previewImageUrl: URL | null;
readonly publishedTime: Date | null; readonly publishedTime: Date | null;
private constructor(props: { private constructor(props: {
@ -15,7 +15,7 @@ export class CreatePostRequestDto {
description: string; description: string;
content: string; content: string;
labelIds: number[]; labelIds: number[];
previewImageUrl: URL; previewImageUrl: URL | null;
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {
this.semanticId = props.semanticId; this.semanticId = props.semanticId;
@ -33,7 +33,7 @@ export class CreatePostRequestDto {
description: '', description: '',
content: '', content: '',
labelIds: [], labelIds: [],
previewImageUrl: new URL('https://example.com'), previewImageUrl: null,
publishedTime: null, publishedTime: null,
}); });
} }

View File

@ -7,7 +7,7 @@ export const PostInfoResponseSchema = z.object({
semantic_id: z.string(), semantic_id: z.string(),
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
preview_image_url: z.url(), preview_image_url: z.url().nullable(),
labels: z.array(LabelResponseSchema), labels: z.array(LabelResponseSchema),
published_time: z.iso.datetime({ offset: true }).nullable(), published_time: z.iso.datetime({ offset: true }).nullable(),
}); });
@ -17,7 +17,7 @@ export class PostInfoResponseDto {
readonly semanticId: string; readonly semanticId: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly previewImageUrl: URL; readonly previewImageUrl: URL | null;
readonly labels: readonly LabelResponseDto[]; readonly labels: readonly LabelResponseDto[];
readonly publishedTime: Date | null; readonly publishedTime: Date | null;
@ -26,7 +26,7 @@ export class PostInfoResponseDto {
semanticId: string; semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: URL; previewImageUrl: URL | null;
labels: LabelResponseDto[]; labels: LabelResponseDto[];
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {
@ -47,12 +47,17 @@ export class PostInfoResponseDto {
published_time = new Date(parsedJson.published_time); published_time = new Date(parsedJson.published_time);
} }
let preview_image_url: URL | null = null;
if (parsedJson.preview_image_url !== null) {
preview_image_url = new URL(parsedJson.preview_image_url);
}
return new PostInfoResponseDto({ return new PostInfoResponseDto({
id: parsedJson.id, id: parsedJson.id,
semanticId: parsedJson.semantic_id, semanticId: parsedJson.semantic_id,
title: parsedJson.title, title: parsedJson.title,
description: parsedJson.description, description: parsedJson.description,
previewImageUrl: new URL(parsedJson.preview_image_url), previewImageUrl: preview_image_url,
labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)), labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)),
publishedTime: published_time, publishedTime: published_time,
}); });

View File

@ -9,7 +9,7 @@ export class PostInfoViewModel {
readonly semanticId: string; readonly semanticId: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly previewImageUrl: URL; readonly previewImageUrl: URL | null;
readonly labels: readonly LabelViewModel[]; readonly labels: readonly LabelViewModel[];
readonly publishedTime: Date | null; readonly publishedTime: Date | null;
@ -18,7 +18,7 @@ export class PostInfoViewModel {
semanticId: string; semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: URL; previewImageUrl: URL | null;
labels: readonly LabelViewModel[]; labels: readonly LabelViewModel[];
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {
@ -49,12 +49,17 @@ export class PostInfoViewModel {
publishedTime = new Date(props.publishedTime); publishedTime = new Date(props.publishedTime);
} }
let previewImageUrl: URL | null = null;
if (props.previewImageUrl) {
previewImageUrl = new URL(props.previewImageUrl);
}
return new PostInfoViewModel({ return new PostInfoViewModel({
id: props.id, id: props.id,
semanticId: props.semanticId, semanticId: props.semanticId,
title: props.title, title: props.title,
description: props.description, description: props.description,
previewImageUrl: new URL(props.previewImageUrl), previewImageUrl: previewImageUrl,
labels: props.labels.map((label) => LabelViewModel.rehydrate(label)), labels: props.labels.map((label) => LabelViewModel.rehydrate(label)),
publishedTime: publishedTime, publishedTime: publishedTime,
}); });
@ -74,7 +79,7 @@ export class PostInfoViewModel {
semanticId: this.semanticId, semanticId: this.semanticId,
title: this.title, title: this.title,
description: this.description, description: this.description,
previewImageUrl: this.previewImageUrl.href, previewImageUrl: this.previewImageUrl?.href ?? null,
labels: this.labels.map((label) => label.dehydrate()), labels: this.labels.map((label) => label.dehydrate()),
publishedTime: this.publishedTime?.getTime() ?? null, publishedTime: this.publishedTime?.getTime() ?? null,
}; };
@ -86,7 +91,7 @@ export interface DehydratedPostInfoProps {
semanticId: string; semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: string; previewImageUrl: string | null;
labels: DehydratedLabelProps[]; labels: DehydratedLabelProps[];
publishedTime: number | null; publishedTime: number | null;
} }

View File

@ -5,7 +5,7 @@ export class PostInfo {
readonly semanticId: string; readonly semanticId: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly previewImageUrl: URL; readonly previewImageUrl: URL | null;
readonly labels: readonly Label[]; readonly labels: readonly Label[];
readonly publishedTime: Date | null; readonly publishedTime: Date | null;
@ -14,7 +14,7 @@ export class PostInfo {
semanticId: string; semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: URL; previewImageUrl: URL | null;
labels: readonly Label[]; labels: readonly Label[];
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {

View File

@ -14,27 +14,28 @@
const md = markdownit(); const md = markdownit();
const parsedContent = $derived(state.data?.content ? md.render(state.data.content) : ''); const parsedContent = $derived(state.data?.content ? md.render(state.data.content) : '');
const postInfo = $derived(state.data?.info);
onMount(() => postBloc.dispatch({ event: PostEventType.PostLoadedEvent, id: id })); onMount(() => postBloc.dispatch({ event: PostEventType.PostLoadedEvent, id: id }));
</script> </script>
<svelte:head> <svelte:head>
<title>{generateTitle(state.data?.info.title)}</title> <title>{generateTitle(postInfo?.title)}</title>
{#if state.data} {#if postInfo}
<meta name="description" content={state.data.info.description} /> <meta name="description" content={postInfo.description} />
{#if state.data.info.isPublished} {#if postInfo.isPublished}
<StructuredData <StructuredData
headline={state.data.info.title} headline={postInfo.title}
description={state.data.info.description} description={postInfo.description}
datePublished={state.data.info.publishedTime!} datePublished={postInfo.publishedTime!}
image={state.data.info.previewImageUrl} image={postInfo.previewImageUrl}
/> />
{/if} {/if}
{/if} {/if}
</svelte:head> </svelte:head>
<article class="content-container prose pb-10 prose-gray"> <article class="content-container prose prose-gray pb-10">
{#if state.data} {#if postInfo}
<PostContentHeader postInfo={state.data.info} /> <PostContentHeader {postInfo} />
<div class="max-w-3xl"> <div class="max-w-3xl">
<hr /> <hr />
<SafeHtml html={parsedContent} /> <SafeHtml html={parsedContent} />

View File

@ -7,11 +7,11 @@
let isImageLoading = $state(true); let isImageLoading = $state(true);
let isImageError = $state(false); let isImageError = $state(false);
function handleImageLoad() { function onImageLoad() {
isImageLoading = false; isImageLoading = false;
} }
function handleImageError() { function onImageError() {
isImageLoading = false; isImageLoading = false;
isImageError = true; isImageError = true;
} }
@ -27,10 +27,10 @@
class="rounded-2xl object-cover transition-opacity duration-300 class="rounded-2xl object-cover transition-opacity duration-300
{isImageLoading ? 'opacity-0' : 'opacity-100'} {isImageLoading ? 'opacity-0' : 'opacity-100'}
{isImageError ? 'hidden' : ''}" {isImageError ? 'hidden' : ''}"
src={postInfo.previewImageUrl.href} src={postInfo.previewImageUrl?.href}
alt={postInfo.title} alt={postInfo.title}
onload={handleImageLoad} onload={onImageLoad}
onerror={handleImageError} onerror={onImageError}
/> />
{#if isImageLoading || isImageError} {#if isImageLoading || isImageError}
<div class="absolute inset-0 flex items-center justify-center bg-gray-200"></div> <div class="absolute inset-0 flex items-center justify-center bg-gray-200"></div>

View File

@ -10,7 +10,7 @@
headline: string; headline: string;
description: string; description: string;
datePublished: Date; datePublished: Date;
image: URL; image: URL | null;
} = $props(); } = $props();
const structuredData = $derived({ const structuredData = $derived({
@ -19,7 +19,7 @@
headline: headline, headline: headline,
description: description, description: description,
datePublished: datePublished.toISOString(), datePublished: datePublished.toISOString(),
image: image.href, image: image?.href,
}); });
const jsonLdScript = $derived( const jsonLdScript = $derived(