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
|
||||
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
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
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="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>
|
||||
|
@ -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