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>
This commit is contained in:
SquidSpirit 2025-07-24 22:20:58 +08:00 committed by squid
parent 4a924c1b92
commit 3cb69f6e7c
38 changed files with 684 additions and 81 deletions

View File

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

1
backend/Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ edition.workspace = true
actix-web.workspace = true
dotenv.workspace = true
env_logger.workspace = true
percent-encoding.workspace = true
sqlx.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 password = env::var("DATABASE_PASSWORD").unwrap_or_else(|_| "".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!(
"postgres://{}:{}@{}:{}/{}",
user, password, host, port, dbname
user, encoded_password, host, port, dbname
);
let db_pool = PgPoolOptions::new()

View File

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

143
frontend/pnpm-lock.yaml generated
View File

@ -35,6 +35,12 @@ importers:
'@tailwindcss/vite':
specifier: ^4.0.0
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:
specifier: ^9.18.0
version: 9.31.0(jiti@2.4.2)
@ -47,6 +53,9 @@ importers:
globals:
specifier: ^16.0.0
version: 16.3.0
markdown-it:
specifier: ^14.1.0
version: 14.1.0
prettier:
specifier: ^3.4.2
version: 3.6.2
@ -56,6 +65,9 @@ importers:
prettier-plugin-tailwindcss:
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)
sanitize-html:
specifier: ^2.17.0
version: 2.17.0
svelte:
specifier: ^5.0.0
version: 5.36.13
@ -622,9 +634,21 @@ packages:
'@types/json-schema@7.0.15':
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':
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':
resolution: {integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -794,10 +818,27 @@ packages:
devalue@5.1.1:
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:
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
esbuild@0.25.8:
resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==}
engines: {node: '>=18'}
@ -955,6 +996,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -990,6 +1034,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
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:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
@ -1098,6 +1146,9 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
@ -1117,6 +1168,13 @@ packages:
magic-string@0.30.17:
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:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@ -1180,6 +1238,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@ -1314,6 +1375,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -1350,6 +1415,9 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
sanitize-html@2.17.0:
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
@ -1452,6 +1520,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -1950,8 +2021,21 @@ snapshots:
'@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/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)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@ -2130,11 +2214,31 @@ snapshots:
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:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.2
entities@4.5.0: {}
esbuild@0.25.8:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.8
@ -2334,6 +2438,13 @@ snapshots:
dependencies:
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@7.0.5: {}
@ -2359,6 +2470,8 @@ snapshots:
is-number@7.0.0: {}
is-plain-object@5.0.0: {}
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.8
@ -2441,6 +2554,10 @@ snapshots:
lilconfig@2.1.0: {}
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
locate-character@3.0.0: {}
locate-path@6.0.0:
@ -2457,6 +2574,17 @@ snapshots:
dependencies:
'@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: {}
micromatch@4.0.8:
@ -2511,6 +2639,8 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-srcset@1.0.2: {}
path-exists@4.0.0: {}
path-key@3.1.1: {}
@ -2569,6 +2699,8 @@ snapshots:
prettier@3.6.2: {}
punycode.js@2.3.1: {}
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
@ -2619,6 +2751,15 @@ snapshots:
dependencies:
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: {}
set-cookie-parser@2.7.1: {}
@ -2730,6 +2871,8 @@ snapshots:
typescript@5.8.3: {}
uc.micro@2.1.0: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1

View File

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

View File

