Compare commits

...

2 Commits

Author SHA1 Message Date
057087a6be NO-ISSUE docs: update document link
All checks were successful
Frontend CI / build (push) Successful in 1m6s
PR Title Check / pr-title-check (pull_request) Successful in 16s
2025-07-24 23:34:59 +08:00
3cb69f6e7c BLOG-45 Post content page (#67)
All checks were successful
Frontend CI / build (push) Successful in 1m8s
### Description

- Implement the content page
  - Parse markdown formant content to html by `markdown-it`
  - Use `sanitize-html` to prevent from XSS attack
  - Style the html with `tailwindcss-typography`
- Fix the issue when backend parse the password to url
- Fix and make the post info list from backend always sorted by id

### Package Changes

### Rust

```toml
percent-encoding = "2.3.1"
```

### Node

```json
{
  "@types/markdown-it": "^14.1.2",
  "@types/sanitize-html": "^2.16.0",
  "markdown-it": "^14.1.0",
  "sanitize-html": "^2.17.0"
}
```

### Screenshots

|Desktop|Mobile|
|-|-|
|![image.png](/attachments/0ec5718a-f804-432f-8e4b-e9dc22c080d2)|![beta.squidspirit.com_post(iPhone 12 Pro) (1).png](/attachments/b30d1b96-d4a4-4b2b-b9bd-90fd2592ab52)|

### Reference

Resolves #45

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #67
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-07-24 22:20:58 +08:00
39 changed files with 686 additions and 81 deletions

View File

@ -5,7 +5,7 @@ on:
- published - published
jobs: jobs:
frontend-deployment: deployment:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout

View File

@ -11,6 +11,8 @@ As for the more detailed development approach, I plan to use Clean Architecture
These will allow me to become more proficient in these modern development practices and leave a lot of flexibility and room for adjustments in the future. These will allow me to become more proficient in these modern development practices and leave a lot of flexibility and room for adjustments in the future.
For more information about the development process, you can check out the [project board](https://git.squidspirit.com/squid/blog/projects). As for the details of the architecture and convention, you can find them in the [wiki](https://git.squidspirit.com/squid/blog/wiki).
## License ## License
This project uses a combination of the [MIT License and a custom license](./LICENSE.md). Based on the MIT License, anyone is permitted to use the code. However, before deploying the code, they must first replace any information belonging to "me" or any content that could identify "me," such as logos, names, and "about me" sections. This project uses a combination of the [MIT License and a custom license](./LICENSE.md). Based on the MIT License, anyone is permitted to use the code. However, before deploying the code, they must first replace any information belonging to "me" or any content that could identify "me," such as logos, names, and "about me" sections.

1
backend/Cargo.lock generated
View File

@ -1789,6 +1789,7 @@ dependencies = [
"actix-web", "actix-web",
"dotenv", "dotenv",
"env_logger", "env_logger",
"percent-encoding",
"post", "post",
"sqlx", "sqlx",
] ]

View File

@ -14,6 +14,7 @@ dotenv = "0.15.0"
env_logger = "0.11.8" env_logger = "0.11.8"
futures = "0.3.31" futures = "0.3.31"
log = "0.4.27" log = "0.4.27"
percent-encoding = "2.3.1"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
sqlx = { version = "0.8.5", features = [ sqlx = { version = "0.8.5", features = [
"chrono", "chrono",

View File

@ -68,21 +68,23 @@ impl PostDbService for PostDbServiceImpl {
let mut post_info_mappers_map = HashMap::<i32, PostInfoMapper>::new(); let mut post_info_mappers_map = HashMap::<i32, PostInfoMapper>::new();
for record in records { for record in &records {
let post_info = post_info_mappers_map let post_info = post_info_mappers_map
.entry(record.post_id) .entry(record.post_id)
.or_insert_with(|| PostInfoMapper { .or_insert_with(|| PostInfoMapper {
id: record.post_id, id: record.post_id,
title: record.title, title: record.title.clone(),
description: record.description, description: record.description.clone(),
preview_image_url: record.preview_image_url, preview_image_url: record.preview_image_url.clone(),
labels: Vec::new(), labels: Vec::new(),
published_time: record.published_time, published_time: record.published_time,
}); });
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.clone(),
record.label_color,
) {
post_info.labels.push(LabelMapper { post_info.labels.push(LabelMapper {
id: label_id, id: label_id,
name: label_name, name: label_name,
@ -93,7 +95,14 @@ impl PostDbService for PostDbServiceImpl {
} }
} }
Ok(post_info_mappers_map.into_values().collect()) let mut ordered_posts = Vec::new();
for record in &records {
if let Some(post_info) = post_info_mappers_map.remove(&record.post_id) {
ordered_posts.push(post_info);
}
}
Ok(ordered_posts)
} }
async fn get_full_post(&self, id: i32) -> Result<PostMapper, PostError> { async fn get_full_post(&self, id: i32) -> Result<PostMapper, PostError> {
@ -135,25 +144,27 @@ impl PostDbService for PostDbServiceImpl {
let mut post_mappers_map = HashMap::<i32, PostMapper>::new(); let mut post_mappers_map = HashMap::<i32, PostMapper>::new();
for record in records { for record in &records {
let post = post_mappers_map let post = post_mappers_map
.entry(record.post_id) .entry(record.post_id)
.or_insert_with(|| PostMapper { .or_insert_with(|| PostMapper {
id: record.post_id, id: record.post_id,
info: PostInfoMapper { info: PostInfoMapper {
id: record.post_id, id: record.post_id,
title: record.title, title: record.title.clone(),
description: record.description, description: record.description.clone(),
preview_image_url: record.preview_image_url, preview_image_url: record.preview_image_url.clone(),
labels: Vec::new(), labels: Vec::new(),
published_time: record.published_time, published_time: record.published_time,
}, },
content: record.content, content: record.content.clone(),
}); });
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.clone(),
record.label_color,
) {
post.info.labels.push(LabelMapper { post.info.labels.push(LabelMapper {
id: label_id, id: label_id,
name: label_name, name: label_name,

View File

@ -7,6 +7,7 @@ edition.workspace = true
actix-web.workspace = true actix-web.workspace = true
dotenv.workspace = true dotenv.workspace = true
env_logger.workspace = true env_logger.workspace = true
percent-encoding.workspace = true
sqlx.workspace = true sqlx.workspace = true
post.workspace = true post.workspace = true

View File

@ -34,9 +34,13 @@ async fn init_database() -> Pool<Postgres> {
let user = env::var("DATABASE_USER").unwrap_or_else(|_| "postgres".to_string()); let user = env::var("DATABASE_USER").unwrap_or_else(|_| "postgres".to_string());
let password = env::var("DATABASE_PASSWORD").unwrap_or_else(|_| "".to_string()); let password = env::var("DATABASE_PASSWORD").unwrap_or_else(|_| "".to_string());
let dbname = env::var("DATABASE_NAME").unwrap_or_else(|_| "postgres".to_string()); let dbname = env::var("DATABASE_NAME").unwrap_or_else(|_| "postgres".to_string());
let encoded_password =
percent_encoding::utf8_percent_encode(&password, percent_encoding::NON_ALPHANUMERIC)
.to_string();
let database_url = format!( let database_url = format!(
"postgres://{}:{}@{}:{}/{}", "postgres://{}:{}@{}:{}/{}",
user, password, host, port, dbname user, encoded_password, host, port, dbname
); );
let db_pool = PgPoolOptions::new() let db_pool = PgPoolOptions::new()

View File

@ -23,13 +23,17 @@
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"markdown-it": "^14.1.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"sanitize-html": "^2.17.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",

143
frontend/pnpm-lock.yaml generated
View File

@ -35,6 +35,12 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.1.11(vite@7.0.5(jiti@2.4.2)(lightningcss@1.30.1)) version: 4.1.11(vite@7.0.5(jiti@2.4.2)(lightningcss@1.30.1))
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
'@types/sanitize-html':
specifier: ^2.16.0
version: 2.16.0
eslint: eslint:
specifier: ^9.18.0 specifier: ^9.18.0
version: 9.31.0(jiti@2.4.2) version: 9.31.0(jiti@2.4.2)
@ -47,6 +53,9 @@ importers:
globals: globals:
specifier: ^16.0.0 specifier: ^16.0.0
version: 16.3.0 version: 16.3.0
markdown-it:
specifier: ^14.1.0
version: 14.1.0
prettier: prettier:
specifier: ^3.4.2 specifier: ^3.4.2
version: 3.6.2 version: 3.6.2
@ -56,6 +65,9 @@ importers:
prettier-plugin-tailwindcss: prettier-plugin-tailwindcss:
specifier: ^0.6.11 specifier: ^0.6.11
version: 0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.36.13))(prettier@3.6.2) version: 0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.36.13))(prettier@3.6.2)
sanitize-html:
specifier: ^2.17.0
version: 2.17.0
svelte: svelte:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.36.13 version: 5.36.13
@ -622,9 +634,21 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/sanitize-html@2.16.0':
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
'@typescript-eslint/eslint-plugin@8.38.0': '@typescript-eslint/eslint-plugin@8.38.0':
resolution: {integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==} resolution: {integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -794,10 +818,27 @@ packages:
devalue@5.1.1: devalue@5.1.1:
resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
enhanced-resolve@5.18.2: enhanced-resolve@5.18.2:
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
esbuild@0.25.8: esbuild@0.25.8:
resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==} resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -955,6 +996,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@ -990,6 +1034,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-reference@1.2.1: is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
@ -1098,6 +1146,9 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
locate-character@3.0.0: locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
@ -1117,6 +1168,13 @@ packages:
magic-string@0.30.17: magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
merge2@1.4.1: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -1180,6 +1238,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1314,6 +1375,10 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1350,6 +1415,9 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
sanitize-html@2.17.0:
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
semver@7.7.2: semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1452,6 +1520,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -1950,8 +2021,21 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/sanitize-html@2.16.0':
dependencies:
htmlparser2: 8.0.2
'@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)': '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
@ -2130,11 +2214,31 @@ snapshots:
devalue@5.1.1: {} devalue@5.1.1: {}
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
enhanced-resolve@5.18.2: enhanced-resolve@5.18.2:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.2.2 tapable: 2.2.2
entities@4.5.0: {}
esbuild@0.25.8: esbuild@0.25.8:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.25.8 '@esbuild/aix-ppc64': 0.25.8
@ -2334,6 +2438,13 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
@ -2359,6 +2470,8 @@ snapshots:
is-number@7.0.0: {} is-number@7.0.0: {}
is-plain-object@5.0.0: {}
is-reference@1.2.1: is-reference@1.2.1:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@ -2441,6 +2554,10 @@ snapshots:
lilconfig@2.1.0: {} lilconfig@2.1.0: {}
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
locate-character@3.0.0: {} locate-character@3.0.0: {}
locate-path@6.0.0: locate-path@6.0.0:
@ -2457,6 +2574,17 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.4 '@jridgewell/sourcemap-codec': 1.5.4
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
mdurl@2.0.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
micromatch@4.0.8: micromatch@4.0.8:
@ -2511,6 +2639,8 @@ snapshots:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
parse-srcset@1.0.2: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@ -2569,6 +2699,8 @@ snapshots:
prettier@3.6.2: {} prettier@3.6.2: {}
punycode.js@2.3.1: {}
punycode@2.3.1: {} punycode@2.3.1: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
@ -2619,6 +2751,15 @@ snapshots:
dependencies: dependencies:
mri: 1.2.0 mri: 1.2.0
sanitize-html@2.17.0:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.5.6
semver@7.7.2: {} semver@7.7.2: {}
set-cookie-parser@2.7.1: {} set-cookie-parser@2.7.1: {}
@ -2730,6 +2871,8 @@ snapshots:
typescript@5.8.3: {} typescript@5.8.3: {}
uc.micro@2.1.0: {}
uri-js@4.4.1: uri-js@4.4.1:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1

View File

@ -1,4 +1,5 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin '@tailwindcss/typography';
@font-face { @font-face {
font-family: 'HackNerdMono'; font-family: 'HackNerdMono';
@ -25,14 +26,7 @@
} }
body { body {
@apply bg-white font-sans text-base font-normal text-gray-800; @apply bg-white font-sans text-base font-normal text-gray-700;
}
pre,
code,
kbd,
samp {
@apply font-mono;
} }
.container { .container {

View File

@ -3,7 +3,12 @@
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {}
interface Locals {
postListBloc: import('$lib/post/adapter/presenter/postListBloc').PostListBloc;
postBloc: import('$lib/post/adapter/presenter/postBloc').PostBloc;
}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

View File

@ -0,0 +1,19 @@
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl';
import { PostBloc } from '$lib/post/adapter/presenter/postBloc';
import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc';
import { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = ({ event, resolve }) => {
const postApiService = new PostApiServiceImpl(event.fetch);
const postRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const getPostUseCase = new GetPostUseCase(postRepository);
event.locals.postListBloc = new PostListBloc(getAllPostsUseCase);
event.locals.postBloc = new PostBloc(getPostUseCase);
return resolve(event);
};

View File

@ -5,12 +5,14 @@ export enum StatusType {
Error Error
} }
export interface IdleState { export interface IdleState<T> {
status: StatusType.Idle; status: StatusType.Idle;
data?: T;
} }
export interface LoadingState { export interface LoadingState<T> {
status: StatusType.Loading; status: StatusType.Loading;
data?: T;
} }
export interface SuccessState<T> { export interface SuccessState<T> {
@ -18,9 +20,10 @@ export interface SuccessState<T> {
data: T; data: T;
} }
export interface ErrorState { export interface ErrorState<T> {
status: StatusType.Error; status: StatusType.Error;
data?: T;
error: Error; error: Error;
} }
export type AsyncState<T> = IdleState | LoadingState | SuccessState<T> | ErrorState; export type AsyncState<T> = IdleState<T> | LoadingState<T> | SuccessState<T> | ErrorState<T>;

View File

@ -1,6 +1,6 @@
<div class="border-t border-gray-300"> <div class="border-t border-gray-300">
<div <div
class="mx-auto flex max-w-screen-xl flex-col items-center justify-center gap-4 px-4 py-12 md:flex-row md:px-6" class="mx-auto flex max-w-screen-xl flex-col items-center justify-center gap-4 px-4 py-12 text-gray-600 md:flex-row md:px-6"
> >
<div class="flex flex-row items-center justify-center gap-x-4"> <div class="flex flex-row items-center justify-center gap-x-4">
<a href="https://www.youtube.com/@squidspirit16" target="_blank" aria-label="YouTube Channel"> <a href="https://www.youtube.com/@squidspirit16" target="_blank" aria-label="YouTube Channel">

View File

@ -0,0 +1,11 @@
<script lang="ts">
/* eslint-disable svelte/no-at-html-tags */
import sanitizeHtml from 'sanitize-html';
const { html }: { html: string } = $props();
const sanitizedHtml = $derived(sanitizeHtml(html));
</script>
{@html sanitizedHtml}

View File

@ -1,5 +1,7 @@
import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
import type { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export interface PostApiService { export interface PostApiService {
getAllPosts(): Promise<PostInfoResponseDto[]>; getAllPosts(): Promise<PostInfoResponseDto[]>;
getPost(id: number): Promise<PostResponseDto | null>;
} }

View File

@ -1,5 +1,6 @@
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/repository/postRepository'; import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class PostRepositoryImpl implements PostRepository { export class PostRepositoryImpl implements PostRepository {
@ -9,4 +10,9 @@ export class PostRepositoryImpl implements PostRepository {
const dtos = await this.postApiService.getAllPosts(); const dtos = await this.postApiService.getAllPosts();
return dtos.map((dto) => dto.toEntity()); return dtos.map((dto) => dto.toEntity());
} }
async getPost(id: number): Promise<Post | null> {
const dto = await this.postApiService.getPost(id);
return dto?.toEntity() ?? null;
}
} }

View File

@ -0,0 +1,41 @@
import {
PostInfoResponseDto,
PostInfoResponseSchema
} from '$lib/post/adapter/gateway/postInfoResponseDto';
import { Post } from '$lib/post/domain/entity/post';
import z from 'zod';
export const PostResponseSchema = z.object({
id: z.int32(),
info: PostInfoResponseSchema,
content: z.string()
});
export class PostResponseDto {
readonly id: number;
readonly info: PostInfoResponseDto;
readonly content: string;
private constructor(props: { id: number; info: PostInfoResponseDto; content: string }) {
this.id = props.id;
this.info = props.info;
this.content = props.content;
}
static fromJson(json: unknown): PostResponseDto {
const parsedJson = PostResponseSchema.parse(json);
return new PostResponseDto({
id: parsedJson.id,
info: PostInfoResponseDto.fromJson(parsedJson.info),
content: parsedJson.content
});
}
toEntity(): Post {
return new Post({
id: this.id,
info: this.info.toEntity(),
content: this.content
});
}
}

View File

@ -56,6 +56,10 @@ export class ColorViewModel {
}); });
} }
static rehydrate(props: DehydratedColorProps): ColorViewModel {
return new ColorViewModel(props);
}
get hex(): string { get hex(): string {
const toHex = (value: number) => value.toString(16).padStart(2, '0'); const toHex = (value: number) => value.toString(16).padStart(2, '0');
return `#${toHex(this.red)}${toHex(this.green)}${toHex(this.blue)}${toHex(this.alpha)}`; return `#${toHex(this.red)}${toHex(this.green)}${toHex(this.blue)}${toHex(this.alpha)}`;
@ -105,6 +109,15 @@ export class ColorViewModel {
darken(amount: number): ColorViewModel { darken(amount: number): ColorViewModel {
return this.lighten(-amount); return this.lighten(-amount);
} }
dehydrate(): DehydratedColorProps {
return {
red: this.red,
green: this.green,
blue: this.blue,
alpha: this.alpha
};
}
} }
interface Hsl { interface Hsl {
@ -112,3 +125,10 @@ interface Hsl {
s: number; s: number;
l: number; l: number;
} }
export interface DehydratedColorProps {
red: number;
green: number;
blue: number;
alpha: number;
}

View File

@ -1,4 +1,7 @@
import { ColorViewModel } from '$lib/post/adapter/presenter/colorViewModel'; import {
ColorViewModel,
type DehydratedColorProps
} from '$lib/post/adapter/presenter/colorViewModel';
import type { Label } from '$lib/post/domain/entity/label'; import type { Label } from '$lib/post/domain/entity/label';
export class LabelViewModel { export class LabelViewModel {
@ -19,4 +22,26 @@ export class LabelViewModel {
color: ColorViewModel.fromEntity(label.color) color: ColorViewModel.fromEntity(label.color)
}); });
} }
static rehydrate(props: DehydratedLabelProps): LabelViewModel {
return new LabelViewModel({
id: props.id,
name: props.name,
color: ColorViewModel.rehydrate(props.color)
});
}
dehydrate(): DehydratedLabelProps {
return {
id: this.id,
name: this.name,
color: this.color.dehydrate()
};
}
}
export interface DehydratedLabelProps {
id: number;
name: string;
color: DehydratedColorProps;
} }

View File

@ -0,0 +1,62 @@
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
import { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
import type { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { get, writable } from 'svelte/store';
export type PostState = AsyncState<PostViewModel>;
export type PostEvent = PostLoadedEvent;
export class PostBloc {
private readonly state = writable<PostState>({
status: StatusType.Idle
});
constructor(
private readonly getPostUseCase: GetPostUseCase,
initialData?: PostViewModel
) {
this.state.set({
status: StatusType.Idle,
data: initialData
});
}
get subscribe() {
return this.state.subscribe;
}
async dispatch(event: PostEvent): Promise<PostState> {
switch (event.event) {
case PostEventType.PostLoadedEvent:
return this.loadPost(event.id);
}
}
private async loadPost(id: number): Promise<PostState> {
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
const post = await this.getPostUseCase.execute(id);
if (!post) {
this.state.set({ status: StatusType.Error, error: new Error('Post not found') });
return get(this.state);
}
const postViewModel = PostViewModel.fromEntity(post);
const result: PostState = {
status: StatusType.Success,
data: postViewModel
};
this.state.set(result);
return result;
}
}
export enum PostEventType {
PostLoadedEvent
}
export interface PostLoadedEvent {
event: PostEventType.PostLoadedEvent;
id: number;
}

View File

@ -1,4 +1,7 @@
import { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel'; import {
LabelViewModel,
type DehydratedLabelProps
} from '$lib/post/adapter/presenter/labelViewModel';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class PostInfoViewModel { export class PostInfoViewModel {
@ -35,4 +38,39 @@ export class PostInfoViewModel {
publishedTime: postInfo.publishedTime publishedTime: postInfo.publishedTime
}); });
} }
static rehydrate(props: DehydratedPostInfoProps): PostInfoViewModel {
return new PostInfoViewModel({
id: props.id,
title: props.title,
description: props.description,
previewImageUrl: new URL(props.previewImageUrl),
labels: props.labels.map((label) => LabelViewModel.rehydrate(label)),
publishedTime: new Date(props.publishedTime)
});
}
get formattedPublishedTime(): string {
return this.publishedTime.toISOString().slice(0, 10);
}
dehydrate(): DehydratedPostInfoProps {
return {
id: this.id,
title: this.title,
description: this.description,
previewImageUrl: this.previewImageUrl.href,
labels: this.labels.map((label) => label.dehydrate()),
publishedTime: this.publishedTime.getTime()
};
}
}
export interface DehydratedPostInfoProps {
id: number;
title: string;
description: string;
previewImageUrl: string;
labels: DehydratedLabelProps[];
publishedTime: number;
} }

View File

@ -1,35 +1,48 @@
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState'; import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel'; import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import type { GetAllPostUseCase } from '$lib/post/application/useCase/getAllPostsUseCase'; import type { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
export type PostListState = AsyncState<readonly PostInfoViewModel[]>;
export type PostListEvent = PostListLoadedEvent;
export class PostListBloc { export class PostListBloc {
constructor(private readonly getAllPostsUseCase: GetAllPostUseCase) {} private readonly state = writable<PostListState>({
private readonly state = writable<AsyncState<readonly PostInfoViewModel[]>>({
status: StatusType.Idle status: StatusType.Idle
}); });
constructor(
private readonly getAllPostsUseCase: GetAllPostsUseCase,
initialData?: readonly PostInfoViewModel[]
) {
this.state.set({
status: StatusType.Idle,
data: initialData
});
}
get subscribe() { get subscribe() {
return this.state.subscribe; return this.state.subscribe;
} }
dispatch(event: PostListEvent) { async dispatch(event: PostListEvent): Promise<PostListState> {
switch (event.event) { switch (event.event) {
case PostListEventType.PostListLoadedEvent: case PostListEventType.PostListLoadedEvent:
this.loadPosts(); return this.loadPosts();
break;
} }
} }
private async loadPosts() { private async loadPosts(): Promise<PostListState> {
this.state.set({ status: StatusType.Loading }); this.state.set({ status: StatusType.Loading, data: get(this.state).data });
const posts = await this.getAllPostsUseCase.execute(); const posts = await this.getAllPostsUseCase.execute();
const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post)); const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post));
this.state.set({ const result: PostListState = {
status: StatusType.Success, status: StatusType.Success,
data: postViewModels data: postViewModels
}); };
this.state.set(result);
return result;
} }
} }
@ -40,5 +53,3 @@ export enum PostListEventType {
export interface PostListLoadedEvent { export interface PostListLoadedEvent {
event: PostListEventType.PostListLoadedEvent; event: PostListEventType.PostListLoadedEvent;
} }
export type PostListEvent = PostListLoadedEvent;

View File

@ -0,0 +1,47 @@
import {
PostInfoViewModel,
type DehydratedPostInfoProps
} from '$lib/post/adapter/presenter/postInfoViewModel';
import type { Post } from '$lib/post/domain/entity/post';
export class PostViewModel {
id: number;
info: PostInfoViewModel;
content: string;
private constructor(props: { id: number; info: PostInfoViewModel; content: string }) {
this.id = props.id;
this.info = props.info;
this.content = props.content;
}
static fromEntity(post: Post): PostViewModel {
return new PostViewModel({
id: post.id,
info: PostInfoViewModel.fromEntity(post.info),
content: post.content
});
}
static rehydrate(props: DehydratedPostProps): PostViewModel {
return new PostViewModel({
id: props.id,
info: PostInfoViewModel.rehydrate(props.info),
content: props.content
});
}
dehydrate(): DehydratedPostProps {
return {
id: this.id,
info: this.info.dehydrate(),
content: this.content
};
}
}
export interface DehydratedPostProps {
id: number;
info: DehydratedPostInfoProps;
content: string;
}

View File

@ -1,5 +1,7 @@
import type { Post } from '$lib/post/domain/entity/post';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export interface PostRepository { export interface PostRepository {
getAllPosts(): Promise<PostInfo[]>; getAllPosts(): Promise<PostInfo[]>;
getPost(id: number): Promise<Post | null>;
} }

View File

@ -1,7 +1,7 @@
import type { PostRepository } from '$lib/post/application/repository/postRepository'; import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class GetAllPostUseCase { export class GetAllPostsUseCase {
constructor(private readonly postRepository: PostRepository) {} constructor(private readonly postRepository: PostRepository) {}
execute(): Promise<PostInfo[]> { execute(): Promise<PostInfo[]> {

View File

@ -0,0 +1,10 @@
import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
export class GetPostUseCase {
constructor(private readonly postRepository: PostRepository) {}
execute(id: number): Promise<Post | null> {
return this.postRepository.getPost(id);
}
}

View File

@ -0,0 +1,13 @@
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class Post {
id: number;
info: PostInfo;
content: string;
constructor(props: { id: number; info: PostInfo; content: string }) {
this.id = props.id;
this.info = props.info;
this.content = props.content;
}
}

View File

@ -1,12 +1,15 @@
import { Environment } from '$lib/environment'; import { Environment } from '$lib/environment';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
import { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export class PostApiServiceImpl implements PostApiService { export class PostApiServiceImpl implements PostApiService {
constructor(private fetchFn: typeof fetch) {}
async getAllPosts(): Promise<PostInfoResponseDto[]> { async getAllPosts(): Promise<PostInfoResponseDto[]> {
const url = new URL('post/all', Environment.API_BASE_URL); const url = new URL('post/all', Environment.API_BASE_URL);
const response = await fetch(url.href); const response = await this.fetchFn(url.href);
if (!response.ok) { if (!response.ok) {
return []; return [];
@ -15,4 +18,17 @@ export class PostApiServiceImpl implements PostApiService {
const json = await response.json(); const json = await response.json();
return json.map(PostInfoResponseDto.fromJson); return json.map(PostInfoResponseDto.fromJson);
} }
async getPost(id: number): Promise<PostResponseDto | null> {
const url = new URL(`post/${id}`, Environment.API_BASE_URL);
const response = await this.fetchFn(url.href);
if (!response.ok) {
return null;
}
const json = await response.json();
return PostResponseDto.fromJson(json);
}
} }

View File

@ -0,0 +1,13 @@
<script lang="ts">
import type { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel';
const { label }: { label: LabelViewModel } = $props();
</script>
<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 class="text-xs">{label.name}</span>
</div>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import type { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import Label from '$lib/post/framework/ui/Label.svelte';
const { postInfo }: { postInfo: PostInfoViewModel } = $props();
</script>
<div class="flex flex-col pt-9 md:pt-20">
<div class="mb-4 flex flex-row gap-2">
{#each postInfo.labels as label (label.id)}
<Label {label} />
{/each}
</div>
<h1 class="text-3xl font-bold text-gray-800 sm:text-4xl md:text-5xl">{postInfo.title}</h1>
<p class="max-w-3xl">{postInfo.description}</p>
<span class="text-gray-500">{postInfo.formattedPublishedTime}</span>
</div>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { PostBloc, PostEventType } from '$lib/post/adapter/presenter/postBloc';
import PostContentHeader from '$lib/post/framework/ui/PostContentHeader.svelte';
import { getContext, onMount } from 'svelte';
import markdownit from 'markdown-it';
import SafeHtml from '$lib/common/framework/ui/SafeHtml.svelte';
const { id }: { id: number } = $props();
const postBloc = getContext<PostBloc>(PostBloc.name);
const state = $derived($postBloc);
const md = markdownit();
const parsedContent = $derived(state.data?.content ? md.render(state.data.content) : '');
onMount(() => postBloc.dispatch({ event: PostEventType.PostLoadedEvent, id: id }));
</script>
<article class="container prose pb-10">
{#if state.data}
<PostContentHeader postInfo={state.data.info} />
<div class="max-w-3xl">
<hr />
<SafeHtml html={parsedContent} />
</div>
{/if}
</article>

View File

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
import { PostListBloc, PostListEventType } from '$lib/post/adapter/presenter/postListBloc'; import { PostListBloc, PostListEventType } from '$lib/post/adapter/presenter/postListBloc';
import PostPreview from '$lib/post/framework/ui/PostPreview.svelte'; import PostPreview from '$lib/post/framework/ui/PostPreview.svelte';
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
@ -10,13 +9,11 @@
onMount(() => postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent })); onMount(() => postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent }));
</script> </script>
<div class="container"> <div class="container pb-10">
<div class="py-9 text-center text-3xl font-bold text-gray-800 md:py-20 md:text-5xl">文章</div> <h1 class="py-9 text-center text-3xl font-bold text-gray-800 md:py-20 md:text-5xl">文章</h1>
{#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">
<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)}
{#each state.data as postInfo (postInfo.id)} <PostPreview {postInfo} />
<PostPreview {postInfo} /> {/each}
{/each} </div>
</div>
{/if}
</div> </div>

View File

@ -17,7 +17,7 @@
} }
</script> </script>
<a class="flex cursor-pointer flex-col gap-y-4" href="/post/{postInfo.id}"> <a class="flex cursor-pointer flex-col gap-y-6" href="/post/{postInfo.id}">
<div class="relative aspect-video overflow-hidden rounded-2xl bg-gray-200"> <div class="relative aspect-video overflow-hidden rounded-2xl bg-gray-200">
<img <img
class="rounded-2xl object-cover transition-opacity duration-300 class="rounded-2xl object-cover transition-opacity duration-300
@ -32,10 +32,10 @@
<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>
{/if} {/if}
</div> </div>
<div class="flex flex-col gap-y-1.5"> <div class="flex flex-col gap-y-2.5">
<PostPreviewLabels labels={postInfo.labels} /> <PostPreviewLabels labels={postInfo.labels} />
<span class="line-clamp-1 font-bold">{postInfo.title}</span> <span class="line-clamp-1 text-lg font-bold">{postInfo.title}</span>
<span class="line-clamp-3 text-justify text-sm">{postInfo.description}</span> <span class="line-clamp-3 text-justify text-sm">{postInfo.description}</span>
<span class="text-sm text-gray-500">查看更多 ⭢</span> <span class="text-sm text-gray-500">{postInfo.formattedPublishedTime}</span>
</div> </div>
</a> </a>

View File

@ -1,24 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel'; import type { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel';
import Label from '$lib/post/framework/ui/Label.svelte';
const { labels }: { labels: readonly LabelViewModel[] } = $props(); const { labels }: { labels: readonly LabelViewModel[] } = $props();
</script> </script>
<div class="flex flex-row gap-x-2 text-xs"> <div class="flex flex-row gap-x-2">
{#each labels.slice(0, 2) as label (label.id)} {#each labels.slice(0, 2) as label (label.id)}
<div <Label {label} />
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} {/each}
{#if labels.length > 2} {#if labels.length > 2}
<div class="rounded-full bg-gray-200 px-2 py-0.5"> <div class="rounded-full bg-gray-200 px-2 py-0.5 text-xs">
<span>+{labels.length - 2}</span> <span>+{labels.length - 2}</span>
</div> </div>
{/if} {/if}

View File

@ -0,0 +1,12 @@
import { PostListEventType } from '$lib/post/adapter/presenter/postListBloc';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { postListBloc } = locals;
const state = await postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent });
return {
dehydratedData: state.data?.map((post) => post.dehydrate())
};
};

View File

@ -1,15 +1,21 @@
<script lang="ts"> <script lang="ts">
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl'; import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl';
import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc'; import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc';
import { GetAllPostUseCase } from '$lib/post/application/useCase/getAllPostsUseCase'; import { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl'; import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
import PostOverallPage from '$lib/post/framework/ui/PostOverallPage.svelte';
import { setContext } from 'svelte'; import { setContext } from 'svelte';
import type { PageProps } from './$types';
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import PostOverallPage from '$lib/post/framework/ui/PostOverallPage.svelte';
const postApiService = new PostApiServiceImpl(); let { data }: PageProps = $props();
const initialData = data.dehydratedData?.map((post) => PostInfoViewModel.rehydrate(post));
const postApiService = new PostApiServiceImpl(fetch);
const postRepository = new PostRepositoryImpl(postApiService); const postRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostUseCase(postRepository); const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const postListBloc = new PostListBloc(getAllPostsUseCase); const postListBloc = new PostListBloc(getAllPostsUseCase, initialData);
setContext(PostListBloc.name, postListBloc); setContext(PostListBloc.name, postListBloc);
</script> </script>

View File

@ -0,0 +1,24 @@
import { PostEventType } from '$lib/post/adapter/presenter/postBloc';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
const { postBloc } = locals;
const id = parseInt(params.id, 10);
if (isNaN(id) || id <= 0) {
error(400, { message: 'Invalid post ID' });
}
const state = await postBloc.dispatch({
event: PostEventType.PostLoadedEvent,
id: id
});
if (!state.data) {
error(404, { message: 'Post not found' });
}
return {
dehydratedData: state.data.dehydrate()
};
};

View File

@ -0,0 +1,25 @@
<script lang="ts">
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl';
import { PostBloc } from '$lib/post/adapter/presenter/postBloc';
import { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
import { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
import { setContext } from 'svelte';
import type { PageProps } from './$types';
import PostContentPage from '$lib/post/framework/ui/PostContentPage.svelte';
const { data, params }: PageProps = $props();
const id = parseInt(params.id, 10);
const initialData = PostViewModel.rehydrate(data.dehydratedData!);
const postApiService = new PostApiServiceImpl(fetch);
const postRepository = new PostRepositoryImpl(postApiService);
const getPostUseCase = new GetPostUseCase(postRepository);
const postBloc = new PostBloc(getPostUseCase, initialData);
setContext(PostBloc.name, postBloc);
</script>
<PostContentPage {id} />