Compare commits
2 Commits
d4ad3ed990
...
057087a6be
Author | SHA1 | Date | |
---|---|---|---|
057087a6be | |||
3cb69f6e7c |
@ -5,7 +5,7 @@ on:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
frontend-deployment:
|
||||
deployment:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
1
backend/Cargo.lock
generated
1
backend/Cargo.lock
generated
@ -1789,6 +1789,7 @@ dependencies = [
|
||||
"actix-web",
|
||||
"dotenv",
|
||||
"env_logger",
|
||||
"percent-encoding",
|
||||
"post",
|
||||
"sqlx",
|
||||
]
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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
143
frontend/pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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 {
|
||||
|
7
frontend/src/app.d.ts
vendored
7
frontend/src/app.d.ts
vendored
@ -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 {}
|
||||
|
19
frontend/src/hooks.server.ts
Normal file
19
frontend/src/hooks.server.ts
Normal 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);
|
||||
};
|
@ -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>;
|
||||
|
@ -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">
|
||||
|
11
frontend/src/lib/common/framework/ui/SafeHtml.svelte
Normal file
11
frontend/src/lib/common/framework/ui/SafeHtml.svelte
Normal 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}
|
@ -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>;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
41
frontend/src/lib/post/adapter/gateway/postResponseDto.ts
Normal file
41
frontend/src/lib/post/adapter/gateway/postResponseDto.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
62
frontend/src/lib/post/adapter/presenter/postBloc.ts
Normal file
62
frontend/src/lib/post/adapter/presenter/postBloc.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
47
frontend/src/lib/post/adapter/presenter/postViewModel.ts
Normal file
47
frontend/src/lib/post/adapter/presenter/postViewModel.ts
Normal 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;
|
||||
}
|
@ -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>;
|
||||
}
|
||||
|
@ -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[]> {
|
||||
|
10
frontend/src/lib/post/application/useCase/getPostUseCase.ts
Normal file
10
frontend/src/lib/post/application/useCase/getPostUseCase.ts
Normal 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);
|
||||
}
|
||||
}
|
13
frontend/src/lib/post/domain/entity/post.ts
Normal file
13
frontend/src/lib/post/domain/entity/post.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
13
frontend/src/lib/post/framework/ui/Label.svelte
Normal file
13
frontend/src/lib/post/framework/ui/Label.svelte
Normal 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>
|
17
frontend/src/lib/post/framework/ui/PostContentHeader.svelte
Normal file
17
frontend/src/lib/post/framework/ui/PostContentHeader.svelte
Normal 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>
|
27
frontend/src/lib/post/framework/ui/PostContentPage.svelte
Normal file
27
frontend/src/lib/post/framework/ui/PostContentPage.svelte
Normal 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>
|
@ -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="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)}
|
||||
{#each state.data ?? [] as postInfo (postInfo.id)}
|
||||
<PostPreview {postInfo} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
12
frontend/src/routes/post/+page.server.ts
Normal file
12
frontend/src/routes/post/+page.server.ts
Normal 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())
|
||||
};
|
||||
};
|
@ -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>
|
||||
|
24
frontend/src/routes/post/[id]/+page.server.ts
Normal file
24
frontend/src/routes/post/[id]/+page.server.ts
Normal 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()
|
||||
};
|
||||
};
|
25
frontend/src/routes/post/[id]/+page.svelte
Normal file
25
frontend/src/routes/post/[id]/+page.svelte
Normal 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} />
|
Loading…
x
Reference in New Issue
Block a user