BLOG-44 Post overall page #64
@ -1,3 +1,4 @@
|
|||||||
|
pub mod color_response_dto;
|
||||||
pub mod label_response_dto;
|
pub mod label_response_dto;
|
||||||
pub mod post_controller;
|
pub mod post_controller;
|
||||||
pub mod post_info_query_dto;
|
pub mod post_info_query_dto;
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::domain::entity::color::Color;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ColorResponseDto {
|
||||||
|
pub red: u8,
|
||||||
|
pub green: u8,
|
||||||
|
pub blue: u8,
|
||||||
|
pub alpha: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Color> for ColorResponseDto {
|
||||||
|
fn from(color: Color) -> Self {
|
||||||
|
Self {
|
||||||
|
red: color.red,
|
||||||
|
green: color.green,
|
||||||
|
blue: color.blue,
|
||||||
|
alpha: color.alpha,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,14 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::domain::entity::label::Label;
|
use crate::{
|
||||||
|
adapter::delivery::color_response_dto::ColorResponseDto, domain::entity::label::Label,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct LabelResponseDto {
|
pub struct LabelResponseDto {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub color: String,
|
pub color: ColorResponseDto,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Label> for LabelResponseDto {
|
impl From<Label> for LabelResponseDto {
|
||||||
@ -14,7 +16,7 @@ impl From<Label> for LabelResponseDto {
|
|||||||
Self {
|
Self {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
color: format!("#{:08X}", entity.color),
|
color: ColorResponseDto::from(entity.color),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,2 +1,6 @@
|
|||||||
|
pub mod color_mapper;
|
||||||
|
pub mod label_mapper;
|
||||||
pub mod post_db_service;
|
pub mod post_db_service;
|
||||||
|
pub mod post_info_mapper;
|
||||||
|
pub mod post_mapper;
|
||||||
pub mod post_repository_impl;
|
pub mod post_repository_impl;
|
||||||
|
16
backend/feature/post/src/adapter/gateway/color_mapper.rs
Normal file
16
backend/feature/post/src/adapter/gateway/color_mapper.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
use crate::domain::entity::color::Color;
|
||||||
|
|
||||||
|
pub struct ColorMapper {
|
||||||
|
pub value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorMapper {
|
||||||
|
pub fn to_entity(&self) -> Color {
|
||||||
|
Color {
|
||||||
|
red: (self.value >> 24) as u8,
|
||||||
|
green: ((self.value >> 16) & 0xFF) as u8,
|
||||||
|
blue: ((self.value >> 8) & 0xFF) as u8,
|
||||||
|
alpha: (self.value & 0xFF) as u8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
backend/feature/post/src/adapter/gateway/label_mapper.rs
Normal file
17
backend/feature/post/src/adapter/gateway/label_mapper.rs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
use crate::{adapter::gateway::color_mapper::ColorMapper, domain::entity::label::Label};
|
||||||
|
|
||||||
|
pub struct LabelMapper {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub color: ColorMapper,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LabelMapper {
|
||||||
|
pub fn to_entity(&self) -> Label {
|
||||||
|
Label {
|
||||||
|
id: self.id,
|
||||||
|
name: self.name.clone(),
|
||||||
|
color: self.color.to_entity(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,15 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
adapter::gateway::{post_info_mapper::PostInfoMapper, post_mapper::PostMapper},
|
||||||
application::error::post_error::PostError,
|
application::error::post_error::PostError,
|
||||||
domain::entity::{post::Post, post_info::PostInfo},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait PostDbService: Send + Sync {
|
pub trait PostDbService: Send + Sync {
|
||||||
async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError>;
|
async fn get_all_post_info(
|
||||||
async fn get_full_post(&self, id: i32) -> Result<Post, PostError>;
|
&self,
|
||||||
|
is_published_only: bool,
|
||||||
|
) -> Result<Vec<PostInfoMapper>, PostError>;
|
||||||
|
async fn get_full_post(&self, id: i32) -> Result<PostMapper, PostError>;
|
||||||
}
|
}
|
||||||
|
27
backend/feature/post/src/adapter/gateway/post_info_mapper.rs
Normal file
27
backend/feature/post/src/adapter/gateway/post_info_mapper.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
|
|
||||||
|
use crate::{adapter::gateway::label_mapper::LabelMapper, domain::entity::post_info::PostInfo};
|
||||||
|
|
||||||
|
pub struct PostInfoMapper {
|
||||||
|
pub id: i32,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub preview_image_url: String,
|
||||||
|
pub published_time: Option<NaiveDateTime>,
|
||||||
|
pub labels: Vec<LabelMapper>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostInfoMapper {
|
||||||
|
pub fn to_entity(&self) -> PostInfo {
|
||||||
|
PostInfo {
|
||||||
|
id: self.id,
|
||||||
|
title: self.title.clone(),
|
||||||
|
description: self.description.clone(),
|
||||||
|
preview_image_url: self.preview_image_url.clone(),
|
||||||
|
published_time: self
|
||||||
|
.published_time
|
||||||
|
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
|
||||||
|
labels: self.labels.iter().map(LabelMapper::to_entity).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
backend/feature/post/src/adapter/gateway/post_mapper.rs
Normal file
17
backend/feature/post/src/adapter/gateway/post_mapper.rs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
use crate::{adapter::gateway::post_info_mapper::PostInfoMapper, domain::entity::post::Post};
|
||||||
|
|
||||||
|
pub struct PostMapper {
|
||||||
|
pub id: i32,
|
||||||
|
pub info: PostInfoMapper,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostMapper {
|
||||||
|
pub fn to_entity(&self) -> Post {
|
||||||
|
Post {
|
||||||
|
id: self.id,
|
||||||
|
info: self.info.to_entity(),
|
||||||
|
content: self.content.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -22,10 +22,21 @@ impl PostRepositoryImpl {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PostRepository for PostRepositoryImpl {
|
impl PostRepository for PostRepositoryImpl {
|
||||||
async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError> {
|
async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError> {
|
||||||
self.post_db_service.get_all_post_info(is_published_only).await
|
self.post_db_service
|
||||||
|
.get_all_post_info(is_published_only)
|
||||||
|
.await
|
||||||
|
.map(|mappers| {
|
||||||
|
mappers
|
||||||
|
.into_iter()
|
||||||
|
.map(|mapper| mapper.to_entity())
|
||||||
|
.collect::<Vec<PostInfo>>()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_full_post(&self, id: i32) -> Result<Post, PostError> {
|
async fn get_full_post(&self, id: i32) -> Result<Post, PostError> {
|
||||||
self.post_db_service.get_full_post(id).await
|
self.post_db_service
|
||||||
|
.get_full_post(id)
|
||||||
|
.await
|
||||||
|
.map(|mapper| mapper.to_entity())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
pub mod color;
|
||||||
pub mod label;
|
pub mod label;
|
||||||
pub mod post_info;
|
pub mod post_info;
|
||||||
pub mod post;
|
pub mod post;
|
||||||
|
6
backend/feature/post/src/domain/entity/color.rs
Normal file
6
backend/feature/post/src/domain/entity/color.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
pub struct Color {
|
||||||
|
pub red: u8,
|
||||||
|
pub green: u8,
|
||||||
|
pub blue: u8,
|
||||||
|
pub alpha: u8,
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
|
use crate::domain::entity::color::Color;
|
||||||
|
|
||||||
pub struct Label {
|
pub struct Label {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub color: u32,
|
pub color: Color,
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use sqlx::{Pool, Postgres};
|
use sqlx::{Pool, Postgres};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
adapter::gateway::post_db_service::PostDbService,
|
adapter::gateway::{
|
||||||
|
color_mapper::ColorMapper, label_mapper::LabelMapper, post_db_service::PostDbService,
|
||||||
|
post_info_mapper::PostInfoMapper, post_mapper::PostMapper,
|
||||||
|
},
|
||||||
application::error::post_error::PostError,
|
application::error::post_error::PostError,
|
||||||
domain::entity::{label::Label, post::Post, post_info::PostInfo},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@ -27,7 +28,10 @@ impl PostDbServiceImpl {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PostDbService for PostDbServiceImpl {
|
impl PostDbService for PostDbServiceImpl {
|
||||||
async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError> {
|
async fn get_all_post_info(
|
||||||
|
&self,
|
||||||
|
is_published_only: bool,
|
||||||
|
) -> Result<Vec<PostInfoMapper>, PostError> {
|
||||||
let mut query_builder = sqlx::QueryBuilder::new(
|
let mut query_builder = sqlx::QueryBuilder::new(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
@ -62,37 +66,37 @@ impl PostDbService for PostDbServiceImpl {
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
|
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
|
||||||
|
|
||||||
let mut post_info_map = HashMap::<i32, PostInfo>::new();
|
let mut post_info_mappers_map = HashMap::<i32, PostInfoMapper>::new();
|
||||||
|
|
||||||
for record in records {
|
for record in records {
|
||||||
let post_info = post_info_map
|
let post_info = post_info_mappers_map
|
||||||
.entry(record.post_id)
|
.entry(record.post_id)
|
||||||
.or_insert_with(|| PostInfo {
|
.or_insert_with(|| PostInfoMapper {
|
||||||
id: record.post_id,
|
id: record.post_id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
description: record.description,
|
description: record.description,
|
||||||
preview_image_url: record.preview_image_url,
|
preview_image_url: record.preview_image_url,
|
||||||
labels: Vec::new(),
|
labels: Vec::new(),
|
||||||
published_time: record
|
published_time: record.published_time,
|
||||||
.published_time
|
|
||||||
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if let (Some(label_id), Some(label_name), Some(label_color)) =
|
if let (Some(label_id), Some(label_name), Some(label_color)) =
|
||||||
(record.label_id, record.label_name, record.label_color)
|
(record.label_id, record.label_name, record.label_color)
|
||||||
{
|
{
|
||||||
post_info.labels.push(Label {
|
post_info.labels.push(LabelMapper {
|
||||||
id: label_id,
|
id: label_id,
|
||||||
name: label_name,
|
name: label_name,
|
||||||
color: label_color as u32,
|
color: ColorMapper {
|
||||||
|
value: label_color as u32,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(post_info_map.into_values().collect())
|
Ok(post_info_mappers_map.into_values().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_full_post(&self, id: i32) -> Result<Post, PostError> {
|
async fn get_full_post(&self, id: i32) -> Result<PostMapper, PostError> {
|
||||||
let mut query_builder = sqlx::QueryBuilder::new(
|
let mut query_builder = sqlx::QueryBuilder::new(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
@ -129,20 +133,20 @@ impl PostDbService for PostDbServiceImpl {
|
|||||||
return Err(PostError::NotFound);
|
return Err(PostError::NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut post_map = HashMap::<i32, Post>::new();
|
let mut post_mappers_map = HashMap::<i32, PostMapper>::new();
|
||||||
|
|
||||||
for record in records {
|
for record in records {
|
||||||
let post = post_map.entry(record.post_id).or_insert_with(|| Post {
|
let post = post_mappers_map
|
||||||
|
.entry(record.post_id)
|
||||||
|
.or_insert_with(|| PostMapper {
|
||||||
id: record.post_id,
|
id: record.post_id,
|
||||||
info: PostInfo {
|
info: PostInfoMapper {
|
||||||
id: record.post_id,
|
id: record.post_id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
description: record.description,
|
description: record.description,
|
||||||
preview_image_url: record.preview_image_url,
|
preview_image_url: record.preview_image_url,
|
||||||
labels: Vec::new(),
|
labels: Vec::new(),
|
||||||
published_time: record
|
published_time: record.published_time,
|
||||||
.published_time
|
|
||||||
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
|
|
||||||
},
|
},
|
||||||
content: record.content,
|
content: record.content,
|
||||||
});
|
});
|
||||||
@ -150,15 +154,17 @@ impl PostDbService for PostDbServiceImpl {
|
|||||||
if let (Some(label_id), Some(label_name), Some(label_color)) =
|
if let (Some(label_id), Some(label_name), Some(label_color)) =
|
||||||
(record.label_id, record.label_name, record.label_color)
|
(record.label_id, record.label_name, record.label_color)
|
||||||
{
|
{
|
||||||
post.info.labels.push(Label {
|
post.info.labels.push(LabelMapper {
|
||||||
id: label_id,
|
id: label_id,
|
||||||
name: label_name,
|
name: label_name,
|
||||||
color: label_color as u32,
|
color: ColorMapper {
|
||||||
|
value: label_color as u32,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let post = post_map.into_values().next();
|
let post = post_mappers_map.into_values().next();
|
||||||
|
|
||||||
match post {
|
match post {
|
||||||
Some(v) => Ok(v),
|
Some(v) => Ok(v),
|
||||||
|
@ -35,7 +35,8 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4",
|
||||||
|
"zod": "^4.0.5"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
@ -74,6 +74,9 @@ importers:
|
|||||||
vite:
|
vite:
|
||||||
specifier: ^7.0.4
|
specifier: ^7.0.4
|
||||||
version: 7.0.5(jiti@2.4.2)(lightningcss@1.30.1)
|
version: 7.0.5(jiti@2.4.2)(lightningcss@1.30.1)
|
||||||
|
zod:
|
||||||
|
specifier: ^4.0.5
|
||||||
|
version: 4.0.5
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@ -1527,6 +1530,9 @@ packages:
|
|||||||
zimmerframe@1.1.2:
|
zimmerframe@1.1.2:
|
||||||
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
|
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
|
||||||
|
|
||||||
|
zod@4.0.5:
|
||||||
|
resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
@ -2760,3 +2766,5 @@ snapshots:
|
|||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zimmerframe@1.1.2: {}
|
zimmerframe@1.1.2: {}
|
||||||
|
|
||||||
|
zod@4.0.5: {}
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-white font-sans text-base font-normal text-gray-600;
|
@apply bg-white font-sans text-base font-normal text-gray-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre,
|
pre,
|
||||||
@ -35,6 +35,6 @@ samp {
|
|||||||
@apply font-mono;
|
@apply font-mono;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.container {
|
||||||
@apply h-[--tool-bar-height];
|
@apply mx-auto max-w-screen-xl px-4 md:px-6;
|
||||||
}
|
}
|
||||||
|
26
frontend/src/lib/common/adapter/presenter/asyncState.ts
Normal file
26
frontend/src/lib/common/adapter/presenter/asyncState.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export enum StatusType {
|
||||||
|
Idle,
|
||||||
|
Loading,
|
||||||
|
Success,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdleState {
|
||||||
|
status: StatusType.Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadingState {
|
||||||
|
status: StatusType.Loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuccessState<T> {
|
||||||
|
status: StatusType.Success;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorState {
|
||||||
|
status: StatusType.Error;
|
||||||
|
error: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AsyncState<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;
|
@ -13,6 +13,11 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="flex flex-row items-center gap-x-6">
|
<div class="flex flex-row items-center gap-x-6">
|
||||||
<NavbarAction label="首頁" link="/" isSelected={page.url.pathname === '/'} />
|
<NavbarAction label="首頁" link="/" isSelected={page.url.pathname === '/'} />
|
||||||
|
<NavbarAction
|
||||||
|
label="文章"
|
||||||
|
link="/post"
|
||||||
|
isSelected={page.url.pathname === '/post' || page.url.pathname.startsWith('/post/')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
5
frontend/src/lib/environment.ts
Normal file
5
frontend/src/lib/environment.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
export abstract class Environment {
|
||||||
|
static readonly API_BASE_URL = env.PUBLIC_BACKEND_URL ?? 'http://localhost:5173/api';
|
||||||
|
}
|
11
frontend/src/lib/home/framework/ui/HomePage.svelte
Normal file
11
frontend/src/lib/home/framework/ui/HomePage.svelte
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Motto from '$lib/home/framework/ui/Motto.svelte';
|
||||||
|
import Terminal from '$lib/home/framework/ui/Terminal.svelte';
|
||||||
|
import TitleScreen from '$lib/home/framework/ui/TitleScreen.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<TitleScreen />
|
||||||
|
<Terminal />
|
||||||
|
<Motto />
|
||||||
|
</div>
|
@ -2,9 +2,7 @@
|
|||||||
import MottoAnimatedMark from './MottoAnimatedMark.svelte';
|
import MottoAnimatedMark from './MottoAnimatedMark.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="container flex h-screen flex-col items-center justify-center gap-y-2.5 md:gap-y-8">
|
||||||
class="mx-auto flex h-screen max-w-screen-xl flex-col items-center justify-center gap-y-2.5 px-4 md:gap-y-8 md:px-6"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="flex w-[19rem] flex-col gap-y-3 text-3xl font-bold text-gray-800 md:w-[38rem] md:gap-y-4 md:text-6xl"
|
class="flex w-[19rem] flex-col gap-y-3 text-3xl font-bold text-gray-800 md:w-[38rem] md:gap-y-4 md:text-6xl"
|
||||||
>
|
>
|
||||||
|
@ -34,9 +34,9 @@
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
class="rounded-md bg-blue-600 px-1 py-0.5 text-white transition-transform delay-500 duration-1000 md:rounded-lg md:px-2.5 md:py-2 {origin} {isReady
|
class="rounded-md bg-blue-600 px-1 py-0.5 text-white transition-transform delay-500 duration-1000 md:rounded-lg md:px-2.5 md:py-2
|
||||||
? 'scale-x-100'
|
{origin}
|
||||||
: 'scale-x-0'}"
|
{isReady ? 'scale-x-100' : 'scale-x-0'}"
|
||||||
>
|
>
|
||||||
<span class="scale-x-100">{text}</span>
|
<span class="scale-x-100">{text}</span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -53,11 +53,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex max-w-screen-xl flex-col items-center justify-center gap-y-2.5 px-4 py-32 md:gap-y-8 md:px-24 md:py-32"
|
class="container flex flex-col items-center justify-center gap-y-2.5 py-32 md:gap-y-8 md:px-24 md:py-32"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
class="flex w-full flex-col gap-y-1.5 rounded-2xl border-4 border-true-gray-800 bg-true-gray-700 p-4 pb-28 font-mono font-medium text-gray-50 shadow-lg transition-opacity duration-300 md:gap-y-2.5 md:rounded-3xl md:border-8 md:p-8 md:pb-32 md:text-xl md:shadow-xl {isReady
|
class="flex w-full flex-col gap-y-1.5 rounded-2xl border-4 border-true-gray-800 bg-true-gray-700 p-4 pb-28 font-mono font-medium text-gray-50 shadow-lg transition-opacity duration-300 md:gap-y-2.5 md:rounded-3xl md:border-8 md:p-8 md:pb-32 md:text-lg md:shadow-xl {isReady
|
||||||
? 'opacity-100'
|
? 'opacity-100'
|
||||||
: 'opacity-0'}"
|
: 'opacity-0'}"
|
||||||
>
|
>
|
||||||
|
@ -2,9 +2,7 @@
|
|||||||
import TitleScreenAnimatedTags from '$lib/home/framework/ui/TitleScreenAnimatedTags.svelte';
|
import TitleScreenAnimatedTags from '$lib/home/framework/ui/TitleScreenAnimatedTags.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="container flex min-h-content-height flex-col justify-center gap-y-2.5 md:gap-y-8">
|
||||||
class="mx-auto flex min-h-content-height max-w-screen-xl flex-col justify-center gap-y-2.5 px-4 md:gap-y-8 md:px-6"
|
|
||||||
>
|
|
||||||
<h2 class="text-3xl font-bold text-gray-800 md:text-6xl">Hello 大家好!</h2>
|
<h2 class="text-3xl font-bold text-gray-800 md:text-6xl">Hello 大家好!</h2>
|
||||||
<h1 class="flex flex-row items-center gap-x-2 text-4xl font-extrabold text-gray-800 md:text-7xl">
|
<h1 class="flex flex-row items-center gap-x-2 text-4xl font-extrabold text-gray-800 md:text-7xl">
|
||||||
<span>我是</span>
|
<span>我是</span>
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
const tagsCollection = [
|
const tagsCollection = [
|
||||||
'APP',
|
'APP',
|
||||||
'C++',
|
'C++',
|
||||||
|
'Clean Architecture',
|
||||||
'Design Pattern',
|
'Design Pattern',
|
||||||
'Docker',
|
'Docker',
|
||||||
'Flutter',
|
'Flutter',
|
||||||
@ -12,7 +13,10 @@
|
|||||||
'LINER',
|
'LINER',
|
||||||
'Linux',
|
'Linux',
|
||||||
'Python',
|
'Python',
|
||||||
|
'React',
|
||||||
|
'Rust',
|
||||||
'Squid',
|
'Squid',
|
||||||
|
'Svelte',
|
||||||
'TypeScript',
|
'TypeScript',
|
||||||
'中央大學',
|
'中央大學',
|
||||||
'全端',
|
'全端',
|
||||||
@ -20,9 +24,7 @@
|
|||||||
'前端',
|
'前端',
|
||||||
'後端',
|
'後端',
|
||||||
'教學',
|
'教學',
|
||||||
'暴肝',
|
|
||||||
'知識',
|
'知識',
|
||||||
'碼農',
|
|
||||||
'科技',
|
'科技',
|
||||||
'科普',
|
'科普',
|
||||||
'程式設計',
|
'程式設計',
|
||||||
@ -64,7 +66,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`relative w-full max-w-screen-md transition-opacity duration-500 ${isTagsVisible ? 'opacity-100' : 'opacity-0'}`}
|
class="relative w-full max-w-screen-md transition-opacity duration-500
|
||||||
|
{isTagsVisible ? 'opacity-100' : 'opacity-0'}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-gradient-to-r from-transparent via-transparent via-60% to-white"
|
class="absolute inset-0 bg-gradient-to-r from-transparent via-transparent via-60% to-white"
|
||||||
|
42
frontend/src/lib/post/adapter/gateway/colorResponseDto.ts
Normal file
42
frontend/src/lib/post/adapter/gateway/colorResponseDto.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Color } from '$lib/post/domain/entity/color';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
export const ColorResponseSchema = z.object({
|
||||||
|
red: z.number().int().min(0).max(255),
|
||||||
|
green: z.number().int().min(0).max(255),
|
||||||
|
blue: z.number().int().min(0).max(255),
|
||||||
|
alpha: z.number().int().min(0).max(255)
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ColorResponseDto {
|
||||||
|
readonly red: number;
|
||||||
|
readonly green: number;
|
||||||
|
readonly blue: number;
|
||||||
|
readonly alpha: number;
|
||||||
|
|
||||||
|
private constructor(props: { red: number; green: number; blue: number; alpha: number }) {
|
||||||
|
this.red = props.red;
|
||||||
|
this.green = props.green;
|
||||||
|
this.blue = props.blue;
|
||||||
|
this.alpha = props.alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json: unknown): ColorResponseDto {
|
||||||
|
const parsedJson = ColorResponseSchema.parse(json);
|
||||||
|
return new ColorResponseDto({
|
||||||
|
red: parsedJson.red,
|
||||||
|
green: parsedJson.green,
|
||||||
|
blue: parsedJson.blue,
|
||||||
|
alpha: parsedJson.alpha
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toEntity(): Color {
|
||||||
|
return new Color({
|
||||||
|
red: this.red,
|
||||||
|
green: this.green,
|
||||||
|
blue: this.blue,
|
||||||
|
alpha: this.alpha
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
38
frontend/src/lib/post/adapter/gateway/labelResponseDto.ts
Normal file
38
frontend/src/lib/post/adapter/gateway/labelResponseDto.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { ColorResponseDto, ColorResponseSchema } from '$lib/post/adapter/gateway/colorResponseDto';
|
||||||
|
import { Label } from '$lib/post/domain/entity/label';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const LabelResponseSchema = z.object({
|
||||||
|
id: z.int32(),
|
||||||
|
name: z.string(),
|
||||||
|
color: ColorResponseSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export class LabelResponseDto {
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly color: ColorResponseDto;
|
||||||
|
|
||||||
|
private constructor(props: { id: number; name: string; color: ColorResponseDto }) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.name = props.name;
|
||||||
|
this.color = props.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json: unknown): LabelResponseDto {
|
||||||
|
const parsedJson = LabelResponseSchema.parse(json);
|
||||||
|
return new LabelResponseDto({
|
||||||
|
id: parsedJson.id,
|
||||||
|
name: parsedJson.name,
|
||||||
|
color: ColorResponseDto.fromJson(parsedJson.color)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toEntity(): Label {
|
||||||
|
return new Label({
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
color: this.color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
frontend/src/lib/post/adapter/gateway/postApiService.ts
Normal file
5
frontend/src/lib/post/adapter/gateway/postApiService.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
|
||||||
|
|
||||||
|
export interface PostApiService {
|
||||||
|
getAllPosts(): Promise<PostInfoResponseDto[]>;
|
||||||
|
}
|
60
frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts
Normal file
60
frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { LabelResponseDto, LabelResponseSchema } from '$lib/post/adapter/gateway/labelResponseDto';
|
||||||
|
import { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
export const PostInfoResponseSchema = z.object({
|
||||||
|
id: z.int32(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
preview_image_url: z.url(),
|
||||||
|
labels: z.array(LabelResponseSchema),
|
||||||
|
published_time: z.number().int()
|
||||||
|
});
|
||||||
|
|
||||||
|
export class PostInfoResponseDto {
|
||||||
|
readonly id: number;
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly previewImageUrl: URL;
|
||||||
|
readonly labels: readonly LabelResponseDto[];
|
||||||
|
readonly publishedTime: Date;
|
||||||
|
|
||||||
|
private constructor(props: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
previewImageUrl: URL;
|
||||||
|
labels: LabelResponseDto[];
|
||||||
|
publishedTime: Date;
|
||||||
|
}) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.title = props.title;
|
||||||
|
this.description = props.description;
|
||||||
|
this.previewImageUrl = props.previewImageUrl;
|
||||||
|
this.labels = props.labels;
|
||||||
|
this.publishedTime = props.publishedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json: unknown): PostInfoResponseDto {
|
||||||
|
const parsedJson = PostInfoResponseSchema.parse(json);
|
||||||
|
return new PostInfoResponseDto({
|
||||||
|
id: parsedJson.id,
|
||||||
|
title: parsedJson.title,
|
||||||
|
description: parsedJson.description,
|
||||||
|
previewImageUrl: new URL(parsedJson.preview_image_url),
|
||||||
|
labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)),
|
||||||
|
publishedTime: new Date(parsedJson.published_time / 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toEntity(): PostInfo {
|
||||||
|
return new PostInfo({
|
||||||
|
id: this.id,
|
||||||
|
title: this.title,
|
||||||
|
description: this.description,
|
||||||
|
previewImageUrl: this.previewImageUrl,
|
||||||
|
labels: this.labels.map((label) => label.toEntity()),
|
||||||
|
publishedTime: this.publishedTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
12
frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts
Normal file
12
frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
|
||||||
|
import type { PostRepository } from '$lib/post/application/repository/postRepository';
|
||||||
|
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||||
|
|
||||||
|
export class PostRepositoryImpl implements PostRepository {
|
||||||
|
constructor(private readonly postApiService: PostApiService) {}
|
||||||
|
|
||||||
|
async getAllPosts(): Promise<PostInfo[]> {
|
||||||
|
const dtos = await this.postApiService.getAllPosts();
|
||||||
|
return dtos.map((dto) => dto.toEntity());
|
||||||
|
}
|
||||||
|
}
|
114
frontend/src/lib/post/adapter/presenter/colorViewModel.ts
Normal file
114
frontend/src/lib/post/adapter/presenter/colorViewModel.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import type { Color } from '$lib/post/domain/entity/color';
|
||||||
|
|
||||||
|
export class ColorViewModel {
|
||||||
|
readonly red: number;
|
||||||
|
readonly green: number;
|
||||||
|
readonly blue: number;
|
||||||
|
readonly alpha: number;
|
||||||
|
|
||||||
|
private constructor(props: { red: number; green: number; blue: number; alpha: number }) {
|
||||||
|
this.red = props.red;
|
||||||
|
this.green = props.green;
|
||||||
|
this.blue = props.blue;
|
||||||
|
this.alpha = props.alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static fromHsl(hsl: Hsl): ColorViewModel {
|
||||||
|
const { h, s, l } = hsl;
|
||||||
|
let r, g, b;
|
||||||
|
|
||||||
|
if (s === 0) {
|
||||||
|
// achromatic (grayscale)
|
||||||
|
r = g = b = l;
|
||||||
|
} else {
|
||||||
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
|
if (t < 0) t += 1;
|
||||||
|
if (t > 1) t -= 1;
|
||||||
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1 / 2) return q;
|
||||||
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
const h_norm = h / 360;
|
||||||
|
|
||||||
|
r = hue2rgb(p, q, h_norm + 1 / 3);
|
||||||
|
g = hue2rgb(p, q, h_norm);
|
||||||
|
b = hue2rgb(p, q, h_norm - 1 / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ColorViewModel({
|
||||||
|
red: Math.round(r * 255),
|
||||||
|
green: Math.round(g * 255),
|
||||||
|
blue: Math.round(b * 255),
|
||||||
|
alpha: 255
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEntity(color: Color): ColorViewModel {
|
||||||
|
return new ColorViewModel({
|
||||||
|
red: color.red,
|
||||||
|
green: color.green,
|
||||||
|
blue: color.blue,
|
||||||
|
alpha: color.alpha
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get hex(): string {
|
||||||
|
const toHex = (value: number) => value.toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(this.red)}${toHex(this.green)}${toHex(this.blue)}${toHex(this.alpha)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toHsl(): Hsl {
|
||||||
|
const r = this.red / 255;
|
||||||
|
const g = this.green / 255;
|
||||||
|
const b = this.blue / 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
let h = 0,
|
||||||
|
s = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (max === min) {
|
||||||
|
// achromatic (grayscale)
|
||||||
|
h = s = 0;
|
||||||
|
} else {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2;
|
||||||
|
break;
|
||||||
|
case b:
|
||||||
|
h = (r - g) / d + 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
h /= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { h: h * 360, s: s, l: l };
|
||||||
|
}
|
||||||
|
|
||||||
|
lighten(amount: number): ColorViewModel {
|
||||||
|
const hsl = this.toHsl();
|
||||||
|
hsl.l += amount;
|
||||||
|
hsl.l = Math.max(0, Math.min(1, hsl.l));
|
||||||
|
return ColorViewModel.fromHsl(hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
darken(amount: number): ColorViewModel {
|
||||||
|
return this.lighten(-amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Hsl {
|
||||||
|
h: number;
|
||||||
|
s: number;
|
||||||
|
l: number;
|
||||||
|
}
|
22
frontend/src/lib/post/adapter/presenter/labelViewModel.ts
Normal file
22
frontend/src/lib/post/adapter/presenter/labelViewModel.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ColorViewModel } from '$lib/post/adapter/presenter/colorViewModel';
|
||||||
|
import type { Label } from '$lib/post/domain/entity/label';
|
||||||
|
|
||||||
|
export class LabelViewModel {
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly color: ColorViewModel;
|
||||||
|
|
||||||
|
private constructor(props: { id: number; name: string; color: ColorViewModel }) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.name = props.name;
|
||||||
|
this.color = props.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEntity(label: Label): LabelViewModel {
|
||||||
|
return new LabelViewModel({
|
||||||
|
id: label.id,
|
||||||
|
name: label.name,
|
||||||
|
color: ColorViewModel.fromEntity(label.color)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
38
frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts
Normal file
38
frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel';
|
||||||
|
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||||
|
|
||||||
|
export class PostInfoViewModel {
|
||||||
|
readonly id: number;
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly previewImageUrl: URL;
|
||||||
|
readonly labels: readonly LabelViewModel[];
|
||||||
|
readonly publishedTime: Date;
|
||||||
|
|
||||||
|
private constructor(props: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
previewImageUrl: URL;
|
||||||
|
labels: readonly LabelViewModel[];
|
||||||
|
publishedTime: Date;
|
||||||
|
}) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.title = props.title;
|
||||||
|
this.description = props.description;
|
||||||
|
this.previewImageUrl = props.previewImageUrl;
|
||||||
|
this.labels = props.labels;
|
||||||
|
this.publishedTime = props.publishedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEntity(postInfo: PostInfo): PostInfoViewModel {
|
||||||
|
return new PostInfoViewModel({
|
||||||
|
id: postInfo.id,
|
||||||
|
title: postInfo.title,
|
||||||
|
description: postInfo.description,
|
||||||
|
previewImageUrl: postInfo.previewImageUrl,
|
||||||
|
labels: postInfo.labels.map((label) => LabelViewModel.fromEntity(label)),
|
||||||
|
publishedTime: postInfo.publishedTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
44
frontend/src/lib/post/adapter/presenter/postListBloc.ts
Normal file
44
frontend/src/lib/post/adapter/presenter/postListBloc.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
|
||||||
|
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
|
||||||
|
import type { GetAllPostUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export class PostListBloc {
|
||||||
|
constructor(private readonly getAllPostsUseCase: GetAllPostUseCase) {}
|
||||||
|
|
||||||
|
private readonly state = writable<AsyncState<readonly PostInfoViewModel[]>>({
|
||||||
|
status: StatusType.Idle
|
||||||
|
});
|
||||||
|
|
||||||
|
get subscribe() {
|
||||||
|
return this.state.subscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(event: PostListEvent) {
|
||||||
|
switch (event.event) {
|
||||||
|
case PostListEventType.PostListLoadedEvent:
|
||||||
|
this.loadPosts();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPosts() {
|
||||||
|
this.state.set({ status: StatusType.Loading });
|
||||||
|
const posts = await this.getAllPostsUseCase.execute();
|
||||||
|
const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post));
|
||||||
|
this.state.set({
|
||||||
|
status: StatusType.Success,
|
||||||
|
data: postViewModels
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PostListEventType {
|
||||||
|
PostListLoadedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostListLoadedEvent {
|
||||||
|
event: PostListEventType.PostListLoadedEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostListEvent = PostListLoadedEvent;
|
@ -0,0 +1,5 @@
|
|||||||
|
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||||
|
|
||||||
|
export interface PostRepository {
|
||||||
|
getAllPosts(): Promise<PostInfo[]>;
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
import type { PostRepository } from '$lib/post/application/repository/postRepository';
|
||||||
|
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||||
|
|
||||||
|
export class GetAllPostUseCase {
|
||||||
|
constructor(private readonly postRepository: PostRepository) {}
|
||||||
|
|
||||||
|
execute(): Promise<PostInfo[]> {
|
||||||
|
return this.postRepository.getAllPosts();
|
||||||
|
}
|
||||||
|
}
|
13
frontend/src/lib/post/domain/entity/color.ts
Normal file
13
frontend/src/lib/post/domain/entity/color.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export class Color {
|
||||||
|
readonly red: number;
|
||||||
|
readonly green: number;
|
||||||
|
readonly blue: number;
|
||||||
|
readonly alpha: number;
|
||||||
|
|
||||||
|
constructor(props: { red: number; green: number; blue: number; alpha: number }) {
|
||||||
|
this.red = props.red;
|
||||||
|
this.green = props.green;
|
||||||
|
this.blue = props.blue;
|
||||||
|
this.alpha = props.alpha;
|
||||||
|
}
|
||||||
|
}
|
13
frontend/src/lib/post/domain/entity/label.ts
Normal file
13
frontend/src/lib/post/domain/entity/label.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { Color } from '$lib/post/domain/entity/color';
|
||||||
|
|
||||||
|
export class Label {
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly color: Color;
|
||||||
|
|
||||||
|
constructor(props: { id: number; name: string; color: Color }) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.name = props.name;
|
||||||
|
this.color = props.color;
|
||||||
|
}
|
||||||
|
}
|
26
frontend/src/lib/post/domain/entity/postInfo.ts
Normal file
26
frontend/src/lib/post/domain/entity/postInfo.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { Label } from '$lib/post/domain/entity/label';
|
||||||
|
|
||||||
|
export class PostInfo {
|
||||||
|
readonly id: number;
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly previewImageUrl: URL;
|
||||||
|
readonly labels: readonly Label[];
|
||||||
|
readonly publishedTime: Date;
|
||||||
|
|
||||||
|
constructor(props: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
previewImageUrl: URL;
|
||||||
|
labels: readonly Label[];
|
||||||
|
publishedTime: Date;
|
||||||
|
}) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.title = props.title;
|
||||||
|
this.description = props.description;
|
||||||
|
this.previewImageUrl = props.previewImageUrl;
|
||||||
|
this.labels = props.labels;
|
||||||
|
this.publishedTime = props.publishedTime;
|
||||||
|
}
|
||||||
|
}
|
19
frontend/src/lib/post/framework/api/postApiServiceImpl.ts
Normal file
19
frontend/src/lib/post/framework/api/postApiServiceImpl.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Environment } from '$lib/environment';
|
||||||
|
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
|
||||||
|
import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
|
||||||
|
|
||||||
|
export class PostApiServiceImpl implements PostApiService {
|
||||||
|
async getAllPosts(): Promise<PostInfoResponseDto[]> {
|
||||||
|
const url = new URL(Environment.API_BASE_URL);
|
||||||
|
url.pathname += '/post/all';
|
||||||
|
|
||||||
|
const response = await fetch(url.href);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.map(PostInfoResponseDto.fromJson);
|
||||||
|
}
|
||||||
|
}
|
22
frontend/src/lib/post/framework/ui/PostOverallPage.svelte
Normal file
22
frontend/src/lib/post/framework/ui/PostOverallPage.svelte
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
|
||||||
|
import { PostListBloc, PostListEventType } from '$lib/post/adapter/presenter/postListBloc';
|
||||||
|
import PostPreview from '$lib/post/framework/ui/PostPreview.svelte';
|
||||||
|
import { getContext, onMount } from 'svelte';
|
||||||
|
|
||||||
|
const postListBloc = getContext<PostListBloc>(PostListBloc.name);
|
||||||
|
const state = $derived($postListBloc);
|
||||||
|
|
||||||
|
onMount(() => postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="py-9 text-center text-3xl font-bold text-gray-800 md:py-20 md:text-5xl">文章</div>
|
||||||
|
{#if state.status === StatusType.Success}
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-y-8 lg:grid-cols-3">
|
||||||
|
{#each state.data as postInfo (postInfo.id)}
|
||||||
|
<PostPreview {postInfo} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
41
frontend/src/lib/post/framework/ui/PostPreview.svelte
Normal file
41
frontend/src/lib/post/framework/ui/PostPreview.svelte
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
|
||||||
|
import PostPreviewLabels from '$lib/post/framework/ui/PostPreviewLabels.svelte';
|
||||||
|
|
||||||
|
const { postInfo }: { postInfo: PostInfoViewModel } = $props();
|
||||||
|
|
||||||
|
let isImageLoading = $state(true);
|
||||||
|
let isImageError = $state(false);
|
||||||
|
|
||||||
|
function handleImageLoad() {
|
||||||
|
isImageLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageError() {
|
||||||
|
isImageLoading = false;
|
||||||
|
isImageError = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a class="flex cursor-pointer flex-col gap-y-4" href="/post/{postInfo.id}">
|
||||||
|
<div class="relative aspect-video overflow-hidden rounded-2xl bg-gray-200">
|
||||||
|
<img
|
||||||
|
class="rounded-2xl object-cover transition-opacity duration-300
|
||||||
|
{isImageLoading ? 'opacity-0' : 'opacity-100'}
|
||||||
|
{isImageError ? 'hidden' : ''}"
|
||||||
|
src={postInfo.previewImageUrl.href}
|
||||||
|
alt={postInfo.title}
|
||||||
|
onload={handleImageLoad}
|
||||||
|
onerror={handleImageError}
|
||||||
|
/>
|
||||||
|
{#if isImageLoading || isImageError}
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-gray-200"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-y-1.5">
|
||||||
|
<PostPreviewLabels labels={postInfo.labels} />
|
||||||
|
<span class="line-clamp-1 font-bold">{postInfo.title}</span>
|
||||||
|
<span class="line-clamp-3 text-justify text-sm">{postInfo.description}</span>
|
||||||
|
<span class="text-sm text-gray-500">查看更多 ⭢</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
25
frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte
Normal file
25
frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel';
|
||||||
|
|
||||||
|
const { labels }: { labels: readonly LabelViewModel[] } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-x-2 text-xs">
|
||||||
|
{#each labels.slice(0, 2) as label (label.id)}
|
||||||
|
<div
|
||||||
|
class="flex flex-row items-center gap-x-1 rounded-full px-2 py-0.5"
|
||||||
|
style="background-color: {label.color.hex};"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="size-2 rounded-full"
|
||||||
|
style="background-color: {label.color.darken(0.2).hex};"
|
||||||
|
></div>
|
||||||
|
<span>{label.name}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if labels.length > 2}
|
||||||
|
<div class="rounded-full bg-gray-200 px-2 py-0.5">
|
||||||
|
<span>+{labels.length - 2}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
@ -1,11 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import Motto from '$lib/home/framework/ui/Motto.svelte';
|
import HomePage from '$lib/home/framework/ui/HomePage.svelte';
|
||||||
import Terminal from '$lib/home/framework/ui/Terminal.svelte';
|
|
||||||
import TitleScreen from '$lib/home/framework/ui/TitleScreen.svelte';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<HomePage />
|
||||||
<TitleScreen />
|
|
||||||
<Terminal />
|
|
||||||
<Motto />
|
|
||||||
</div>
|
|
||||||
|
17
frontend/src/routes/post/+page.svelte
Normal file
17
frontend/src/routes/post/+page.svelte
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl';
|
||||||
|
import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc';
|
||||||
|
import { GetAllPostUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
|
||||||
|
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
|
||||||
|
import PostOverallPage from '$lib/post/framework/ui/PostOverallPage.svelte';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
|
||||||
|
const postApiService = new PostApiServiceImpl();
|
||||||
|
const postRepository = new PostRepositoryImpl(postApiService);
|
||||||
|
const getAllPostsUseCase = new GetAllPostUseCase(postRepository);
|
||||||
|
const postListBloc = new PostListBloc(getAllPostsUseCase);
|
||||||
|
|
||||||
|
setContext(PostListBloc.name, postListBloc);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PostOverallPage />
|
@ -8,5 +8,14 @@ export default defineConfig({
|
|||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
define: {
|
define: {
|
||||||
'App.__VERSION__': JSON.stringify(version)
|
'App.__VERSION__': JSON.stringify(version)
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user