@ -3,7 +3,12 @@
declare global {
namespace App {
// 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 PageState {}
// 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
}
export interface IdleState {
export interface IdleState<T> {
status: StatusType.Idle;
data?: T;
}
export interface LoadingState {
export interface LoadingState<T> {
status: StatusType.Loading;
data?: T;
}
export interface SuccessState<T> {
@ -18,9 +20,10 @@ export interface SuccessState<T> {
data: T;
}
export interface ErrorState {
export interface ErrorState<T> {
status: StatusType.Error;
data?: T;
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="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">
<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 { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export interface PostApiService {
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 { 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';
export class PostRepositoryImpl implements PostRepository {
@ -9,4 +10,9 @@ export class PostRepositoryImpl implements PostRepository {
const dtos = await this.postApiService.getAllPosts();
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 {
const toHex = (value: number) => value.toString(16).padStart(2, '0');
return `#${toHex(this.red)}${toHex(this.green)}${toHex(this.blue)}${toHex(this.alpha)}`;
@ -105,6 +109,15 @@ export class ColorViewModel {
darken(amount: number): ColorViewModel {
return this.lighten(-amount);
}
dehydrate(): DehydratedColorProps {
return {
red: this.red,
green: this.green,
blue: this.blue,
alpha: this.alpha
};
}
}
interface Hsl {
@ -112,3 +125,10 @@ interface Hsl {
s: 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';
export class LabelViewModel {
@ -19,4 +22,26 @@ export class LabelViewModel {
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';
export class PostInfoViewModel {
@ -35,4 +38,39 @@ export class PostInfoViewModel {
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 { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import type { GetAllPostUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { writable } from 'svelte/store';
import type { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { get, writable } from 'svelte/store';
export type PostListState = AsyncState<readonly PostInfoViewModel[]>;
export type PostListEvent = PostListLoadedEvent;
export class PostListBloc {
constructor(private readonly getAllPostsUseCase: GetAllPostUseCase) {}
private readonly state = writable<AsyncState<readonly PostInfoViewModel[]>>({
private readonly state = writable<PostListState>({
status: StatusType.Idle
});
constructor(
private readonly getAllPostsUseCase: GetAllPostsUseCase,
initialData?: readonly PostInfoViewModel[]
) {
this.state.set({
status: StatusType.Idle,
data: initialData
});
}
get subscribe() {
return this.state.subscribe;
}
dispatch(event: PostListEvent) {
async dispatch(event: PostListEvent): Promise<PostListState> {
switch (event.event) {
case PostListEventType.PostListLoadedEvent:
this.loadPosts();
break;
return this.loadPosts();
}
}
private async loadPosts() {
this.state.set({ status: StatusType.Loading });
private async loadPosts(): Promise<PostListState> {
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
const posts = await this.getAllPostsUseCase.execute();
const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post));
this.state.set({
const result: PostListState = {
status: StatusType.Success,
data: postViewModels
});
};
this.state.set(result);
return result;
}
}
@ -40,5 +53,3 @@ export enum PostListEventType {
export interface 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';
export interface PostRepository {
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 { PostInfo } from '$lib/post/domain/entity/postInfo';
export class GetAllPostUseCase {
export class GetAllPostsUseCase {
constructor(private readonly postRepository: PostRepository) {}
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 type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
import { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export class PostApiServiceImpl implements PostApiService {
constructor(private fetchFn: typeof fetch) {}
async getAllPosts(): Promise<PostInfoResponseDto[]> {
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) {
return [];
@ -15,4 +18,17 @@ export class PostApiServiceImpl implements PostApiService {
const json = await response.json();
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">
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';
@ -10,13 +9,11 @@
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 class="container pb-10">
<h1 class="py-9 text-center text-3xl font-bold text-gray-800 md:py-20 md:text-5xl">文章</h1>
<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>
</div>

View File

@ -17,7 +17,7 @@
}
</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">
<img
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>
{/if}
</div>
<div class="flex flex-col gap-y-1.5">
<div class="flex flex-col gap-y-2.5">
<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="text-sm text-gray-500">查看更多 ⭢</span>
<span class="text-sm text-gray-500">{postInfo.formattedPublishedTime}</span>
</div>
</a>

View File

@ -1,24 +1,16 @@
<script lang="ts">
import type { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel';
import Label from '$lib/post/framework/ui/Label.svelte';
const { labels }: { labels: readonly LabelViewModel[] } = $props();
</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)}
<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>
<Label {label} />
{/each}
{#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>
</div>
{/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">
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 { GetAllPostsUseCase } 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';
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 getAllPostsUseCase = new GetAllPostUseCase(postRepository);
const postListBloc = new PostListBloc(getAllPostsUseCase);
const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const postListBloc = new PostListBloc(getAllPostsUseCase, initialData);
setContext(PostListBloc.name, postListBloc);
</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} />