diff --git a/.gitea/workflows/deployment.yaml b/.gitea/workflows/deployment.yaml index 1f61d0d..fa9bbb8 100644 --- a/.gitea/workflows/deployment.yaml +++ b/.gitea/workflows/deployment.yaml @@ -5,7 +5,7 @@ on: - published jobs: - frontend-deployment: + deployment: runs-on: ubuntu-latest steps: - name: Checkout diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7e36810..0cb2aab 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1789,6 +1789,7 @@ dependencies = [ "actix-web", "dotenv", "env_logger", + "percent-encoding", "post", "sqlx", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5afd97e..6a8dc37 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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", diff --git a/backend/feature/post/src/framework/db/post_db_service_impl.rs b/backend/feature/post/src/framework/db/post_db_service_impl.rs index 73d5a06..77311c1 100644 --- a/backend/feature/post/src/framework/db/post_db_service_impl.rs +++ b/backend/feature/post/src/framework/db/post_db_service_impl.rs @@ -68,21 +68,23 @@ impl PostDbService for PostDbServiceImpl { let mut post_info_mappers_map = HashMap::::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 { @@ -135,25 +144,27 @@ impl PostDbService for PostDbServiceImpl { let mut post_mappers_map = HashMap::::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, diff --git a/backend/server/Cargo.toml b/backend/server/Cargo.toml index 1dbcb27..ad808e1 100644 --- a/backend/server/Cargo.toml +++ b/backend/server/Cargo.toml @@ -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 diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index 674881b..92f93a2 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -34,9 +34,13 @@ async fn init_database() -> Pool { 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() diff --git a/frontend/package.json b/frontend/package.json index 84a9605..1cec3b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 519b4f5..0d4e6d0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/app.css b/frontend/src/app.css index e489be2..224a8cb 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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 { diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 1af894e..ecf09e9 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -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 {} diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..6c263ce --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -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); +}; diff --git a/frontend/src/lib/common/adapter/presenter/asyncState.ts b/frontend/src/lib/common/adapter/presenter/asyncState.ts index b80e9af..a058935 100644 --- a/frontend/src/lib/common/adapter/presenter/asyncState.ts +++ b/frontend/src/lib/common/adapter/presenter/asyncState.ts @@ -5,12 +5,14 @@ export enum StatusType { Error } -export interface IdleState { +export interface IdleState { status: StatusType.Idle; + data?: T; } -export interface LoadingState { +export interface LoadingState { status: StatusType.Loading; + data?: T; } export interface SuccessState { @@ -18,9 +20,10 @@ export interface SuccessState { data: T; } -export interface ErrorState { +export interface ErrorState { status: StatusType.Error; + data?: T; error: Error; } -export type AsyncState = IdleState | LoadingState | SuccessState | ErrorState; +export type AsyncState = IdleState | LoadingState | SuccessState | ErrorState; diff --git a/frontend/src/lib/common/framework/ui/Footer.svelte b/frontend/src/lib/common/framework/ui/Footer.svelte index b0814d1..3d80588 100644 --- a/frontend/src/lib/common/framework/ui/Footer.svelte +++ b/frontend/src/lib/common/framework/ui/Footer.svelte @@ -1,6 +1,6 @@
diff --git a/frontend/src/lib/common/framework/ui/SafeHtml.svelte b/frontend/src/lib/common/framework/ui/SafeHtml.svelte new file mode 100644 index 0000000..a319116 --- /dev/null +++ b/frontend/src/lib/common/framework/ui/SafeHtml.svelte @@ -0,0 +1,11 @@ + + +{@html sanitizedHtml} diff --git a/frontend/src/lib/post/adapter/gateway/postApiService.ts b/frontend/src/lib/post/adapter/gateway/postApiService.ts index 123480e..ccabb11 100644 --- a/frontend/src/lib/post/adapter/gateway/postApiService.ts +++ b/frontend/src/lib/post/adapter/gateway/postApiService.ts @@ -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; + getPost(id: number): Promise; } diff --git a/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts b/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts index 560f82b..bfd0f3e 100644 --- a/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts +++ b/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts @@ -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 { + const dto = await this.postApiService.getPost(id); + return dto?.toEntity() ?? null; + } } diff --git a/frontend/src/lib/post/adapter/gateway/postResponseDto.ts b/frontend/src/lib/post/adapter/gateway/postResponseDto.ts new file mode 100644 index 0000000..00f443d --- /dev/null +++ b/frontend/src/lib/post/adapter/gateway/postResponseDto.ts @@ -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 + }); + } +} diff --git a/frontend/src/lib/post/adapter/presenter/colorViewModel.ts b/frontend/src/lib/post/adapter/presenter/colorViewModel.ts index 827aa46..cf08d2f 100644 --- a/frontend/src/lib/post/adapter/presenter/colorViewModel.ts +++ b/frontend/src/lib/post/adapter/presenter/colorViewModel.ts @@ -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; +} diff --git a/frontend/src/lib/post/adapter/presenter/labelViewModel.ts b/frontend/src/lib/post/adapter/presenter/labelViewModel.ts index 4abebd3..c4ba633 100644 --- a/frontend/src/lib/post/adapter/presenter/labelViewModel.ts +++ b/frontend/src/lib/post/adapter/presenter/labelViewModel.ts @@ -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; } diff --git a/frontend/src/lib/post/adapter/presenter/postBloc.ts b/frontend/src/lib/post/adapter/presenter/postBloc.ts new file mode 100644 index 0000000..0e65a29 --- /dev/null +++ b/frontend/src/lib/post/adapter/presenter/postBloc.ts @@ -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; +export type PostEvent = PostLoadedEvent; + +export class PostBloc { + private readonly state = writable({ + 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 { + switch (event.event) { + case PostEventType.PostLoadedEvent: + return this.loadPost(event.id); + } + } + + private async loadPost(id: number): Promise { + 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; +} diff --git a/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts b/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts index ae6ed7d..670acb7 100644 --- a/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts +++ b/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts @@ -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; } diff --git a/frontend/src/lib/post/adapter/presenter/postListBloc.ts b/frontend/src/lib/post/adapter/presenter/postListBloc.ts index 3bccf0d..e481e4d 100644 --- a/frontend/src/lib/post/adapter/presenter/postListBloc.ts +++ b/frontend/src/lib/post/adapter/presenter/postListBloc.ts @@ -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; +export type PostListEvent = PostListLoadedEvent; export class PostListBloc { - constructor(private readonly getAllPostsUseCase: GetAllPostUseCase) {} - - private readonly state = writable>({ + private readonly state = writable({ 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 { 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 { + 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; diff --git a/frontend/src/lib/post/adapter/presenter/postViewModel.ts b/frontend/src/lib/post/adapter/presenter/postViewModel.ts new file mode 100644 index 0000000..24bfb31 --- /dev/null +++ b/frontend/src/lib/post/adapter/presenter/postViewModel.ts @@ -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; +} diff --git a/frontend/src/lib/post/application/repository/postRepository.ts b/frontend/src/lib/post/application/repository/postRepository.ts index 7afe74a..021170f 100644 --- a/frontend/src/lib/post/application/repository/postRepository.ts +++ b/frontend/src/lib/post/application/repository/postRepository.ts @@ -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; + getPost(id: number): Promise; } diff --git a/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts b/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts index d2eb96e..f55957c 100644 --- a/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts +++ b/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts @@ -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 { diff --git a/frontend/src/lib/post/application/useCase/getPostUseCase.ts b/frontend/src/lib/post/application/useCase/getPostUseCase.ts new file mode 100644 index 0000000..2e2d920 --- /dev/null +++ b/frontend/src/lib/post/application/useCase/getPostUseCase.ts @@ -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 { + return this.postRepository.getPost(id); + } +} diff --git a/frontend/src/lib/post/domain/entity/post.ts b/frontend/src/lib/post/domain/entity/post.ts new file mode 100644 index 0000000..458a4de --- /dev/null +++ b/frontend/src/lib/post/domain/entity/post.ts @@ -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; + } +} diff --git a/frontend/src/lib/post/framework/api/postApiServiceImpl.ts b/frontend/src/lib/post/framework/api/postApiServiceImpl.ts index e1d4040..61e1328 100644 --- a/frontend/src/lib/post/framework/api/postApiServiceImpl.ts +++ b/frontend/src/lib/post/framework/api/postApiServiceImpl.ts @@ -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 { 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 { + 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); + } } diff --git a/frontend/src/lib/post/framework/ui/Label.svelte b/frontend/src/lib/post/framework/ui/Label.svelte new file mode 100644 index 0000000..1962dd8 --- /dev/null +++ b/frontend/src/lib/post/framework/ui/Label.svelte @@ -0,0 +1,13 @@ + + +
+
+ {label.name} +
diff --git a/frontend/src/lib/post/framework/ui/PostContentHeader.svelte b/frontend/src/lib/post/framework/ui/PostContentHeader.svelte new file mode 100644 index 0000000..03cf330 --- /dev/null +++ b/frontend/src/lib/post/framework/ui/PostContentHeader.svelte @@ -0,0 +1,17 @@ + + +
+
+ {#each postInfo.labels as label (label.id)} +
+

{postInfo.title}

+

{postInfo.description}

+ {postInfo.formattedPublishedTime} +
diff --git a/frontend/src/lib/post/framework/ui/PostContentPage.svelte b/frontend/src/lib/post/framework/ui/PostContentPage.svelte new file mode 100644 index 0000000..d8093b8 --- /dev/null +++ b/frontend/src/lib/post/framework/ui/PostContentPage.svelte @@ -0,0 +1,27 @@ + + +
+ {#if state.data} + +
+
+ +
+ {/if} +
diff --git a/frontend/src/lib/post/framework/ui/PostOverallPage.svelte b/frontend/src/lib/post/framework/ui/PostOverallPage.svelte index 85f051f..1432bd0 100644 --- a/frontend/src/lib/post/framework/ui/PostOverallPage.svelte +++ b/frontend/src/lib/post/framework/ui/PostOverallPage.svelte @@ -1,5 +1,4 @@ -
-
+
- {postInfo.title} + {postInfo.title} {postInfo.description} - 查看更多 ⭢ + {postInfo.formattedPublishedTime}
diff --git a/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte b/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte index d9e132a..67fa3b0 100644 --- a/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte +++ b/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte @@ -1,24 +1,16 @@ -
+
{#each labels.slice(0, 2) as label (label.id)} -
-
- {label.name} -
+