Compare commits

..

1 Commits

Author SHA1 Message Date
d4ad3ed990 NO-ISSUE docs: update document link
All checks were successful
Frontend CI / build (push) Successful in 1m3s
PR Title Check / pr-title-check (pull_request) Successful in 16s
2025-07-24 23:32:10 +08:00
38 changed files with 81 additions and 684 deletions

View File

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

1
backend/Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@ -34,13 +34,9 @@ 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, encoded_password, host, port, dbname
user, password, host, port, dbname
);
let db_pool = PgPoolOptions::new()

View File

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

143
frontend/pnpm-lock.yaml generated
View File

@ -35,12 +35,6 @@ 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)
@ -53,9 +47,6 @@ 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
@ -65,9 +56,6 @@ 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
@ -634,21 +622,9 @@ 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}
@ -818,27 +794,10 @@ 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'}
@ -996,9 +955,6 @@ 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'}
@ -1034,10 +990,6 @@ 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==}
@ -1146,9 +1098,6 @@ 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==}
@ -1168,13 +1117,6 @@ 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'}
@ -1238,9 +1180,6 @@ 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'}
@ -1375,10 +1314,6 @@ 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'}
@ -1415,9 +1350,6 @@ 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'}
@ -1520,9 +1452,6 @@ 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==}
@ -2021,21 +1950,8 @@ 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
@ -2214,31 +2130,11 @@ 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
@ -2438,13 +2334,6 @@ 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: {}
@ -2470,8 +2359,6 @@ snapshots:
is-number@7.0.0: {}
is-plain-object@5.0.0: {}
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.8
@ -2554,10 +2441,6 @@ 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:
@ -2574,17 +2457,6 @@ 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:
@ -2639,8 +2511,6 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-srcset@1.0.2: {}
path-exists@4.0.0: {}
path-key@3.1.1: {}
@ -2699,8 +2569,6 @@ snapshots:
prettier@3.6.2: {}
punycode.js@2.3.1: {}
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
@ -2751,15 +2619,6 @@ 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: {}
@ -2871,8 +2730,6 @@ snapshots:
typescript@5.8.3: {}
uc.micro@2.1.0: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
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 {
@ -10,9 +9,4 @@ export class PostRepositoryImpl implements PostRepository {
const dtos = await this.postApiService.getAllPosts();
return dtos.map((dto) => dto.toEntity());
}
async getPost(id: number): Promise<Post | null> {
const dto = await this.postApiService.getPost(id);
return dto?.toEntity() ?? null;
}
}

View File

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

View File

@ -56,10 +56,6 @@ 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)}`;
@ -109,15 +105,6 @@ 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 {
@ -125,10 +112,3 @@ interface Hsl {
s: number;
l: number;
}
export interface DehydratedColorProps {
red: number;
green: number;
blue: number;
alpha: number;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,12 @@
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 this.fetchFn(url.href);
const response = await fetch(url.href);
if (!response.ok) {
return [];
@ -18,17 +15,4 @@ export class PostApiServiceImpl implements PostApiService {
const json = await response.json();
return json.map(PostInfoResponseDto.fromJson);
}
async getPost(id: number): Promise<PostResponseDto | null> {
const url = new URL(`post/${id}`, Environment.API_BASE_URL);
const response = await this.fetchFn(url.href);
if (!response.ok) {
return null;
}
const json = await response.json();
return PostResponseDto.fromJson(json);
}
}

View File

@ -1,13 +0,0 @@
<script lang="ts">
import type { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel';
const { label }: { label: LabelViewModel } = $props();
</script>
<div
class="flex flex-row items-center gap-x-1 rounded-full px-2 py-0.5"
style="background-color: {label.color.hex};"
>
<div class="size-2 rounded-full" style="background-color: {label.color.darken(0.2).hex};"></div>
<span class="text-xs">{label.name}</span>
</div>

View File

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

View File

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

View File

@ -1,4 +1,5 @@
<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';
@ -9,11 +10,13 @@
onMount(() => postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent }));
</script>
<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="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)}
{#each state.data as postInfo (postInfo.id)}
<PostPreview {postInfo} />
{/each}
</div>
{/if}
</div>

View File

@ -17,7 +17,7 @@
}
</script>
<a class="flex cursor-pointer flex-col gap-y-6" href="/post/{postInfo.id}">
<a class="flex cursor-pointer flex-col gap-y-4" 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-2.5">
<div class="flex flex-col gap-y-1.5">
<PostPreviewLabels labels={postInfo.labels} />
<span class="line-clamp-1 text-lg font-bold">{postInfo.title}</span>
<span class="line-clamp-1 font-bold">{postInfo.title}</span>
<span class="line-clamp-3 text-justify text-sm">{postInfo.description}</span>
<span class="text-sm text-gray-500">{postInfo.formattedPublishedTime}</span>
<span class="text-sm text-gray-500">查看更多 ⭢</span>
</div>
</a>

View File

@ -1,16 +1,24 @@
<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">
<div class="flex flex-row gap-x-2 text-xs">
{#each labels.slice(0, 2) as label (label.id)}
<Label {label} />
<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>
{/each}
{#if labels.length > 2}
<div class="rounded-full bg-gray-200 px-2 py-0.5 text-xs">
<div class="rounded-full bg-gray-200 px-2 py-0.5">
<span>+{labels.length - 2}</span>
</div>
{/if}

View File

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

View File

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

View File

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

View File

@ -1,25 +0,0 @@
<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} />