From 779e4eae2fedfbe7ab52555fb126c4ad03ecd05a Mon Sep 17 00:00:00 2001 From: zhenyi <434836402@qq.com> Date: Sun, 31 May 2026 03:09:49 +0800 Subject: [PATCH] feat(channel): add article feed and composer with room type support - Add ArticleFeed component for article-based channels - Implement ArticleComposer with draft persistence - Add Newspaper icon for article room type - Update ChannelPage to conditionally render article feed vs message view - Add article-related API endpoints and models - Reset thread view when switching rooms - Add room type check in channel sidebar - Update CSS to hide scrollbars globally - Add gRPC message size limit configuration - Fix git diff tree handling --- index.html | 20 + lib/api/src/channel/mod.rs | 30 + lib/api/src/channel/rest_article.rs | 306 ++++++++ lib/api/src/channel/rest_room.rs | 2 + lib/api/src/openapi.rs | 11 + lib/channel/bus.rs | 2 +- lib/channel/event/article.rs | 115 +++ lib/channel/event/common.rs | 2 +- lib/channel/event/mod.rs | 3 +- lib/channel/http/dispatch.rs | 2 +- lib/channel/http/handler/article.rs | 717 ++++++++++++++++++ lib/channel/http/handler/category.rs | 8 +- lib/channel/http/handler/forward.rs | 2 +- lib/channel/http/handler/helpers.rs | 2 +- lib/channel/http/handler/message.rs | 16 +- lib/channel/http/handler/mod.rs | 86 ++- lib/channel/http/handler/room.rs | 19 +- lib/channel/http/handler/thread.rs | 6 +- lib/channel/http/out_event.rs | 42 +- lib/channel/http/types.rs | 55 ++ lib/channel/pagination.rs | 12 +- lib/channel/reconnect.rs | 2 +- lib/channel/rooms.rs | 3 +- lib/channel/search.rs | 2 +- lib/git/cmd/diff/diff_patch.rs | 2 - lib/git/cmd/diff/diff_stats.rs | 1 - lib/git/cmd/diff/diff_tree_to_tree.rs | 2 - lib/git/rpc/server.rs | 1 + .../sql/room/channel_article_down_01.sql | 3 + .../channel_article_like_comment_down_01.sql | 2 + .../channel_article_like_comment_up_01.sql | 28 + .../sql/room/channel_article_up_01.sql | 28 + lib/model/channel/channel.rs | 85 +++ lib/model/channel/channel_article.rs | 69 ++ lib/model/channel/channel_article_interact.rs | 51 ++ lib/model/{room => channel}/message_read.rs | 0 lib/model/{room => channel}/message_star.rs | 0 lib/model/{room => channel}/mod.rs | 16 +- .../{room => channel}/room_attachments.rs | 0 .../{room => channel}/room_categories.rs | 0 lib/model/{room => channel}/room_mention.rs | 0 lib/model/{room => channel}/room_message.rs | 0 .../room_message_edit_history.rs | 0 .../room_permission_overwrite.rs | 0 lib/model/{room => channel}/room_pins.rs | 0 lib/model/{room => channel}/room_reactions.rs | 0 .../{room => channel}/room_server_label.rs | 0 lib/model/{room => channel}/room_threads.rs | 0 .../{room => channel}/user_room_state.rs | 0 lib/model/lib.rs | 2 +- lib/model/room/room.rs | 22 - lib/service/git/diff.rs | 17 +- openapi.json | 530 +++++++++++++ src/App.css | 2 + src/App.tsx | 4 +- src/client/endpoints.ts | 129 +++- .../models/articleCommentCreateRequest.ts | 13 + src/client/models/articleCreateRequest.ts | 22 + src/client/models/articleLikeRequest.ts | 11 + src/client/models/articleUpdateRequest.ts | 26 + .../models/channelArticleCommentListParams.ts | 18 + .../models/channelArticleLikedUsersParams.ts | 18 + src/client/models/channelArticleListParams.ts | 18 + src/client/models/index.ts | 7 + src/client/models/roomCreateRequest.ts | 2 + src/components/right-drawer.tsx | 87 +++ src/hooks/use-drawer.ts | 8 + src/page/workspace/channel/article-card.tsx | 140 ++++ .../workspace/channel/article-composer.tsx | 261 +++++++ src/page/workspace/channel/article-detail.tsx | 400 ++++++++++ src/page/workspace/channel/article-feed.tsx | 325 ++++++++ src/page/workspace/channel/article-types.ts | 106 +++ src/page/workspace/channel/channel-header.tsx | 17 +- .../workspace/channel/channel-sidebar.tsx | 3 + .../channel/channel-thread-panel.tsx | 60 +- src/page/workspace/channel/index.tsx | 119 ++- .../workspace/channel/message-content.tsx | 68 ++ src/page/workspace/channel/message-item.tsx | 57 +- src/page/workspace/channel/repo-drawer.tsx | 45 ++ .../workspace/channel/repo-embed-card.tsx | 167 ++++ .../workspace/channel/repo-link-parser.ts | 38 + .../workspace/channel/room-create-dialog.tsx | 45 +- .../workspace/channel/use-article-draft.ts | 111 +++ src/page/workspace/repo/code.tsx | 8 + src/page/workspace/repo/commit-detail.tsx | 2 +- src/page/workspace/repo/layout.tsx | 18 +- 86 files changed, 4503 insertions(+), 176 deletions(-) create mode 100644 lib/api/src/channel/rest_article.rs create mode 100644 lib/channel/event/article.rs create mode 100644 lib/channel/http/handler/article.rs create mode 100644 lib/migrate/sql/room/channel_article_down_01.sql create mode 100644 lib/migrate/sql/room/channel_article_like_comment_down_01.sql create mode 100644 lib/migrate/sql/room/channel_article_like_comment_up_01.sql create mode 100644 lib/migrate/sql/room/channel_article_up_01.sql create mode 100644 lib/model/channel/channel.rs create mode 100644 lib/model/channel/channel_article.rs create mode 100644 lib/model/channel/channel_article_interact.rs rename lib/model/{room => channel}/message_read.rs (100%) rename lib/model/{room => channel}/message_star.rs (100%) rename lib/model/{room => channel}/mod.rs (60%) rename lib/model/{room => channel}/room_attachments.rs (100%) rename lib/model/{room => channel}/room_categories.rs (100%) rename lib/model/{room => channel}/room_mention.rs (100%) rename lib/model/{room => channel}/room_message.rs (100%) rename lib/model/{room => channel}/room_message_edit_history.rs (100%) rename lib/model/{room => channel}/room_permission_overwrite.rs (100%) rename lib/model/{room => channel}/room_pins.rs (100%) rename lib/model/{room => channel}/room_reactions.rs (100%) rename lib/model/{room => channel}/room_server_label.rs (100%) rename lib/model/{room => channel}/room_threads.rs (100%) rename lib/model/{room => channel}/user_room_state.rs (100%) delete mode 100644 lib/model/room/room.rs create mode 100644 src/client/models/articleCommentCreateRequest.ts create mode 100644 src/client/models/articleCreateRequest.ts create mode 100644 src/client/models/articleLikeRequest.ts create mode 100644 src/client/models/articleUpdateRequest.ts create mode 100644 src/client/models/channelArticleCommentListParams.ts create mode 100644 src/client/models/channelArticleLikedUsersParams.ts create mode 100644 src/client/models/channelArticleListParams.ts create mode 100644 src/components/right-drawer.tsx create mode 100644 src/hooks/use-drawer.ts create mode 100644 src/page/workspace/channel/article-card.tsx create mode 100644 src/page/workspace/channel/article-composer.tsx create mode 100644 src/page/workspace/channel/article-detail.tsx create mode 100644 src/page/workspace/channel/article-feed.tsx create mode 100644 src/page/workspace/channel/article-types.ts create mode 100644 src/page/workspace/channel/message-content.tsx create mode 100644 src/page/workspace/channel/repo-drawer.tsx create mode 100644 src/page/workspace/channel/repo-embed-card.tsx create mode 100644 src/page/workspace/channel/repo-link-parser.ts create mode 100644 src/page/workspace/channel/use-article-draft.ts diff --git a/index.html b/index.html index 8346b2f..d1594d2 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,26 @@ GitDataAI +
diff --git a/lib/api/src/channel/mod.rs b/lib/api/src/channel/mod.rs index efe0846..8e7b52e 100644 --- a/lib/api/src/channel/mod.rs +++ b/lib/api/src/channel/mod.rs @@ -1,4 +1,5 @@ pub mod rest; +pub mod rest_article; pub mod rest_interact; pub mod rest_member; pub mod rest_message; @@ -188,6 +189,35 @@ pub fn configure(cfg: &mut ServiceConfig, bus: ChannelBus) { actix_web::web::resource("/users/summary/{username}") .route(actix_web::web::get().to(rest_user::user_summary)), ); + // --- Article routes (waterfall post channels) --- + cfg.service( + actix_web::web::resource("/channels/{channel_id}/articles") + .route(actix_web::web::post().to(rest_article::article_create)) + .route(actix_web::web::get().to(rest_article::article_list)), + ) + .service( + actix_web::web::resource("/channels/{channel_id}/articles/{article_id}") + .route(actix_web::web::get().to(rest_article::article_get)) + .route(actix_web::web::patch().to(rest_article::article_update)) + .route(actix_web::web::delete().to(rest_article::article_delete)), + ) + .service( + actix_web::web::resource("/articles/{article_id}/like") + .route(actix_web::web::post().to(rest_article::article_like)), + ) + .service( + actix_web::web::resource("/articles/{article_id}/comments") + .route(actix_web::web::post().to(rest_article::article_comment_create)) + .route(actix_web::web::get().to(rest_article::article_comment_list)), + ) + .service( + actix_web::web::resource("/articles/{article_id}/comments/{comment_id}") + .route(actix_web::web::delete().to(rest_article::article_comment_delete)), + ) + .service( + actix_web::web::resource("/articles/{article_id}/likes") + .route(actix_web::web::get().to(rest_article::article_liked_users)), + ); cfg.service( actix_web::web::resource("/token") .route(actix_web::web::post().to(token::generate_token)), diff --git a/lib/api/src/channel/rest_article.rs b/lib/api/src/channel/rest_article.rs new file mode 100644 index 0000000..ae3058e --- /dev/null +++ b/lib/api/src/channel/rest_article.rs @@ -0,0 +1,306 @@ +use actix_web::{HttpRequest, HttpResponse, web}; +use channel::ChannelBus; +use channel::http::{WsHandler, WsInMessage}; +use serde::Deserialize; +use uuid::Uuid; + +use super::rest::{channel_err, created_json, extract_user, ok_json}; +use crate::error::ApiError; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ArticleCreateRequest { + pub title: String, + pub cover_url: Option, + pub content: String, + pub content_type: Option, + pub summary: Option, + pub tags: Option>, + pub status: Option, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ArticleUpdateRequest { + pub title: Option, + pub cover_url: Option, + pub content: Option, + pub content_type: Option, + pub summary: Option, + pub tags: Option>, + pub is_pinned: Option, + pub status: Option, +} + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub struct ArticleListQuery { + pub before: Option, + pub limit: Option, +} + +#[utoipa::path( + post, + path = "/api/v1/ws/channels/{channel_id}/articles", + request_body = ArticleCreateRequest, + responses((status = 201, description = "Article created")), + tag = "channel", +)] +pub async fn article_create( + req: HttpRequest, + channel_id: web::Path, + body: web::Json, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let msg = WsInMessage::ArticleCreate { + channel: channel_id.into_inner(), + title: body.title.clone(), + cover_url: body.cover_url.clone(), + content: body.content.clone(), + content_type: body.content_type.clone(), + summary: body.summary.clone(), + tags: body.tags.clone(), + status: body.status.clone(), + }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(created_json(result)) +} + +#[utoipa::path( + get, + path = "/api/v1/ws/channels/{channel_id}/articles", + params(ArticleListQuery), + responses((status = 200, description = "Article list (waterfall feed)")), + tag = "channel", +)] +pub async fn article_list( + req: HttpRequest, + channel_id: web::Path, + query: web::Query, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let msg = WsInMessage::ArticleList { + channel: channel_id.into_inner(), + before: query.before, + limit: query.limit, + }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(ok_json(result)) +} + +#[utoipa::path( + get, + path = "/api/v1/ws/channels/{channel_id}/articles/{article_id}", + responses((status = 200, description = "Article detail")), + tag = "channel", +)] +pub async fn article_get( + req: HttpRequest, + path: web::Path<(Uuid, Uuid)>, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let (_channel_id, article_id) = path.into_inner(); + let msg = WsInMessage::ArticleGet { article_id }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(ok_json(result)) +} + +#[utoipa::path( + patch, + path = "/api/v1/ws/channels/{channel_id}/articles/{article_id}", + request_body = ArticleUpdateRequest, + responses((status = 200, description = "Article updated")), + tag = "channel", +)] +pub async fn article_update( + req: HttpRequest, + path: web::Path<(Uuid, Uuid)>, + body: web::Json, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let (_channel_id, article_id) = path.into_inner(); + let msg = WsInMessage::ArticleUpdate { + article_id, + title: body.title.clone(), + cover_url: body.cover_url.clone(), + content: body.content.clone(), + content_type: body.content_type.clone(), + summary: body.summary.clone(), + tags: body.tags.clone(), + is_pinned: body.is_pinned, + status: body.status.clone(), + }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(ok_json(result)) +} + +#[utoipa::path( + delete, + path = "/api/v1/ws/channels/{channel_id}/articles/{article_id}", + responses((status = 204, description = "Article deleted")), + tag = "channel", +)] +pub async fn article_delete( + req: HttpRequest, + path: web::Path<(Uuid, Uuid)>, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let (_channel_id, article_id) = path.into_inner(); + let msg = WsInMessage::ArticleDelete { article_id }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(ok_json(result)) +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ArticleLikeRequest { + pub like: bool, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ArticleCommentCreateRequest { + pub content: String, + pub parent: Option, +} + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub struct ArticleCommentListQuery { + pub before: Option, + pub limit: Option, +} + +#[utoipa::path( + post, + path = "/api/v1/ws/articles/{article_id}/like", + request_body = ArticleLikeRequest, + responses((status = 200, description = "Like toggled")), + tag = "channel", +)] +pub async fn article_like( + req: HttpRequest, + article_id: web::Path, + body: web::Json, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let msg = WsInMessage::ArticleLike { + article_id: article_id.into_inner(), + like: body.like, + }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(ok_json(result)) +} + +#[utoipa::path( + post, + path = "/api/v1/ws/articles/{article_id}/comments", + request_body = ArticleCommentCreateRequest, + responses((status = 201, description = "Comment created")), + tag = "channel", +)] +pub async fn article_comment_create( + req: HttpRequest, + article_id: web::Path, + body: web::Json, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let msg = WsInMessage::ArticleCommentCreate { + article_id: article_id.into_inner(), + content: body.content.clone(), + parent: body.parent, + }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(created_json(result)) +} + +#[utoipa::path( + get, + path = "/api/v1/ws/articles/{article_id}/comments", + params(ArticleCommentListQuery), + responses((status = 200, description = "Comment list")), + tag = "channel", +)] +pub async fn article_comment_list( + req: HttpRequest, + article_id: web::Path, + query: web::Query, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let msg = WsInMessage::ArticleCommentList { + article_id: article_id.into_inner(), + before: query.before, + limit: query.limit, + }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(ok_json(result)) +} + +#[utoipa::path( + delete, + path = "/api/v1/ws/articles/{article_id}/comments/{comment_id}", + responses((status = 204, description = "Comment deleted")), + tag = "channel", +)] +pub async fn article_comment_delete( + req: HttpRequest, + path: web::Path<(Uuid, Uuid)>, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let (_article_id, comment_id) = path.into_inner(); + let msg = WsInMessage::ArticleCommentDelete { comment_id }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(ok_json(result)) +} + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub struct ArticleLikedUsersQuery { + pub before: Option, + pub limit: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/ws/articles/{article_id}/likes", + params(ArticleLikedUsersQuery), + responses((status = 200, description = "List of users who liked")), + tag = "channel", +)] +pub async fn article_liked_users( + req: HttpRequest, + article_id: web::Path, + query: web::Query, + bus: web::Data, +) -> Result { + let user_id = extract_user(&req)?; + let msg = WsInMessage::ArticleLikedUsers { + article_id: article_id.into_inner(), + before: query.before, + limit: query.limit, + }; + let result = WsHandler::handle(&bus, user_id, msg) + .await + .map_err(channel_err)?; + Ok(ok_json(result)) +} diff --git a/lib/api/src/channel/rest_room.rs b/lib/api/src/channel/rest_room.rs index 09a7a34..4f63e15 100644 --- a/lib/api/src/channel/rest_room.rs +++ b/lib/api/src/channel/rest_room.rs @@ -14,6 +14,7 @@ pub struct RoomCreateRequest { pub public: bool, pub category: Option, pub ai_enabled: Option, + pub channel_type: Option, } #[derive(Debug, Deserialize, utoipa::ToSchema)] @@ -150,6 +151,7 @@ pub async fn room_create( public: body.public, category: body.category, ai_enabled: body.ai_enabled, + channel_type: body.channel_type.clone(), }; let result = WsHandler::handle(&bus, user_id, msg) .await diff --git a/lib/api/src/openapi.rs b/lib/api/src/openapi.rs index 93797d3..ecd1617 100644 --- a/lib/api/src/openapi.rs +++ b/lib/api/src/openapi.rs @@ -298,6 +298,17 @@ use utoipa::openapi::security::{ crate::channel::rest_voice::voice_deaf, crate::channel::rest_voice::screen_share, crate::channel::rest_user::user_summary, + crate::channel::rest_room::list_rooms, + crate::channel::rest_article::article_create, + crate::channel::rest_article::article_list, + crate::channel::rest_article::article_get, + crate::channel::rest_article::article_update, + crate::channel::rest_article::article_delete, + crate::channel::rest_article::article_like, + crate::channel::rest_article::article_comment_create, + crate::channel::rest_article::article_comment_list, + crate::channel::rest_article::article_comment_delete, + crate::channel::rest_article::article_liked_users, crate::search::search, ), modifiers(&SecurityAddon) diff --git a/lib/channel/bus.rs b/lib/channel/bus.rs index 00f0b9b..080a573 100644 --- a/lib/channel/bus.rs +++ b/lib/channel/bus.rs @@ -6,7 +6,7 @@ use std::{ use cache::AppCache; use dashmap::DashMap; use db::AppDatabase; -use model::room::RoomMessageModel; +use model::channel::RoomMessageModel; use serde::Deserialize; use serde::Serialize; use socketio::{Socket, SocketIo}; diff --git a/lib/channel/event/article.rs b/lib/channel/event/article.rs new file mode 100644 index 0000000..f2d2487 --- /dev/null +++ b/lib/channel/event/article.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::common::{UserInfo, RoomInfo}; + +/// Created when a user publishes an article in an article channel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleCreatedService { + pub article: ArticleItem, + pub channel: RoomInfo, + pub author: UserInfo, +} + +/// Created when an article is updated. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleUpdatedService { + pub article: ArticleItem, + pub channel: RoomInfo, +} + +/// Created when an article is deleted (soft-delete). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleDeletedService { + pub article_id: Uuid, + pub channel: RoomInfo, + pub deleted_by: UserInfo, +} + +/// Lightweight article card for waterfall feed listing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleItem { + pub id: Uuid, + pub channel: Uuid, + pub author: UserInfo, + pub title: String, + pub cover_url: Option, + pub summary: Option, + pub tags: Vec, + pub like_count: i64, + pub comment_count: i64, + pub view_count: i64, + pub is_pinned: bool, + pub content_type: String, + pub status: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +/// Full article with content body. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleDetail { + #[serde(flatten)] + pub card: ArticleItem, + pub content: String, +} + +/// Paginated article list response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleListService { + pub articles: Vec, + pub total: i64, + pub has_more: bool, +} + +/// Fired when a user likes an article. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleLikedService { + pub article_id: Uuid, + pub channel: RoomInfo, + pub user: UserInfo, + pub like_count: i64, +} + +/// Fired when a user unlikes an article. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleUnlikedService { + pub article_id: Uuid, + pub channel: RoomInfo, + pub user: UserInfo, + pub like_count: i64, +} + +/// Created when a comment is posted on an article. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleCommentCreatedService { + pub comment: model::channel::ArticleCommentItem, + pub channel: RoomInfo, + pub author: UserInfo, + pub comment_count: i64, +} + +/// Paginated comment list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleCommentListService { + pub comments: Vec, + pub total: i64, +} + +/// Fired when a comment is deleted. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleCommentDeletedService { + pub comment_id: Uuid, + pub article_id: Uuid, + pub channel: RoomInfo, + pub deleted_by: UserInfo, + pub comment_count: i64, +} + +/// List of users who liked an article. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleLikedUsersService { + pub article_id: Uuid, + pub users: Vec, + pub total: i64, +} diff --git a/lib/channel/event/common.rs b/lib/channel/event/common.rs index 28c7507..65b1714 100644 --- a/lib/channel/event/common.rs +++ b/lib/channel/event/common.rs @@ -41,7 +41,7 @@ impl RoomInfo { } } - pub fn from_model(m: &model::room::RoomModel) -> Self { + pub fn from_model(m: &model::channel::ChannelModel) -> Self { Self { id: m.id, name: m.name.clone(), diff --git a/lib/channel/event/mod.rs b/lib/channel/event/mod.rs index b346cfe..084d107 100644 --- a/lib/channel/event/mod.rs +++ b/lib/channel/event/mod.rs @@ -1,3 +1,4 @@ +pub mod article; pub mod attachment; pub mod ban; pub mod category; @@ -22,7 +23,7 @@ pub mod workspace; pub use common::{RoomInfo, UserInfo, WorkspaceInfo}; -use model::room::RoomMessageModel; +use model::channel::RoomMessageModel; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; diff --git a/lib/channel/http/dispatch.rs b/lib/channel/http/dispatch.rs index 714ed56..1dd9dd3 100644 --- a/lib/channel/http/dispatch.rs +++ b/lib/channel/http/dispatch.rs @@ -11,7 +11,7 @@ impl EventDispatcher { pub fn dispatch_message( room_id: Uuid, room_name: &str, - msg: &model::room::RoomMessageModel, + msg: &model::channel::RoomMessageModel, ) -> WsOutEvent { let room = RoomInfo { id: room_id, diff --git a/lib/channel/http/handler/article.rs b/lib/channel/http/handler/article.rs new file mode 100644 index 0000000..9a94c4d --- /dev/null +++ b/lib/channel/http/handler/article.rs @@ -0,0 +1,717 @@ +use uuid::Uuid; + +use crate::event::{RoomInfo, UserInfo, article}; +use crate::http::handler::WsHandler; +use crate::http::out_event::WsOutEvent; +use crate::{ChannelBus, ChannelError, ChannelResult}; + +impl WsHandler { + pub(super) async fn article_create( + bus: &ChannelBus, + user_id: Uuid, + channel: Uuid, + title: String, + cover_url: Option, + content: String, + content_type: Option, + summary: Option, + tags: Option>, + status: Option, + ) -> ChannelResult> { + Self::ensure_room_access(bus, user_id, channel).await?; + + let ctype = content_type.unwrap_or_else(|| "markdown".to_string()); + let st = status.unwrap_or_else(|| "published".to_string()); + let tag_list = tags.unwrap_or_default(); + + let row = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( + "INSERT INTO channel_article \ + (channel, author, title, cover_url, content, content_type, summary, tags, status, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), now()) \ + RETURNING id, channel, author, title, cover_url, content, content_type, summary, \ + tags, is_pinned, view_count, like_count, comment_count, status, \ + created_at, updated_at, deleted_at", + ) + .bind(channel) + .bind(user_id) + .bind(&title) + .bind(&cover_url) + .bind(&content) + .bind(&ctype) + .bind(&summary) + .bind(&tag_list) + .bind(&st) + .fetch_one(bus.inner.db.writer()) + .await?; + + let author = bus + .lookup_user(user_id) + .await + .unwrap_or_else(|_| UserInfo::unknown(user_id)); + let room = bus + .lookup_room(channel) + .await + .unwrap_or_else(|_| RoomInfo::unknown(channel)); + + let item = article_item_from_model(&row, &author); + let data = article::ArticleCreatedService { + article: item, + channel: room.clone(), + author: author.clone(), + }; + + bus.publish_room_event(channel, "article.created", &data).await?; + + Ok(Some(WsOutEvent::ArticleCreated { + room, + data, + })) + } + + pub(super) async fn article_update( + bus: &ChannelBus, + user_id: Uuid, + article_id: Uuid, + title: Option, + cover_url: Option, + content: Option, + content_type: Option, + summary: Option, + tags: Option>, + is_pinned: Option, + status: Option, + ) -> ChannelResult> { + let old = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( + "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ + tags, is_pinned, view_count, like_count, comment_count, status, \ + created_at, updated_at, deleted_at \ + FROM channel_article WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(article_id) + .fetch_optional(bus.inner.db.reader()) + .await? + .ok_or(ChannelError::RoomNotFound)?; + + if old.author != user_id { + return Err(ChannelError::AccessDenied); + } + Self::ensure_room_access(bus, user_id, old.channel).await?; + + let new_title = title.unwrap_or(old.title.clone()); + let new_cover = cover_url.or(old.cover_url.clone()); + let new_content = content.unwrap_or(old.content.clone()); + let new_ctype = content_type.unwrap_or(old.content_type.clone()); + let new_summary = summary.or(old.summary.clone()); + let new_tags = tags.unwrap_or(old.tags.clone()); + let new_pinned = is_pinned.unwrap_or(old.is_pinned); + let new_status = status.unwrap_or(old.status.clone()); + + let row = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( + "UPDATE channel_article \ + SET title = $2, cover_url = $3, content = $4, content_type = $5, \ + summary = $6, tags = $7, is_pinned = $8, status = $9, updated_at = now() \ + WHERE id = $1 AND deleted_at IS NULL \ + RETURNING id, channel, author, title, cover_url, content, content_type, summary, \ + tags, is_pinned, view_count, like_count, comment_count, status, \ + created_at, updated_at, deleted_at", + ) + .bind(article_id) + .bind(&new_title) + .bind(&new_cover) + .bind(&new_content) + .bind(&new_ctype) + .bind(&new_summary) + .bind(&new_tags) + .bind(new_pinned) + .bind(&new_status) + .fetch_one(bus.inner.db.writer()) + .await?; + + let author = bus + .lookup_user(row.author) + .await + .unwrap_or_else(|_| UserInfo::unknown(row.author)); + let room = bus + .lookup_room(row.channel) + .await + .unwrap_or_else(|_| RoomInfo::unknown(row.channel)); + + let item = article_item_from_model(&row, &author); + let data = article::ArticleUpdatedService { + article: item, + channel: room.clone(), + }; + + bus.publish_room_event(row.channel, "article.updated", &data).await?; + + Ok(Some(WsOutEvent::ArticleUpdated { + room, + data, + })) + } + + pub(super) async fn article_delete( + bus: &ChannelBus, + user_id: Uuid, + article_id: Uuid, + ) -> ChannelResult> { + let old = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( + "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ + tags, is_pinned, view_count, like_count, comment_count, status, \ + created_at, updated_at, deleted_at \ + FROM channel_article WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(article_id) + .fetch_optional(bus.inner.db.reader()) + .await? + .ok_or(ChannelError::RoomNotFound)?; + + if old.author != user_id { + return Err(ChannelError::AccessDenied); + } + + db::sqlx::query( + "UPDATE channel_article SET deleted_at = now(), updated_at = now() \ + WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(article_id) + .execute(bus.inner.db.writer()) + .await?; + + let deleted_by = bus + .lookup_user(user_id) + .await + .unwrap_or_else(|_| UserInfo::unknown(user_id)); + let room = bus + .lookup_room(old.channel) + .await + .unwrap_or_else(|_| RoomInfo::unknown(old.channel)); + + let data = article::ArticleDeletedService { + article_id, + channel: room.clone(), + deleted_by: deleted_by.clone(), + }; + + bus.publish_room_event(old.channel, "article.deleted", &data).await?; + + Ok(Some(WsOutEvent::ArticleDeleted { + room, + data, + })) + } + + pub(super) async fn article_list( + bus: &ChannelBus, + user_id: Uuid, + channel: Uuid, + before: Option, + limit: Option, + ) -> ChannelResult> { + Self::ensure_room_access(bus, user_id, channel).await?; + + let lim = limit.unwrap_or(20).min(50); + let rows = if let Some(cursor) = before { + // Cursor-based pagination: get articles older than the cursor + db::sqlx::query_as::<_, model::channel::ChannelArticleCard>( + "SELECT id, channel, author, title, cover_url, summary, tags, \ + like_count, comment_count, view_count, created_at \ + FROM channel_article \ + WHERE channel = $1 AND deleted_at IS NULL \ + AND created_at < (SELECT created_at FROM channel_article WHERE id = $2) \ + ORDER BY is_pinned DESC, created_at DESC \ + LIMIT $3", + ) + .bind(channel) + .bind(cursor) + .bind(lim) + .fetch_all(bus.inner.db.reader()) + .await? + } else { + db::sqlx::query_as::<_, model::channel::ChannelArticleCard>( + "SELECT id, channel, author, title, cover_url, summary, tags, \ + like_count, comment_count, view_count, created_at \ + FROM channel_article \ + WHERE channel = $1 AND deleted_at IS NULL \ + ORDER BY is_pinned DESC, created_at DESC \ + LIMIT $2", + ) + .bind(channel) + .bind(lim) + .fetch_all(bus.inner.db.reader()) + .await? + }; + + let total: (i64,) = + db::sqlx::query_as("SELECT COUNT(*) FROM channel_article WHERE channel = $1 AND deleted_at IS NULL") + .bind(channel) + .fetch_one(bus.inner.db.reader()) + .await?; + + let author_ids: Vec = rows.iter().map(|r| r.author).collect(); + let users = bus.lookup_users(&author_ids).await?; + let articles: Vec = rows + .iter() + .map(|r| { + let author = users + .get(&r.author) + .cloned() + .unwrap_or_else(|| UserInfo::unknown(r.author)); + article_item_from_card(r, &author) + }) + .collect(); + + let data = article::ArticleListService { + has_more: articles.len() as i64 >= lim, + articles, + total: total.0, + }; + + Ok(Some(WsOutEvent::ArticleList { data })) + } + + pub(super) async fn article_get( + bus: &ChannelBus, + user_id: Uuid, + article_id: Uuid, + ) -> ChannelResult> { + let row = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( + "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ + tags, is_pinned, view_count, like_count, comment_count, status, \ + created_at, updated_at, deleted_at \ + FROM channel_article WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(article_id) + .fetch_optional(bus.inner.db.reader()) + .await? + .ok_or(ChannelError::RoomNotFound)?; + + Self::ensure_room_access(bus, user_id, row.channel).await?; + + // Increment view count (fire-and-forget) + let _ = db::sqlx::query( + "UPDATE channel_article SET view_count = view_count + 1 WHERE id = $1", + ) + .bind(article_id) + .execute(bus.inner.db.writer()) + .await; + + let author = bus + .lookup_user(row.author) + .await + .unwrap_or_else(|_| UserInfo::unknown(row.author)); + + let card = article_item_from_model(&row, &author); + let detail = article::ArticleDetail { + content: row.content, + card, + }; + + Ok(Some(WsOutEvent::ArticleDetail { data: detail })) + } + + pub(super) async fn article_like( + bus: &ChannelBus, + user_id: Uuid, + article_id: Uuid, + like: bool, + ) -> ChannelResult> { + let art = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( + "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ + tags, is_pinned, view_count, like_count, comment_count, status, \ + created_at, updated_at, deleted_at \ + FROM channel_article WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(article_id) + .fetch_optional(bus.inner.db.reader()) + .await? + .ok_or(ChannelError::RoomNotFound)?; + + Self::ensure_room_access(bus, user_id, art.channel).await?; + + let new_count = if like { + let affected = db::sqlx::query( + "INSERT INTO channel_article_like (article, \"user\") VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(article_id) + .bind(user_id) + .execute(bus.inner.db.writer()) + .await?; + if affected.rows_affected() == 0 { + return Ok(None); + } + let row: (i64,) = db::sqlx::query_as( + "UPDATE channel_article SET like_count = like_count + 1 WHERE id = $1 RETURNING like_count", + ) + .bind(article_id) + .fetch_one(bus.inner.db.writer()) + .await?; + row.0 + } else { + let affected = db::sqlx::query( + "DELETE FROM channel_article_like WHERE article = $1 AND \"user\" = $2", + ) + .bind(article_id) + .bind(user_id) + .execute(bus.inner.db.writer()) + .await?; + if affected.rows_affected() == 0 { + return Ok(None); + } + let row: (i64,) = db::sqlx::query_as( + "UPDATE channel_article SET like_count = GREATEST(like_count - 1, 0) WHERE id = $1 RETURNING like_count", + ) + .bind(article_id) + .fetch_one(bus.inner.db.writer()) + .await?; + row.0 + }; + + let user = bus + .lookup_user(user_id) + .await + .unwrap_or_else(|_| UserInfo::unknown(user_id)); + let room = bus + .lookup_room(art.channel) + .await + .unwrap_or_else(|_| RoomInfo::unknown(art.channel)); + + if like { + let data = article::ArticleLikedService { + article_id, + channel: room.clone(), + user, + like_count: new_count, + }; + bus.publish_room_event(art.channel, "article.liked", &data).await?; + Ok(Some(WsOutEvent::ArticleLiked { room, data })) + } else { + let data = article::ArticleUnlikedService { + article_id, + channel: room.clone(), + user, + like_count: new_count, + }; + bus.publish_room_event(art.channel, "article.unliked", &data).await?; + Ok(Some(WsOutEvent::ArticleUnliked { room, data })) + } + } + + pub(super) async fn article_comment_create( + bus: &ChannelBus, + user_id: Uuid, + article_id: Uuid, + content: String, + parent: Option, + ) -> ChannelResult> { + let art = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( + "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ + tags, is_pinned, view_count, like_count, comment_count, status, \ + created_at, updated_at, deleted_at \ + FROM channel_article WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(article_id) + .fetch_optional(bus.inner.db.reader()) + .await? + .ok_or(ChannelError::RoomNotFound)?; + + Self::ensure_room_access(bus, user_id, art.channel).await?; + + let row = db::sqlx::query_as::<_, model::channel::ArticleCommentModel>( + "INSERT INTO channel_article_comment (article, author, parent, content, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, now(), now()) \ + RETURNING id, article, author, parent, content, created_at, updated_at, deleted_at", + ) + .bind(article_id) + .bind(user_id) + .bind(parent) + .bind(&content) + .fetch_one(bus.inner.db.writer()) + .await?; + + let count_row: (i64,) = db::sqlx::query_as( + "UPDATE channel_article SET comment_count = comment_count + 1 WHERE id = $1 RETURNING comment_count", + ) + .bind(article_id) + .fetch_one(bus.inner.db.writer()) + .await?; + + let author = bus + .lookup_user(user_id) + .await + .unwrap_or_else(|_| UserInfo::unknown(user_id)); + let room = bus + .lookup_room(art.channel) + .await + .unwrap_or_else(|_| RoomInfo::unknown(art.channel)); + + let item = model::channel::ArticleCommentItem { + id: row.id, + article: row.article, + parent: row.parent, + content: row.content, + author: row.author, + created_at: row.created_at, + updated_at: row.updated_at, + }; + + let data = article::ArticleCommentCreatedService { + comment: item, + channel: room.clone(), + author, + comment_count: count_row.0, + }; + + bus.publish_room_event(art.channel, "article.comment.created", &data).await?; + + Ok(Some(WsOutEvent::ArticleCommentCreated { room, data })) + } + + pub(super) async fn article_comment_list( + bus: &ChannelBus, + user_id: Uuid, + article_id: Uuid, + before: Option, + limit: Option, + ) -> ChannelResult> { + let art = db::sqlx::query_as::<_, (Uuid,)>( + "SELECT channel FROM channel_article WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(article_id) + .fetch_optional(bus.inner.db.reader()) + .await? + .ok_or(ChannelError::RoomNotFound)?; + + Self::ensure_room_access(bus, user_id, art.0).await?; + + let lim = limit.unwrap_or(30).min(50); + let rows = if let Some(cursor) = before { + db::sqlx::query_as::<_, model::channel::ArticleCommentModel>( + "SELECT id, article, author, parent, content, created_at, updated_at, deleted_at \ + FROM channel_article_comment \ + WHERE article = $1 AND deleted_at IS NULL \ + AND created_at > (SELECT created_at FROM channel_article_comment WHERE id = $2) \ + ORDER BY created_at ASC \ + LIMIT $3", + ) + .bind(article_id) + .bind(cursor) + .bind(lim) + .fetch_all(bus.inner.db.reader()) + .await? + } else { + db::sqlx::query_as::<_, model::channel::ArticleCommentModel>( + "SELECT id, article, author, parent, content, created_at, updated_at, deleted_at \ + FROM channel_article_comment \ + WHERE article = $1 AND deleted_at IS NULL \ + ORDER BY created_at ASC \ + LIMIT $2", + ) + .bind(article_id) + .bind(lim) + .fetch_all(bus.inner.db.reader()) + .await? + }; + + let total: (i64,) = db::sqlx::query_as( + "SELECT COUNT(*) FROM channel_article_comment WHERE article = $1 AND deleted_at IS NULL", + ) + .bind(article_id) + .fetch_one(bus.inner.db.reader()) + .await?; + + let comments: Vec = rows + .into_iter() + .map(|r| model::channel::ArticleCommentItem { + id: r.id, + article: r.article, + parent: r.parent, + content: r.content, + author: r.author, + created_at: r.created_at, + updated_at: r.updated_at, + }) + .collect(); + + let data = article::ArticleCommentListService { + comments, + total: total.0, + }; + + Ok(Some(WsOutEvent::ArticleCommentList { data })) + } + + pub(super) async fn article_comment_delete( + bus: &ChannelBus, + user_id: Uuid, + comment_id: Uuid, + ) -> ChannelResult> { + let cmt = db::sqlx::query_as::<_, model::channel::ArticleCommentModel>( + "SELECT id, article, author, parent, content, created_at, updated_at, deleted_at \ + FROM channel_article_comment WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(comment_id) + .fetch_optional(bus.inner.db.reader()) + .await? + .ok_or(ChannelError::RoomNotFound)?; + + let art = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( + "SELECT id, channel, author FROM channel_article WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(cmt.article) + .fetch_optional(bus.inner.db.reader()) + .await? + .ok_or(ChannelError::RoomNotFound)?; + + // Auth: article author can delete any comment; comment author can delete own + if art.author != user_id && cmt.author != user_id { + return Err(ChannelError::AccessDenied); + } + + db::sqlx::query( + "UPDATE channel_article_comment SET deleted_at = now(), updated_at = now() \ + WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(comment_id) + .execute(bus.inner.db.writer()) + .await?; + + let count_row: (i64,) = db::sqlx::query_as( + "UPDATE channel_article SET comment_count = GREATEST(comment_count - 1, 0) \ + WHERE id = $1 RETURNING comment_count", + ) + .bind(cmt.article) + .fetch_one(bus.inner.db.writer()) + .await?; + + let deleted_by = bus + .lookup_user(user_id) + .await + .unwrap_or_else(|_| UserInfo::unknown(user_id)); + let room = bus + .lookup_room(art.channel) + .await + .unwrap_or_else(|_| RoomInfo::unknown(art.channel)); + + let data = article::ArticleCommentDeletedService { + comment_id, + article_id: cmt.article, + channel: room.clone(), + deleted_by, + comment_count: count_row.0, + }; + + bus.publish_room_event(art.channel, "article.comment.deleted", &data).await?; + Ok(Some(WsOutEvent::ArticleCommentDeleted { room, data })) + } + + pub(super) async fn article_liked_users( + bus: &ChannelBus, + user_id: Uuid, + article_id: Uuid, + before: Option, + limit: Option, + ) -> ChannelResult> { + let art = db::sqlx::query_as::<_, (Uuid,)>( + "SELECT channel FROM channel_article WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(article_id) + .fetch_optional(bus.inner.db.reader()) + .await? + .ok_or(ChannelError::RoomNotFound)?; + + Self::ensure_room_access(bus, user_id, art.0).await?; + + let lim = limit.unwrap_or(30).min(50); + let rows: Vec<(Uuid,)> = if let Some(cursor) = before { + db::sqlx::query_as( + "SELECT l.\"user\" FROM channel_article_like l \ + WHERE l.article = $1 \ + AND l.created_at < (SELECT created_at FROM channel_article_like WHERE article = $1 AND \"user\" = $2) \ + ORDER BY l.created_at DESC LIMIT $3", + ) + .bind(article_id) + .bind(cursor) + .bind(lim) + .fetch_all(bus.inner.db.reader()) + .await? + } else { + db::sqlx::query_as( + "SELECT \"user\" FROM channel_article_like \ + WHERE article = $1 ORDER BY created_at DESC LIMIT $2", + ) + .bind(article_id) + .bind(lim) + .fetch_all(bus.inner.db.reader()) + .await? + }; + + let total: (i64,) = db::sqlx::query_as( + "SELECT COUNT(*) FROM channel_article_like WHERE article = $1", + ) + .bind(article_id) + .fetch_one(bus.inner.db.reader()) + .await?; + + let user_ids: Vec = rows.iter().map(|r| r.0).collect(); + let users_map = bus.lookup_users(&user_ids).await?; + let users: Vec = user_ids + .iter() + .map(|id| users_map.get(id).cloned().unwrap_or_else(|| UserInfo::unknown(*id))) + .collect(); + + Ok(Some(WsOutEvent::ArticleLikedUsers { + data: article::ArticleLikedUsersService { + article_id, + users, + total: total.0, + }, + })) + } +} + +fn article_item_from_model( + m: &model::channel::ChannelArticleModel, + author: &UserInfo, +) -> article::ArticleItem { + article::ArticleItem { + id: m.id, + channel: m.channel, + author: author.clone(), + title: m.title.clone(), + cover_url: m.cover_url.clone(), + summary: m.summary.clone(), + tags: m.tags.clone(), + like_count: m.like_count, + comment_count: m.comment_count, + view_count: m.view_count, + is_pinned: m.is_pinned, + content_type: m.content_type.clone(), + status: m.status.clone(), + created_at: m.created_at, + updated_at: m.updated_at, + } +} + +fn article_item_from_card( + m: &model::channel::ChannelArticleCard, + author: &UserInfo, +) -> article::ArticleItem { + article::ArticleItem { + id: m.id, + channel: m.channel, + author: author.clone(), + title: m.title.clone(), + cover_url: m.cover_url.clone(), + summary: m.summary.clone(), + tags: m.tags.clone(), + like_count: m.like_count, + comment_count: m.comment_count, + view_count: m.view_count, + is_pinned: false, // card doesn't carry is_pinned + content_type: String::new(), + status: String::new(), + created_at: m.created_at, + updated_at: m.created_at, // card only has created_at + } +} diff --git a/lib/channel/http/handler/category.rs b/lib/channel/http/handler/category.rs index 5a3fa8a..0fb6b44 100644 --- a/lib/channel/http/handler/category.rs +++ b/lib/channel/http/handler/category.rs @@ -22,7 +22,7 @@ impl WsHandler { )); } Self::ensure_workspace_member(bus, user_id, workspace).await?; - let row = db::sqlx::query_as::<_, model::room::RoomCategoryModel>( + let row = db::sqlx::query_as::<_, model::channel::RoomCategoryModel>( "INSERT INTO room_category (wk, name, position, created_at, updated_at) \ VALUES ($1, $2, $3, now(), now()) \ RETURNING id, wk, name, position, collapsed, created_at, updated_at", @@ -62,7 +62,7 @@ impl WsHandler { name: Option, position: Option, ) -> ChannelResult> { - let old = db::sqlx::query_as::<_, model::room::RoomCategoryModel>( + let old = db::sqlx::query_as::<_, model::channel::RoomCategoryModel>( "SELECT id, wk, name, position, collapsed, created_at, updated_at \ FROM room_category WHERE id = $1", ) @@ -108,7 +108,7 @@ impl WsHandler { _user_id: Uuid, id: Uuid, ) -> ChannelResult> { - let existing = db::sqlx::query_as::<_, model::room::RoomCategoryModel>( + let existing = db::sqlx::query_as::<_, model::channel::RoomCategoryModel>( "SELECT id, wk, name, position, collapsed, created_at, updated_at \ FROM room_category WHERE id = $1", ) @@ -116,7 +116,7 @@ impl WsHandler { .fetch_one(bus.inner.db.reader()) .await?; Self::ensure_workspace_member(bus, _user_id, existing.wk).await?; - let row = db::sqlx::query_as::<_, model::room::RoomCategoryModel>( + let row = db::sqlx::query_as::<_, model::channel::RoomCategoryModel>( "DELETE FROM room_category WHERE id = $1 \ RETURNING id, wk, name, position, collapsed, created_at, updated_at", ) diff --git a/lib/channel/http/handler/forward.rs b/lib/channel/http/handler/forward.rs index 6b9f5e7..8df35c0 100644 --- a/lib/channel/http/handler/forward.rs +++ b/lib/channel/http/handler/forward.rs @@ -37,7 +37,7 @@ impl WsHandler { "forwarded_by": user_id, }); - let row = db::sqlx::query_as::<_, model::room::RoomMessageModel>( + let row = db::sqlx::query_as::<_, model::channel::RoomMessageModel>( "INSERT INTO room_message \ (room, seq, thread, parent, author, content, content_type, system_type, metadata) \ VALUES ($1, $2, NULL, NULL, $3, $4, 'forward', NULL, $5) \ diff --git a/lib/channel/http/handler/helpers.rs b/lib/channel/http/handler/helpers.rs index 944e4e4..2b0c67c 100644 --- a/lib/channel/http/handler/helpers.rs +++ b/lib/channel/http/handler/helpers.rs @@ -136,7 +136,7 @@ impl WsHandler { } #[allow(dead_code)] pub(super) fn message_data( - m: model::room::RoomMessageModel, + m: model::channel::RoomMessageModel, ) -> message::MessageNewService { message::MessageNewService { id: m.id, diff --git a/lib/channel/http/handler/message.rs b/lib/channel/http/handler/message.rs index 74db7c0..f702e4c 100644 --- a/lib/channel/http/handler/message.rs +++ b/lib/channel/http/handler/message.rs @@ -143,7 +143,7 @@ impl WsHandler { if should_create { let seq = bus.inner.seq.seq(room).await?; - let thread_row = db::sqlx::query_as::<_, model::room::RoomThreadModel>( + let thread_row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>( "INSERT INTO room_thread (room, seq, starter_message, title, created_by, created_at, updated_at) \ VALUES ($1, $2, $3, '', $4, now(), now()) \ RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \ @@ -226,7 +226,7 @@ impl WsHandler { let seq = bus.inner.seq.seq(room).await?; let sender = bus.lookup_user(user_id).await?; let sender_for_response = sender.clone(); - let row = db::sqlx::query_as::<_, model::room::RoomMessageModel>( + let row = db::sqlx::query_as::<_, model::channel::RoomMessageModel>( "INSERT INTO room_message (room, seq, thread, parent, author, content, content_type) \ VALUES ($1, $2, $3, $4, $5, $6, $7) \ RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \ @@ -297,7 +297,7 @@ impl WsHandler { if old.author != user_id { return Err(ChannelError::Unauthorized); } - let row = db::sqlx::query_as::<_, model::room::RoomMessageModel>( + let row = db::sqlx::query_as::<_, model::channel::RoomMessageModel>( "UPDATE room_message SET content = $2, edited_at = now(), updated_at = now() \ WHERE id = $1 AND deleted_at IS NULL \ RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \ @@ -367,7 +367,7 @@ impl WsHandler { )); } } - let row = db::sqlx::query_as::<_, model::room::RoomMessageModel>( + let row = db::sqlx::query_as::<_, model::channel::RoomMessageModel>( "UPDATE room_message SET deleted_at = now(), updated_at = now() \ WHERE id = $1 AND deleted_at IS NULL \ RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \ @@ -402,8 +402,8 @@ impl WsHandler { pub(super) async fn load_message( bus: &ChannelBus, message_id: Uuid, - ) -> ChannelResult { - db::sqlx::query_as::<_, model::room::RoomMessageModel>( + ) -> ChannelResult { + db::sqlx::query_as::<_, model::channel::RoomMessageModel>( "SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \ system_type, metadata, edited_at, created_at, updated_at, deleted_at \ FROM room_message WHERE id = $1 AND deleted_at IS NULL", @@ -524,7 +524,7 @@ impl WsHandler { .await?; let starter_id = starter.map(|r| r.0); - db::sqlx::query_as::<_, model::room::RoomMessageModel>( + db::sqlx::query_as::<_, model::channel::RoomMessageModel>( "(SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \ system_type, metadata, edited_at, created_at, updated_at, deleted_at \ FROM room_message \ @@ -548,7 +548,7 @@ impl WsHandler { .fetch_all(bus.inner.db.reader()) .await? } else { - db::sqlx::query_as::<_, model::room::RoomMessageModel>( + db::sqlx::query_as::<_, model::channel::RoomMessageModel>( "(SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \ system_type, metadata, edited_at, created_at, updated_at, deleted_at \ FROM room_message \ diff --git a/lib/channel/http/handler/mod.rs b/lib/channel/http/handler/mod.rs index 095bd8b..3214a37 100644 --- a/lib/channel/http/handler/mod.rs +++ b/lib/channel/http/handler/mod.rs @@ -12,6 +12,7 @@ pub(crate) const MAX_CATEGORY_NAME_LEN: usize = 50; mod helpers; +mod article; mod ban; mod category; mod conversation; @@ -113,10 +114,11 @@ impl WsHandler { public, category, ai_enabled, + channel_type, } => { Self::room_create( bus, user_id, workspace, room_name, public, category, - ai_enabled, + ai_enabled, channel_type, ) .await } @@ -344,6 +346,88 @@ impl WsHandler { ) .await } + WsInMessage::ArticleCreate { + channel, + title, + cover_url, + content, + content_type, + summary, + tags, + status, + } => { + Self::article_create( + bus, user_id, channel, title, cover_url, content, + content_type, summary, tags, status, + ) + .await + } + WsInMessage::ArticleUpdate { + article_id, + title, + cover_url, + content, + content_type, + summary, + tags, + is_pinned, + status, + } => { + Self::article_update( + bus, user_id, article_id, title, cover_url, content, + content_type, summary, tags, is_pinned, status, + ) + .await + } + WsInMessage::ArticleDelete { article_id } => { + Self::article_delete(bus, user_id, article_id).await + } + WsInMessage::ArticleList { + channel, + before, + limit, + } => { + Self::article_list(bus, user_id, channel, before, limit).await + } + WsInMessage::ArticleGet { article_id } => { + Self::article_get(bus, user_id, article_id).await + } + WsInMessage::ArticleLike { article_id, like } => { + Self::article_like(bus, user_id, article_id, like).await + } + WsInMessage::ArticleCommentCreate { + article_id, + content, + parent, + } => { + Self::article_comment_create( + bus, user_id, article_id, content, parent, + ) + .await + } + WsInMessage::ArticleCommentDelete { comment_id } => { + Self::article_comment_delete(bus, user_id, comment_id).await + } + WsInMessage::ArticleCommentList { + article_id, + before, + limit, + } => { + Self::article_comment_list( + bus, user_id, article_id, before, limit, + ) + .await + } + WsInMessage::ArticleLikedUsers { + article_id, + before, + limit, + } => { + Self::article_liked_users( + bus, user_id, article_id, before, limit, + ) + .await + } } } } diff --git a/lib/channel/http/handler/room.rs b/lib/channel/http/handler/room.rs index e880027..deb3309 100644 --- a/lib/channel/http/handler/room.rs +++ b/lib/channel/http/handler/room.rs @@ -15,7 +15,7 @@ impl WsHandler { room: Uuid, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, room).await?; - let row = db::sqlx::query_as::<_, model::room::RoomModel>( + let row = db::sqlx::query_as::<_, model::channel::ChannelModel>( "SELECT id, wk, parent, name, topic, room_type, position, \ is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at \ FROM room WHERE id = $1 AND deleted_at IS NULL", @@ -30,7 +30,7 @@ impl WsHandler { "wk": row.wk, "name": row.name, "topic": row.topic, - "room_type": row.room_type, + "room_type": row.channel_type, "is_private": row.is_private, "is_archived": row.is_archived, "ai_enabled": row.ai_enabled, @@ -49,6 +49,7 @@ impl WsHandler { public: bool, category: Option, ai_enabled: Option, + channel_type: Option, ) -> ChannelResult> { if room_name.is_empty() || room_name.len() > MAX_ROOM_NAME_LEN { return Err(ChannelError::Validation("invalid room name".into())); @@ -56,15 +57,17 @@ impl WsHandler { Self::ensure_workspace_member(bus, user_id, workspace).await?; let is_private = !public; let ai = ai_enabled.unwrap_or(false); - let row = db::sqlx::query_as::<_, model::room::RoomModel>( + let ctype = channel_type.unwrap_or_else(|| "channel".to_string()); + let row = db::sqlx::query_as::<_, model::channel::ChannelModel>( "INSERT INTO room (wk, parent, name, room_type, is_private, ai_enabled, created_by, created_at, updated_at) \ - VALUES ($1, $2, $3, 'channel', $4, $5, $6, now(), now()) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now()) \ RETURNING id, wk, parent, name, topic, room_type, position, \ is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at", ) .bind(workspace) .bind(category) .bind(&room_name) + .bind(&ctype) .bind(is_private) .bind(ai) .bind(user_id) @@ -112,7 +115,7 @@ impl WsHandler { ai_enabled: Option, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, room).await?; - let old = db::sqlx::query_as::<_, model::room::RoomModel>( + let old = db::sqlx::query_as::<_, model::channel::ChannelModel>( "SELECT id, wk, parent, name, topic, room_type, position, \ is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at \ FROM room WHERE id = $1 AND deleted_at IS NULL", @@ -124,7 +127,7 @@ impl WsHandler { let new_private = public.map(|p| !p).unwrap_or(old.is_private); let new_category = category.or(old.parent); let new_ai = ai_enabled.unwrap_or(old.ai_enabled); - let row = db::sqlx::query_as::<_, model::room::RoomModel>( + let row = db::sqlx::query_as::<_, model::channel::ChannelModel>( "UPDATE room SET name = $2, is_private = $3, parent = $4, ai_enabled = $5, updated_at = now() \ WHERE id = $1 AND deleted_at IS NULL \ RETURNING id, wk, parent, name, topic, room_type, position, \ @@ -216,7 +219,7 @@ impl WsHandler { room: Uuid, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, room).await?; - let old = db::sqlx::query_as::<_, model::room::RoomModel>( + let old = db::sqlx::query_as::<_, model::channel::ChannelModel>( "SELECT id, wk, parent, name, topic, room_type, position, \ is_private, is_archived, created_by, created_at, updated_at, deleted_at \ FROM room WHERE id = $1 AND deleted_at IS NULL", @@ -227,7 +230,7 @@ impl WsHandler { if old.created_by != user_id { return Err(ChannelError::AccessDenied); } - let row = db::sqlx::query_as::<_, model::room::RoomModel>( + let row = db::sqlx::query_as::<_, model::channel::ChannelModel>( "UPDATE room SET deleted_at = now(), updated_at = now() \ WHERE id = $1 AND deleted_at IS NULL \ RETURNING id, wk, parent, name, topic, room_type, position, \ diff --git a/lib/channel/http/handler/thread.rs b/lib/channel/http/handler/thread.rs index 11c6bcb..b02b05b 100644 --- a/lib/channel/http/handler/thread.rs +++ b/lib/channel/http/handler/thread.rs @@ -102,7 +102,7 @@ impl WsHandler { .await?; let parent_msg_id = parent_id.ok_or(ChannelError::RoomNotFound)?.0; let seq = bus.inner.seq.seq(room).await?; - let row = db::sqlx::query_as::<_, model::room::RoomThreadModel>( + let row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>( "INSERT INTO room_thread (room, seq, starter_message, title, created_by, created_at, updated_at) \ VALUES ($1, $2, $3, '', $4, now(), now()) \ RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \ @@ -150,7 +150,7 @@ impl WsHandler { .await? .ok_or(ChannelError::RoomNotFound)?; Self::ensure_room_access(bus, user_id, existing.0).await?; - let row = db::sqlx::query_as::<_, model::room::RoomThreadModel>( + let row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>( "UPDATE room_thread SET locked = true, updated_at = now() \ WHERE id = $1 \ RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \ @@ -193,7 +193,7 @@ impl WsHandler { .await? .ok_or(ChannelError::RoomNotFound)?; Self::ensure_room_access(bus, user_id, existing.0).await?; - let row = db::sqlx::query_as::<_, model::room::RoomThreadModel>( + let row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>( "UPDATE room_thread SET archived = true, archived_at = now(), updated_at = now() \ WHERE id = $1 \ RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \ diff --git a/lib/channel/http/out_event.rs b/lib/channel/http/out_event.rs index 1cabb64..ff5db8a 100644 --- a/lib/channel/http/out_event.rs +++ b/lib/channel/http/out_event.rs @@ -2,7 +2,7 @@ use serde::Serialize; use uuid::Uuid; use crate::event::{ - RoomInfo, WorkspaceInfo, attachment, ban, category, conversation, draft, + RoomInfo, WorkspaceInfo, article, attachment, ban, category, conversation, draft, forward, invite, member, message, message_read, notify, pin, presence, reaction, rooms, search, star, thread, voice, workspace, }; @@ -244,6 +244,46 @@ pub enum WsOutEvent { room: RoomInfo, data: forward::MessageForwardedService, }, + ArticleCreated { + room: RoomInfo, + data: article::ArticleCreatedService, + }, + ArticleUpdated { + room: RoomInfo, + data: article::ArticleUpdatedService, + }, + ArticleDeleted { + room: RoomInfo, + data: article::ArticleDeletedService, + }, + ArticleList { + data: article::ArticleListService, + }, + ArticleDetail { + data: article::ArticleDetail, + }, + ArticleLiked { + room: RoomInfo, + data: article::ArticleLikedService, + }, + ArticleUnliked { + room: RoomInfo, + data: article::ArticleUnlikedService, + }, + ArticleCommentCreated { + room: RoomInfo, + data: article::ArticleCommentCreatedService, + }, + ArticleCommentDeleted { + room: RoomInfo, + data: article::ArticleCommentDeletedService, + }, + ArticleCommentList { + data: article::ArticleCommentListService, + }, + ArticleLikedUsers { + data: article::ArticleLikedUsersService, + }, Response { request_id: Uuid, data: serde_json::Value, diff --git a/lib/channel/http/types.rs b/lib/channel/http/types.rs index a82efe2..b80c187 100644 --- a/lib/channel/http/types.rs +++ b/lib/channel/http/types.rs @@ -61,6 +61,7 @@ pub enum WsInMessage { public: bool, category: Option, ai_enabled: Option, + channel_type: Option, }, RoomUpdate { room: Uuid, @@ -250,6 +251,60 @@ pub enum WsInMessage { source_message_id: Uuid, target_room: Uuid, }, + ArticleCreate { + channel: Uuid, + title: String, + cover_url: Option, + content: String, + content_type: Option, + summary: Option, + tags: Option>, + status: Option, + }, + ArticleUpdate { + article_id: Uuid, + title: Option, + cover_url: Option, + content: Option, + content_type: Option, + summary: Option, + tags: Option>, + is_pinned: Option, + status: Option, + }, + ArticleDelete { + article_id: Uuid, + }, + ArticleList { + channel: Uuid, + before: Option, + limit: Option, + }, + ArticleGet { + article_id: Uuid, + }, + ArticleLike { + article_id: Uuid, + like: bool, + }, + ArticleCommentCreate { + article_id: Uuid, + content: String, + parent: Option, + }, + ArticleCommentDelete { + comment_id: Uuid, + }, + ArticleCommentList { + article_id: Uuid, + before: Option, + limit: Option, + }, + ArticleLikedUsers { + article_id: Uuid, + before: Option, + limit: Option, + }, } macro_rules! room_variants { diff --git a/lib/channel/pagination.rs b/lib/channel/pagination.rs index 09022d0..846b7e0 100644 --- a/lib/channel/pagination.rs +++ b/lib/channel/pagination.rs @@ -62,7 +62,7 @@ impl MessagePagination { let messages = match (params.direction, cursor_seq) { (PaginationDirection::Before, Some(seq)) => { - db::sqlx::query_as::<_, model::room::RoomMessageModel>( + db::sqlx::query_as::<_, model::channel::RoomMessageModel>( db::sqlx::AssertSqlSafe(format!( "SELECT {RM_COLUMNS} FROM room_message \ WHERE room = $1 AND seq < $2 AND deleted_at IS NULL AND thread IS NULL \ @@ -76,7 +76,7 @@ impl MessagePagination { .await? } (PaginationDirection::After, Some(seq)) => { - db::sqlx::query_as::<_, model::room::RoomMessageModel>( + db::sqlx::query_as::<_, model::channel::RoomMessageModel>( db::sqlx::AssertSqlSafe(format!( "SELECT {RM_COLUMNS} FROM room_message \ WHERE room = $1 AND seq > $2 AND deleted_at IS NULL AND thread IS NULL \ @@ -90,7 +90,7 @@ impl MessagePagination { .await? } _ => { - db::sqlx::query_as::<_, model::room::RoomMessageModel>( + db::sqlx::query_as::<_, model::channel::RoomMessageModel>( db::sqlx::AssertSqlSafe(format!( "SELECT {RM_COLUMNS} FROM room_message \ WHERE room = $1 AND deleted_at IS NULL AND thread IS NULL \ @@ -148,7 +148,7 @@ impl MessagePagination { message_id: Uuid, context_size: i64, ) -> ChannelResult { - let target = db::sqlx::query_as::<_, model::room::RoomMessageModel>( + let target = db::sqlx::query_as::<_, model::channel::RoomMessageModel>( db::sqlx::AssertSqlSafe(format!( "SELECT {RM_COLUMNS} FROM room_message \ WHERE id = $1 AND room = $2 AND deleted_at IS NULL" @@ -160,7 +160,7 @@ impl MessagePagination { .await? .ok_or(ChannelError::Internal("message not found".to_string()))?; - let before = db::sqlx::query_as::<_, model::room::RoomMessageModel>( + let before = db::sqlx::query_as::<_, model::channel::RoomMessageModel>( db::sqlx::AssertSqlSafe(format!( "SELECT {RM_COLUMNS} FROM room_message \ WHERE room = $1 AND seq < $2 AND deleted_at IS NULL \ @@ -173,7 +173,7 @@ impl MessagePagination { .fetch_all(self.db.reader()) .await?; - let after = db::sqlx::query_as::<_, model::room::RoomMessageModel>( + let after = db::sqlx::query_as::<_, model::channel::RoomMessageModel>( db::sqlx::AssertSqlSafe(format!( "SELECT {RM_COLUMNS} FROM room_message \ WHERE room = $1 AND seq > $2 AND deleted_at IS NULL \ diff --git a/lib/channel/reconnect.rs b/lib/channel/reconnect.rs index e4d3c59..2cbd4e7 100644 --- a/lib/channel/reconnect.rs +++ b/lib/channel/reconnect.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use uuid::Uuid; -use model::room::RoomMessageModel; +use model::channel::RoomMessageModel; use serde::{Deserialize, Serialize}; use crate::ChannelResult; diff --git a/lib/channel/rooms.rs b/lib/channel/rooms.rs index ac02d67..c5ad042 100644 --- a/lib/channel/rooms.rs +++ b/lib/channel/rooms.rs @@ -1,6 +1,6 @@ use cache::AppCache; use db::{AppDatabase, sqlx}; -use model::room::RoomMessageModel; +use model::channel::RoomMessageModel; use serde::Serialize; use uuid::Uuid; @@ -21,6 +21,7 @@ pub struct RoomListItem { pub id: Uuid, pub name: String, pub topic: Option, + /// Maps to DB `room_type` column. Serialized as `room_type` for frontend compat. pub room_type: String, pub is_private: bool, pub ai_enabled: bool, diff --git a/lib/channel/search.rs b/lib/channel/search.rs index 8b52932..0f04246 100644 --- a/lib/channel/search.rs +++ b/lib/channel/search.rs @@ -61,7 +61,7 @@ impl SearchEngine { let total = count.0 as u64; - let messages = db::sqlx::query_as::<_, model::room::RoomMessageModel>( + let messages = db::sqlx::query_as::<_, model::channel::RoomMessageModel>( "SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \ system_type, metadata, edited_at, created_at, updated_at, deleted_at \ FROM room_message \ diff --git a/lib/git/cmd/diff/diff_patch.rs b/lib/git/cmd/diff/diff_patch.rs index 57c1fdb..7fa3a01 100644 --- a/lib/git/cmd/diff/diff_patch.rs +++ b/lib/git/cmd/diff/diff_patch.rs @@ -64,9 +64,7 @@ impl GitBare { // Only diff blobs — skip trees (directories) let entry_mode = change.entry_mode(); if entry_mode.is_tree() { - stats.files_changed += 1; resource_cache.clear_resource_cache_keep_allocation(); - deltas.push(delta); continue; } diff --git a/lib/git/cmd/diff/diff_stats.rs b/lib/git/cmd/diff/diff_stats.rs index cd2f42f..80dbc45 100644 --- a/lib/git/cmd/diff/diff_stats.rs +++ b/lib/git/cmd/diff/diff_stats.rs @@ -63,7 +63,6 @@ impl GitBare { // Skip directories — only diff blobs if change.entry_mode().is_tree() { - stats.files_changed += 1; continue; } diff --git a/lib/git/cmd/diff/diff_tree_to_tree.rs b/lib/git/cmd/diff/diff_tree_to_tree.rs index cf90878..2f7ba53 100644 --- a/lib/git/cmd/diff/diff_tree_to_tree.rs +++ b/lib/git/cmd/diff/diff_tree_to_tree.rs @@ -292,9 +292,7 @@ impl GitBare { // Skip directories — only diff blobs if change.entry_mode().is_tree() { - stats.files_changed += 1; resource_cache.clear_resource_cache_keep_allocation(); - deltas.push(delta); continue; } diff --git a/lib/git/rpc/server.rs b/lib/git/rpc/server.rs index f48a79f..6f6f47f 100644 --- a/lib/git/rpc/server.rs +++ b/lib/git/rpc/server.rs @@ -105,6 +105,7 @@ impl GitServer { }); Server::builder() + .max_frame_size(Some(16 * 1024 * 1024 - 1)) .add_service(archive) .add_service(blame) .add_service(blob) diff --git a/lib/migrate/sql/room/channel_article_down_01.sql b/lib/migrate/sql/room/channel_article_down_01.sql new file mode 100644 index 0000000..7208d2e --- /dev/null +++ b/lib/migrate/sql/room/channel_article_down_01.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_channel_article_author; +DROP INDEX IF EXISTS idx_channel_article_feed; +DROP TABLE IF EXISTS channel_article; diff --git a/lib/migrate/sql/room/channel_article_like_comment_down_01.sql b/lib/migrate/sql/room/channel_article_like_comment_down_01.sql new file mode 100644 index 0000000..08696ee --- /dev/null +++ b/lib/migrate/sql/room/channel_article_like_comment_down_01.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS channel_article_comment; +DROP TABLE IF EXISTS channel_article_like; diff --git a/lib/migrate/sql/room/channel_article_like_comment_up_01.sql b/lib/migrate/sql/room/channel_article_like_comment_up_01.sql new file mode 100644 index 0000000..ac578ae --- /dev/null +++ b/lib/migrate/sql/room/channel_article_like_comment_up_01.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS channel_article_like ( + article UUID NOT NULL REFERENCES channel_article(id) ON DELETE CASCADE, + "user" UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (article, "user") +); + +CREATE INDEX IF NOT EXISTS idx_article_like_user + ON channel_article_like ("user", created_at DESC); + +CREATE TABLE IF NOT EXISTS channel_article_comment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article UUID NOT NULL REFERENCES channel_article(id) ON DELETE CASCADE, + author UUID NOT NULL, + parent UUID REFERENCES channel_article_comment(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_article_comment_article + ON channel_article_comment (article, created_at ASC) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_article_comment_parent + ON channel_article_comment (parent, created_at ASC) + WHERE deleted_at IS NULL; diff --git a/lib/migrate/sql/room/channel_article_up_01.sql b/lib/migrate/sql/room/channel_article_up_01.sql new file mode 100644 index 0000000..2f966ba --- /dev/null +++ b/lib/migrate/sql/room/channel_article_up_01.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS channel_article ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel UUID NOT NULL REFERENCES room(id) ON DELETE CASCADE, + author UUID NOT NULL, + title TEXT NOT NULL, + cover_url TEXT, + content TEXT NOT NULL DEFAULT '', + content_type TEXT NOT NULL DEFAULT 'markdown', + summary TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + is_pinned BOOLEAN NOT NULL DEFAULT FALSE, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + comment_count BIGINT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'published', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- Index for waterfall feed queries: by channel, pinned first, then by created_at desc +CREATE INDEX IF NOT EXISTS idx_channel_article_feed + ON channel_article (channel, is_pinned DESC, created_at DESC) + WHERE deleted_at IS NULL; + +-- Index for author lookup +CREATE INDEX IF NOT EXISTS idx_channel_article_author + ON channel_article (author, created_at DESC); diff --git a/lib/model/channel/channel.rs b/lib/model/channel/channel.rs new file mode 100644 index 0000000..dacb1fd --- /dev/null +++ b/lib/model/channel/channel.rs @@ -0,0 +1,85 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// Channel type discriminator. +/// Text chat channels keep the historical "room" naming for familiarity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChannelType { + /// Text-based chat room + Room, + /// Voice channel + Voice, + /// Waterfall / masonry article post channel + Article, +} + +impl Default for ChannelType { + fn default() -> Self { + Self::Room + } +} + +impl std::fmt::Display for ChannelType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Room => write!(f, "room"), + Self::Voice => write!(f, "voice"), + Self::Article => write!(f, "article"), + } + } +} + +impl From<&str> for ChannelType { + fn from(s: &str) -> Self { + match s { + "voice" => Self::Voice, + "article" => Self::Article, + _ => Self::Room, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)] +pub struct ChannelModel { + pub id: Uuid, + pub wk: Uuid, + pub parent: Option, + pub name: String, + pub topic: Option, + /// Legacy DB column; mapped to `channel_type` in app logic. + #[sqlx(rename = "room_type")] + pub channel_type: String, + pub position: i32, + pub is_private: bool, + pub is_archived: bool, + pub ai_enabled: bool, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +impl ChannelModel { + /// Return the parsed channel type enum. + pub fn channel_kind(&self) -> ChannelType { + ChannelType::from(self.channel_type.as_str()) + } + + /// Convenience: true when this is a text-chat room. + pub fn is_room(&self) -> bool { + self.channel_kind() == ChannelType::Room + } + + /// Convenience: true when this is a voice channel. + pub fn is_voice(&self) -> bool { + self.channel_kind() == ChannelType::Voice + } + + /// Convenience: true when this is an article post channel. + pub fn is_article(&self) -> bool { + self.channel_kind() == ChannelType::Article + } +} diff --git a/lib/model/channel/channel_article.rs b/lib/model/channel/channel_article.rs new file mode 100644 index 0000000..e7a699f --- /dev/null +++ b/lib/model/channel/channel_article.rs @@ -0,0 +1,69 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)] +pub struct ChannelArticleModel { + pub id: Uuid, + pub channel: Uuid, + pub author: Uuid, + pub title: String, + pub cover_url: Option, + pub content: String, + pub content_type: String, + pub summary: Option, + #[serde(default)] + pub tags: Vec, + pub is_pinned: bool, + pub view_count: i64, + pub like_count: i64, + pub comment_count: i64, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateArticlePayload { + pub channel: Uuid, + pub title: String, + pub cover_url: Option, + pub content: String, + pub content_type: Option, + pub summary: Option, + pub tags: Option>, + pub status: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateArticlePayload { + pub title: Option, + pub cover_url: Option, + pub content: Option, + pub content_type: Option, + pub summary: Option, + pub tags: Option>, + pub is_pinned: Option, + pub status: Option, +} + + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ChannelArticleCard { + pub id: Uuid, + pub channel: Uuid, + pub author: Uuid, + pub title: String, + pub cover_url: Option, + pub summary: Option, + pub tags: Vec, + pub like_count: i64, + pub comment_count: i64, + pub view_count: i64, + pub created_at: DateTime, +} diff --git a/lib/model/channel/channel_article_interact.rs b/lib/model/channel/channel_article_interact.rs new file mode 100644 index 0000000..6d1d4e7 --- /dev/null +++ b/lib/model/channel/channel_article_interact.rs @@ -0,0 +1,51 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// A "like" on an article. Composite PK (article, user). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)] +pub struct ArticleLikeModel { + pub article: Uuid, + pub user: Uuid, + pub created_at: DateTime, +} + +/// A comment on an article. Supports nested replies via `parent`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)] +pub struct ArticleCommentModel { + pub id: Uuid, + pub article: Uuid, + pub author: Uuid, + pub parent: Option, + pub content: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// Payload for creating a comment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateCommentPayload { + pub content: String, + pub parent: Option, +} + +/// Paginated comment list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleCommentList { + pub comments: Vec, + pub total: i64, +} + +/// API representation of a comment with author info. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleCommentItem { + pub id: Uuid, + pub article: Uuid, + pub parent: Option, + pub content: String, + pub author: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/lib/model/room/message_read.rs b/lib/model/channel/message_read.rs similarity index 100% rename from lib/model/room/message_read.rs rename to lib/model/channel/message_read.rs diff --git a/lib/model/room/message_star.rs b/lib/model/channel/message_star.rs similarity index 100% rename from lib/model/room/message_star.rs rename to lib/model/channel/message_star.rs diff --git a/lib/model/room/mod.rs b/lib/model/channel/mod.rs similarity index 60% rename from lib/model/room/mod.rs rename to lib/model/channel/mod.rs index 6312b96..47fc29e 100644 --- a/lib/model/room/mod.rs +++ b/lib/model/channel/mod.rs @@ -1,6 +1,8 @@ +pub mod channel; +pub mod channel_article; +pub mod channel_article_interact; pub mod message_read; pub mod message_star; -pub mod room; pub mod room_attachments; pub mod room_categories; pub mod room_mention; @@ -15,7 +17,17 @@ pub mod user_room_state; pub use message_read::MessageReadModel; pub use message_star::MessageStarModel; -pub use room::RoomModel; +pub use channel::ChannelModel; +pub use channel::ChannelType; +pub use channel_article::ChannelArticleModel; +pub use channel_article::ChannelArticleCard; +pub use channel_article::CreateArticlePayload; +pub use channel_article::UpdateArticlePayload; +pub use channel_article_interact::ArticleLikeModel; +pub use channel_article_interact::ArticleCommentModel; +pub use channel_article_interact::ArticleCommentItem; +pub use channel_article_interact::ArticleCommentList; +pub use channel_article_interact::CreateCommentPayload; pub use room_attachments::RoomAttachmentModel; pub use room_categories::RoomCategoryModel; pub use room_mention::RoomMentionModel; diff --git a/lib/model/room/room_attachments.rs b/lib/model/channel/room_attachments.rs similarity index 100% rename from lib/model/room/room_attachments.rs rename to lib/model/channel/room_attachments.rs diff --git a/lib/model/room/room_categories.rs b/lib/model/channel/room_categories.rs similarity index 100% rename from lib/model/room/room_categories.rs rename to lib/model/channel/room_categories.rs diff --git a/lib/model/room/room_mention.rs b/lib/model/channel/room_mention.rs similarity index 100% rename from lib/model/room/room_mention.rs rename to lib/model/channel/room_mention.rs diff --git a/lib/model/room/room_message.rs b/lib/model/channel/room_message.rs similarity index 100% rename from lib/model/room/room_message.rs rename to lib/model/channel/room_message.rs diff --git a/lib/model/room/room_message_edit_history.rs b/lib/model/channel/room_message_edit_history.rs similarity index 100% rename from lib/model/room/room_message_edit_history.rs rename to lib/model/channel/room_message_edit_history.rs diff --git a/lib/model/room/room_permission_overwrite.rs b/lib/model/channel/room_permission_overwrite.rs similarity index 100% rename from lib/model/room/room_permission_overwrite.rs rename to lib/model/channel/room_permission_overwrite.rs diff --git a/lib/model/room/room_pins.rs b/lib/model/channel/room_pins.rs similarity index 100% rename from lib/model/room/room_pins.rs rename to lib/model/channel/room_pins.rs diff --git a/lib/model/room/room_reactions.rs b/lib/model/channel/room_reactions.rs similarity index 100% rename from lib/model/room/room_reactions.rs rename to lib/model/channel/room_reactions.rs diff --git a/lib/model/room/room_server_label.rs b/lib/model/channel/room_server_label.rs similarity index 100% rename from lib/model/room/room_server_label.rs rename to lib/model/channel/room_server_label.rs diff --git a/lib/model/room/room_threads.rs b/lib/model/channel/room_threads.rs similarity index 100% rename from lib/model/room/room_threads.rs rename to lib/model/channel/room_threads.rs diff --git a/lib/model/room/user_room_state.rs b/lib/model/channel/user_room_state.rs similarity index 100% rename from lib/model/room/user_room_state.rs rename to lib/model/channel/user_room_state.rs diff --git a/lib/model/lib.rs b/lib/model/lib.rs index 7cf073d..2aecdbe 100644 --- a/lib/model/lib.rs +++ b/lib/model/lib.rs @@ -7,7 +7,7 @@ pub mod logs; pub mod notify; pub mod pull_request; pub mod repos; -pub mod room; +pub mod channel; pub mod system; pub mod users; pub mod workspace; diff --git a/lib/model/room/room.rs b/lib/model/room/room.rs deleted file mode 100644 index 17e62ad..0000000 --- a/lib/model/room/room.rs +++ /dev/null @@ -1,22 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use uuid::Uuid; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)] -pub struct RoomModel { - pub id: Uuid, - pub wk: Uuid, - pub parent: Option, - pub name: String, - pub topic: Option, - pub room_type: String, - pub position: i32, - pub is_private: bool, - pub is_archived: bool, - pub ai_enabled: bool, - pub created_by: Uuid, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} diff --git a/lib/service/git/diff.rs b/lib/service/git/diff.rs index 12e3a03..fa1f9b6 100644 --- a/lib/service/git/diff.rs +++ b/lib/service/git/diff.rs @@ -3,6 +3,8 @@ use session::Session; use crate::{AppService, error::AppError, git::rpc_err}; +const MAX_GRPC_MSG: usize = 50 * 1024 * 1024; // 50MB + impl AppService { pub async fn git_diff_stats( &self, @@ -14,7 +16,8 @@ impl AppService { options: Option, ) -> Result { let repo = self.git_require_member(ctx, wk_name, repo_name).await?; - let mut client = DiffServiceClient::new(self.git.clone()); + let mut client = DiffServiceClient::new(self.git.clone()) + .max_decoding_message_size(MAX_GRPC_MSG); let resp = client .diff_stats(tonic::Request::new(p::DiffStatsRequest { repo_id: repo.id.to_string(), @@ -38,7 +41,8 @@ impl AppService { options: Option, ) -> Result { let repo = self.git_require_member(ctx, wk_name, repo_name).await?; - let mut client = DiffServiceClient::new(self.git.clone()); + let mut client = DiffServiceClient::new(self.git.clone()) + .max_decoding_message_size(MAX_GRPC_MSG); let resp = client .diff_patch(tonic::Request::new(p::DiffPatchRequest { repo_id: repo.id.to_string(), @@ -62,7 +66,8 @@ impl AppService { options: Option, ) -> Result { let repo = self.git_require_member(ctx, wk_name, repo_name).await?; - let mut client = DiffServiceClient::new(self.git.clone()); + let mut client = DiffServiceClient::new(self.git.clone()) + .max_decoding_message_size(MAX_GRPC_MSG); let resp = client .diff_patch_side_by_side(tonic::Request::new( p::DiffPatchSideBySideRequest { @@ -88,7 +93,8 @@ impl AppService { options: Option, ) -> Result { let repo = self.git_require_member(ctx, wk_name, repo_name).await?; - let mut client = DiffServiceClient::new(self.git.clone()); + let mut client = DiffServiceClient::new(self.git.clone()) + .max_decoding_message_size(MAX_GRPC_MSG); let resp = client .diff_tree_to_tree(tonic::Request::new(p::DiffTreeToTreeRequest { repo_id: repo.id.to_string(), @@ -111,7 +117,8 @@ impl AppService { options: Option, ) -> Result { let repo = self.git_require_member(ctx, wk_name, repo_name).await?; - let mut client = DiffServiceClient::new(self.git.clone()); + let mut client = DiffServiceClient::new(self.git.clone()) + .max_decoding_message_size(MAX_GRPC_MSG); let resp = client .diff_index_to_tree(tonic::Request::new( p::DiffIndexToTreeRequest { diff --git a/openapi.json b/openapi.json index 90327a2..8f12231 100644 --- a/openapi.json +++ b/openapi.json @@ -11295,6 +11295,201 @@ ] } }, + "/api/v1/ws/articles/{article_id}/comments": { + "get": { + "tags": [ + "channel" + ], + "operationId": "channel_article_comment_list", + "parameters": [ + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "article_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Comment list" + } + } + }, + "post": { + "tags": [ + "channel" + ], + "operationId": "channel_article_comment_create", + "parameters": [ + { + "name": "article_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleCommentCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Comment created" + } + } + } + }, + "/api/v1/ws/articles/{article_id}/comments/{comment_id}": { + "delete": { + "tags": [ + "channel" + ], + "operationId": "channel_article_comment_delete", + "parameters": [ + { + "name": "article_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "comment_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Comment deleted" + } + } + } + }, + "/api/v1/ws/articles/{article_id}/like": { + "post": { + "tags": [ + "channel" + ], + "operationId": "channel_article_like", + "parameters": [ + { + "name": "article_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleLikeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Like toggled" + } + } + } + }, + "/api/v1/ws/articles/{article_id}/likes": { + "get": { + "tags": [ + "channel" + ], + "operationId": "channel_article_liked_users", + "parameters": [ + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "article_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of users who liked" + } + } + } + }, "/api/v1/ws/categories/{category_id}": { "delete": { "tags": [ @@ -11351,6 +11546,191 @@ } } }, + "/api/v1/ws/channels/{channel_id}/articles": { + "get": { + "tags": [ + "channel" + ], + "operationId": "channel_article_list", + "parameters": [ + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Article list (waterfall feed)" + } + } + }, + "post": { + "tags": [ + "channel" + ], + "operationId": "channel_article_create", + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Article created" + } + } + } + }, + "/api/v1/ws/channels/{channel_id}/articles/{article_id}": { + "get": { + "tags": [ + "channel" + ], + "operationId": "channel_article_get", + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "article_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Article detail" + } + } + }, + "delete": { + "tags": [ + "channel" + ], + "operationId": "channel_article_delete", + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "article_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Article deleted" + } + } + }, + "patch": { + "tags": [ + "channel" + ], + "operationId": "channel_article_update", + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "article_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Article updated" + } + } + } + }, "/api/v1/ws/csrf": { "get": { "tags": [ @@ -11621,6 +12001,17 @@ } }, "/api/v1/ws/rooms": { + "get": { + "tags": [ + "channel" + ], + "operationId": "channel_list_rooms", + "responses": { + "200": { + "description": "List of rooms" + } + } + }, "post": { "tags": [ "channel" @@ -13556,6 +13947,139 @@ } } }, + "ArticleCommentCreateRequest": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string" + }, + "parent": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "ArticleCreateRequest": { + "type": "object", + "required": [ + "title", + "content" + ], + "properties": { + "content": { + "type": "string" + }, + "content_type": { + "type": [ + "string", + "null" + ] + }, + "cover_url": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "type": [ + "string", + "null" + ] + }, + "tags": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + } + } + }, + "ArticleLikeRequest": { + "type": "object", + "required": [ + "like" + ], + "properties": { + "like": { + "type": "boolean" + } + } + }, + "ArticleUpdateRequest": { + "type": "object", + "properties": { + "content": { + "type": [ + "string", + "null" + ] + }, + "content_type": { + "type": [ + "string", + "null" + ] + }, + "cover_url": { + "type": [ + "string", + "null" + ] + }, + "is_pinned": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "type": [ + "string", + "null" + ] + }, + "tags": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + } + } + }, "AssignIssueUser": { "type": "object", "required": [ @@ -16803,6 +17327,12 @@ ], "format": "uuid" }, + "channel_type": { + "type": [ + "string", + "null" + ] + }, "public": { "type": "boolean" }, diff --git a/src/App.css b/src/App.css index f90339d..49b42df 100644 --- a/src/App.css +++ b/src/App.css @@ -17,6 +17,8 @@ } } + + .hero { position: relative; diff --git a/src/App.tsx b/src/App.tsx index 2a31406..e21f1e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ import { PersonalShell, WorkspaceShell, SettingsShell } from "@/components/shell import WorkspaceRepositoriesPage from "@/page/workspace/repositories"; import WorkspaceIssuesPage from "@/page/workspace/issues"; import IssueDetailPage from "@/page/workspace/issues/detail"; -import RepoLayout from "@/page/workspace/repo/layout"; +import RepoLayout, { RepoIndexRedirect } from "@/page/workspace/repo/layout"; import CodeTab from "@/page/workspace/repo/code"; import CommitsTab from "@/page/workspace/repo/commits"; import BranchesTab from "@/page/workspace/repo/branches"; @@ -88,7 +88,7 @@ function App() { path: "repo/:repoName", element: , children: [ - { index: true, element: }, + { index: true, element: }, { path: "code", element: }, { path: "readme", element: }, { path: "commits", element: }, diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts index 2224503..5b5c09c 100644 --- a/src/client/endpoints.ts +++ b/src/client/endpoints.ts @@ -34,6 +34,10 @@ import type { AiModelVersionResponse, AiProviderResponse, ApproveWorkspaceJoinApply, + ArticleCommentCreateRequest, + ArticleCreateRequest, + ArticleLikeRequest, + ArticleUpdateRequest, AssignIssueUser, AssignPrUser, AuthCaptchaParams, @@ -52,6 +56,9 @@ import type { CaptchaResponse, CategoryCreateRequest, CategoryUpdateRequest, + ChannelArticleCommentListParams, + ChannelArticleLikedUsersParams, + ChannelArticleListParams, ChannelListMessagesParams, ChannelMessagesAroundParams, ChannelMissedMessagesParams, @@ -2514,6 +2521,57 @@ const gitListDeliveries = ( ); } +const channelArticleCommentList = ( + articleId: string, + params?: ChannelArticleCommentListParams, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.get( + `/api/v1/ws/articles/${articleId}/comments`,{ + ...options, + params: {...params, ...options?.params},} + ); + } + +const channelArticleCommentCreate = ( + articleId: string, + articleCommentCreateRequest: ArticleCommentCreateRequest, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.post( + `/api/v1/ws/articles/${articleId}/comments`, + articleCommentCreateRequest,options + ); + } + +const channelArticleCommentDelete = ( + articleId: string, + commentId: string, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.delete( + `/api/v1/ws/articles/${articleId}/comments/${commentId}`,options + ); + } + +const channelArticleLike = ( + articleId: string, + articleLikeRequest: ArticleLikeRequest, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.post( + `/api/v1/ws/articles/${articleId}/like`, + articleLikeRequest,options + ); + } + +const channelArticleLikedUsers = ( + articleId: string, + params?: ChannelArticleLikedUsersParams, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.get( + `/api/v1/ws/articles/${articleId}/likes`,{ + ...options, + params: {...params, ...options?.params},} + ); + } + const channelCategoryDelete = ( categoryId: string, options?: AxiosRequestConfig ): Promise> => { @@ -2532,6 +2590,56 @@ const channelCategoryUpdate = ( ); } +const channelArticleList = ( + channelId: string, + params?: ChannelArticleListParams, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.get( + `/api/v1/ws/channels/${channelId}/articles`,{ + ...options, + params: {...params, ...options?.params},} + ); + } + +const channelArticleCreate = ( + channelId: string, + articleCreateRequest: ArticleCreateRequest, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.post( + `/api/v1/ws/channels/${channelId}/articles`, + articleCreateRequest,options + ); + } + +const channelArticleGet = ( + channelId: string, + articleId: string, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.get( + `/api/v1/ws/channels/${channelId}/articles/${articleId}`,options + ); + } + +const channelArticleDelete = ( + channelId: string, + articleId: string, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.delete( + `/api/v1/ws/channels/${channelId}/articles/${articleId}`,options + ); + } + +const channelArticleUpdate = ( + channelId: string, + articleId: string, + articleUpdateRequest: ArticleUpdateRequest, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.patch( + `/api/v1/ws/channels/${channelId}/articles/${articleId}`, + articleUpdateRequest,options + ); + } + const channelCsrfToken = ( options?: AxiosRequestConfig ): Promise> => { @@ -2636,6 +2744,14 @@ const channelPresenceUpdate = ( ); } +const channelListRooms = ( + options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.get( + `/api/v1/ws/rooms`,options + ); + } + const channelRoomCreate = ( roomCreateRequest: RoomCreateRequest, options?: AxiosRequestConfig ): Promise> => { @@ -2968,7 +3084,7 @@ const channelCategoryCreate = ( ); } -return {agentListAllConversations,agentGetConversation,agentDeleteConversation,agentUpdateConversation,agentListMessages,agentSendMessage,agentStreamAgent,agentListSessions,agentCreateSession,agentGetSession,agentDeleteSession,agentUpdateSession,agentListConversations,agentCreateConversation,aiListModels,aiGetModel,aiGetCard,aiListDiscussions,aiListLikes,aiListTags,aiListVersions,aiListProviders,aiGetProvider,authStatus2fa,authDisable2fa,authRegenerateBackupCodes,authEnable2fa,authVerify2fa,authCaptcha,authGetEmail,authEmailChangeRequest,authEmailVerify,authLogin,authLogout,authMe,authRsa,authRegister,authResetPasswordRequest,authResetPasswordVerify,search,userListAccessTokens,userCreateAccessToken,userUpdateAccessToken,userRevokeAccessToken,userUploadAvatar,userConfig,userUpdateAccessibility,userUpdateAppearance,userUpdateNotification,userUpdatePrivacy,userUpdateProfile,userContributionHeatmap,userInvalidateChpcCache,userListSshKeys,userAddSshKey,userUpdateSshKey,userRevokeSshKey,usersUserAvatar,usersBlockedList,usersBlockUser,usersUserChpc,usersFollowUser,usersFollowers,usersFollowing,usersUserPublic,usersRelationStatus,usersRelationCounts,usersUserSummary,usersUnblockUser,usersUnfollowUser,workspaceCreateWorkspace,workspaceMyJoinApplies,workspaceMyWorkspaces,workspaceGetWorkspace,workspaceUpdateWorkspace,workspaceGetAvatar,workspaceUploadAvatar,workspaceListGroups,workspaceCreateGroup,workspaceUpdateGroup,workspaceDeleteGroup,workspaceListGroupMembers,workspaceAddGroupMember,workspaceRemoveGroupMember,issuesListIssues,issuesCreateIssue,issuesGetIssue,issuesUpdateIssue,issuesDeleteIssue,issuesAssignUser,issuesUnassignUser,issuesCloseIssue,issuesListComments,issuesCreateComment,issuesUpdateComment,issuesDeleteComment,issuesAddCommentReaction,issuesRemoveCommentReaction,issuesListEvents,issuesAddIssueLabel,issuesRemoveIssueLabel,issuesSetIssueMilestone,issuesClearIssueMilestone,issuesBindPullRequest,issuesUnbindPullRequest,issuesAddReaction,issuesRemoveReaction,issuesReopenIssue,issuesBindRepo,issuesUnbindRepo,workspaceJoinStrategy,workspaceUpdateJoinStrategy,workspaceListJoinApplies,workspaceApproveJoin,workspaceApplyJoin,workspaceCancelJoin,issuesListLabels,issuesCreateLabel,issuesUpdateLabel,issuesDeleteLabel,workspaceListMembers,workspaceAddMember,workspaceUpdateMember,workspaceRemoveMember,issuesListMilestones,issuesCreateMilestone,issuesUpdateMilestone,issuesDeleteMilestone,gitListRepos,gitCreateRepo,gitCloneRepo,gitGetRepo,gitUpdateRepo,gitDeleteRepo,gitArchiveRepo,gitCombinedStatus,gitListStatuses,gitCompare,gitGetContents,gitUpdateContents,gitCreateContents,gitDeleteContents,gitListForks,gitCreateFork,gitArchive,gitBlameFile,gitBlobUpload,gitBlobInfo,gitListBranches,gitForkBranch,gitBranchInfo,gitDeleteBranch,gitRenameBranch,gitAheadBehind,gitBranchUpstream,gitListCommits,gitCherryPick,gitCommitHistory,gitCommitWalk,gitCommitInfo,gitTreeEntryByPathFromCommit,gitListContributors,gitDiff,gitDiffBranches,gitGetLanguages,gitGetReadme,gitListRefs,gitStarStatus,gitStarRepo,gitUnstarRepo,gitListTags,gitInitTag,gitTagInfo,gitDeleteTag,gitUpdateTag,gitTreeEntries,gitTreeEntryByPath,gitWatchStatus,gitWatchRepo,gitUnwatchRepo,gitListProtects,gitCreateProtect,gitUpdateProtect,gitDeleteProtect,pullRequestListPrs,pullRequestCreatePr,pullRequestGetPr,pullRequestDeletePr,pullRequestUpdatePr,pullRequestAssignUser,pullRequestUnassignUser,pullRequestListComments,pullRequestCreateComment,pullRequestUpdateComment,pullRequestDeleteComment,pullRequestAddCommentReaction,pullRequestRemoveCommentReaction,pullRequestAddLabel,pullRequestRemoveLabel,pullRequestMergeAnalysis,pullRequestMergePr,pullRequestAddReaction,pullRequestRemoveReaction,pullRequestCreateReviewComment,pullRequestListReviews,pullRequestCreateReview,pullRequestDismissReview,pullRequestUpdateBranch,gitListReleases,gitCreateRelease,gitGetReleaseByTag,gitDeleteReleaseByTag,gitGetRelease,gitDeleteRelease,gitUpdateRelease,gitCreateStatus,gitGetTopics,gitUpdateTopics,gitTransferRepo,gitListWebhooks,gitCreateWebhook,gitUpdateWebhook,gitDeleteWebhook,gitListDeliveries,channelCategoryDelete,channelCategoryUpdate,channelCsrfToken,channelCustomStatusUpdate,channelInviteCreate,channelInviteAccept,channelInviteRevoke,channelRevokeMessage,channelUpdateMessage,channelNotificationMarkAllRead,channelNotificationArchive,channelNotificationMarkRead,channelPing,channelPresenceUpdate,channelRoomCreate,channelRoomGet,channelRoomDelete,channelRoomUpdate,channelDndUpdate,channelDraftSave,channelDraftClear,channelAccessGrant,channelAccessRevoke,channelListMessages,channelCreateMessage,channelMessagesAround,channelMissedMessages,channelPinAdd,channelPinRemove,channelReactionAdd,channelReactionRemove,channelReadReceipt,channelScreenShare,channelSubscribe,channelUnsubscribe,channelThreadCreate,channelTyping,channelVoiceDeaf,channelVoiceJoin,channelVoiceLeave,channelVoiceMute,channelSearch,channelThreadArchive,channelThreadResolve,channelGenerateToken,channelUserSummary,channelBanCreate,channelBanRemove,channelCategoryCreate}}; +return {agentListAllConversations,agentGetConversation,agentDeleteConversation,agentUpdateConversation,agentListMessages,agentSendMessage,agentStreamAgent,agentListSessions,agentCreateSession,agentGetSession,agentDeleteSession,agentUpdateSession,agentListConversations,agentCreateConversation,aiListModels,aiGetModel,aiGetCard,aiListDiscussions,aiListLikes,aiListTags,aiListVersions,aiListProviders,aiGetProvider,authStatus2fa,authDisable2fa,authRegenerateBackupCodes,authEnable2fa,authVerify2fa,authCaptcha,authGetEmail,authEmailChangeRequest,authEmailVerify,authLogin,authLogout,authMe,authRsa,authRegister,authResetPasswordRequest,authResetPasswordVerify,search,userListAccessTokens,userCreateAccessToken,userUpdateAccessToken,userRevokeAccessToken,userUploadAvatar,userConfig,userUpdateAccessibility,userUpdateAppearance,userUpdateNotification,userUpdatePrivacy,userUpdateProfile,userContributionHeatmap,userInvalidateChpcCache,userListSshKeys,userAddSshKey,userUpdateSshKey,userRevokeSshKey,usersUserAvatar,usersBlockedList,usersBlockUser,usersUserChpc,usersFollowUser,usersFollowers,usersFollowing,usersUserPublic,usersRelationStatus,usersRelationCounts,usersUserSummary,usersUnblockUser,usersUnfollowUser,workspaceCreateWorkspace,workspaceMyJoinApplies,workspaceMyWorkspaces,workspaceGetWorkspace,workspaceUpdateWorkspace,workspaceGetAvatar,workspaceUploadAvatar,workspaceListGroups,workspaceCreateGroup,workspaceUpdateGroup,workspaceDeleteGroup,workspaceListGroupMembers,workspaceAddGroupMember,workspaceRemoveGroupMember,issuesListIssues,issuesCreateIssue,issuesGetIssue,issuesUpdateIssue,issuesDeleteIssue,issuesAssignUser,issuesUnassignUser,issuesCloseIssue,issuesListComments,issuesCreateComment,issuesUpdateComment,issuesDeleteComment,issuesAddCommentReaction,issuesRemoveCommentReaction,issuesListEvents,issuesAddIssueLabel,issuesRemoveIssueLabel,issuesSetIssueMilestone,issuesClearIssueMilestone,issuesBindPullRequest,issuesUnbindPullRequest,issuesAddReaction,issuesRemoveReaction,issuesReopenIssue,issuesBindRepo,issuesUnbindRepo,workspaceJoinStrategy,workspaceUpdateJoinStrategy,workspaceListJoinApplies,workspaceApproveJoin,workspaceApplyJoin,workspaceCancelJoin,issuesListLabels,issuesCreateLabel,issuesUpdateLabel,issuesDeleteLabel,workspaceListMembers,workspaceAddMember,workspaceUpdateMember,workspaceRemoveMember,issuesListMilestones,issuesCreateMilestone,issuesUpdateMilestone,issuesDeleteMilestone,gitListRepos,gitCreateRepo,gitCloneRepo,gitGetRepo,gitUpdateRepo,gitDeleteRepo,gitArchiveRepo,gitCombinedStatus,gitListStatuses,gitCompare,gitGetContents,gitUpdateContents,gitCreateContents,gitDeleteContents,gitListForks,gitCreateFork,gitArchive,gitBlameFile,gitBlobUpload,gitBlobInfo,gitListBranches,gitForkBranch,gitBranchInfo,gitDeleteBranch,gitRenameBranch,gitAheadBehind,gitBranchUpstream,gitListCommits,gitCherryPick,gitCommitHistory,gitCommitWalk,gitCommitInfo,gitTreeEntryByPathFromCommit,gitListContributors,gitDiff,gitDiffBranches,gitGetLanguages,gitGetReadme,gitListRefs,gitStarStatus,gitStarRepo,gitUnstarRepo,gitListTags,gitInitTag,gitTagInfo,gitDeleteTag,gitUpdateTag,gitTreeEntries,gitTreeEntryByPath,gitWatchStatus,gitWatchRepo,gitUnwatchRepo,gitListProtects,gitCreateProtect,gitUpdateProtect,gitDeleteProtect,pullRequestListPrs,pullRequestCreatePr,pullRequestGetPr,pullRequestDeletePr,pullRequestUpdatePr,pullRequestAssignUser,pullRequestUnassignUser,pullRequestListComments,pullRequestCreateComment,pullRequestUpdateComment,pullRequestDeleteComment,pullRequestAddCommentReaction,pullRequestRemoveCommentReaction,pullRequestAddLabel,pullRequestRemoveLabel,pullRequestMergeAnalysis,pullRequestMergePr,pullRequestAddReaction,pullRequestRemoveReaction,pullRequestCreateReviewComment,pullRequestListReviews,pullRequestCreateReview,pullRequestDismissReview,pullRequestUpdateBranch,gitListReleases,gitCreateRelease,gitGetReleaseByTag,gitDeleteReleaseByTag,gitGetRelease,gitDeleteRelease,gitUpdateRelease,gitCreateStatus,gitGetTopics,gitUpdateTopics,gitTransferRepo,gitListWebhooks,gitCreateWebhook,gitUpdateWebhook,gitDeleteWebhook,gitListDeliveries,channelArticleCommentList,channelArticleCommentCreate,channelArticleCommentDelete,channelArticleLike,channelArticleLikedUsers,channelCategoryDelete,channelCategoryUpdate,channelArticleList,channelArticleCreate,channelArticleGet,channelArticleDelete,channelArticleUpdate,channelCsrfToken,channelCustomStatusUpdate,channelInviteCreate,channelInviteAccept,channelInviteRevoke,channelRevokeMessage,channelUpdateMessage,channelNotificationMarkAllRead,channelNotificationArchive,channelNotificationMarkRead,channelPing,channelPresenceUpdate,channelListRooms,channelRoomCreate,channelRoomGet,channelRoomDelete,channelRoomUpdate,channelDndUpdate,channelDraftSave,channelDraftClear,channelAccessGrant,channelAccessRevoke,channelListMessages,channelCreateMessage,channelMessagesAround,channelMissedMessages,channelPinAdd,channelPinRemove,channelReactionAdd,channelReactionRemove,channelReadReceipt,channelScreenShare,channelSubscribe,channelUnsubscribe,channelThreadCreate,channelTyping,channelVoiceDeaf,channelVoiceJoin,channelVoiceLeave,channelVoiceMute,channelSearch,channelThreadArchive,channelThreadResolve,channelGenerateToken,channelUserSummary,channelBanCreate,channelBanRemove,channelCategoryCreate}}; export type AgentListAllConversationsResult = AxiosResponse export type AgentGetConversationResult = AxiosResponse export type AgentDeleteConversationResult = AxiosResponse @@ -3193,8 +3309,18 @@ export type GitCreateWebhookResult = AxiosResponse export type GitUpdateWebhookResult = AxiosResponse export type GitDeleteWebhookResult = AxiosResponse export type GitListDeliveriesResult = AxiosResponse +export type ChannelArticleCommentListResult = AxiosResponse +export type ChannelArticleCommentCreateResult = AxiosResponse +export type ChannelArticleCommentDeleteResult = AxiosResponse +export type ChannelArticleLikeResult = AxiosResponse +export type ChannelArticleLikedUsersResult = AxiosResponse export type ChannelCategoryDeleteResult = AxiosResponse export type ChannelCategoryUpdateResult = AxiosResponse +export type ChannelArticleListResult = AxiosResponse +export type ChannelArticleCreateResult = AxiosResponse +export type ChannelArticleGetResult = AxiosResponse +export type ChannelArticleDeleteResult = AxiosResponse +export type ChannelArticleUpdateResult = AxiosResponse export type ChannelCsrfTokenResult = AxiosResponse export type ChannelCustomStatusUpdateResult = AxiosResponse export type ChannelInviteCreateResult = AxiosResponse @@ -3207,6 +3333,7 @@ export type ChannelNotificationArchiveResult = AxiosResponse export type ChannelNotificationMarkReadResult = AxiosResponse export type ChannelPingResult = AxiosResponse export type ChannelPresenceUpdateResult = AxiosResponse +export type ChannelListRoomsResult = AxiosResponse export type ChannelRoomCreateResult = AxiosResponse export type ChannelRoomGetResult = AxiosResponse export type ChannelRoomDeleteResult = AxiosResponse diff --git a/src/client/models/articleCommentCreateRequest.ts b/src/client/models/articleCommentCreateRequest.ts new file mode 100644 index 0000000..34a872b --- /dev/null +++ b/src/client/models/articleCommentCreateRequest.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.12.3 🍺 + * Do not edit manually. + * GitDataAI API + * GitDataAI platform REST API + * OpenAPI spec version: 1.0.0 + */ + +export interface ArticleCommentCreateRequest { + content: string; + /** @nullable */ + parent?: string | null; +} diff --git a/src/client/models/articleCreateRequest.ts b/src/client/models/articleCreateRequest.ts new file mode 100644 index 0000000..7601562 --- /dev/null +++ b/src/client/models/articleCreateRequest.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v8.12.3 🍺 + * Do not edit manually. + * GitDataAI API + * GitDataAI platform REST API + * OpenAPI spec version: 1.0.0 + */ + +export interface ArticleCreateRequest { + content: string; + /** @nullable */ + content_type?: string | null; + /** @nullable */ + cover_url?: string | null; + /** @nullable */ + status?: string | null; + /** @nullable */ + summary?: string | null; + /** @nullable */ + tags?: string[] | null; + title: string; +} diff --git a/src/client/models/articleLikeRequest.ts b/src/client/models/articleLikeRequest.ts new file mode 100644 index 0000000..7db7459 --- /dev/null +++ b/src/client/models/articleLikeRequest.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.12.3 🍺 + * Do not edit manually. + * GitDataAI API + * GitDataAI platform REST API + * OpenAPI spec version: 1.0.0 + */ + +export interface ArticleLikeRequest { + like: boolean; +} diff --git a/src/client/models/articleUpdateRequest.ts b/src/client/models/articleUpdateRequest.ts new file mode 100644 index 0000000..497a13b --- /dev/null +++ b/src/client/models/articleUpdateRequest.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval v8.12.3 🍺 + * Do not edit manually. + * GitDataAI API + * GitDataAI platform REST API + * OpenAPI spec version: 1.0.0 + */ + +export interface ArticleUpdateRequest { + /** @nullable */ + content?: string | null; + /** @nullable */ + content_type?: string | null; + /** @nullable */ + cover_url?: string | null; + /** @nullable */ + is_pinned?: boolean | null; + /** @nullable */ + status?: string | null; + /** @nullable */ + summary?: string | null; + /** @nullable */ + tags?: string[] | null; + /** @nullable */ + title?: string | null; +} diff --git a/src/client/models/channelArticleCommentListParams.ts b/src/client/models/channelArticleCommentListParams.ts new file mode 100644 index 0000000..00e51dc --- /dev/null +++ b/src/client/models/channelArticleCommentListParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.12.3 🍺 + * Do not edit manually. + * GitDataAI API + * GitDataAI platform REST API + * OpenAPI spec version: 1.0.0 + */ + +export type ChannelArticleCommentListParams = { +/** + * @nullable + */ +before?: string | null; +/** + * @nullable + */ +limit?: number | null; +}; diff --git a/src/client/models/channelArticleLikedUsersParams.ts b/src/client/models/channelArticleLikedUsersParams.ts new file mode 100644 index 0000000..eed8c72 --- /dev/null +++ b/src/client/models/channelArticleLikedUsersParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.12.3 🍺 + * Do not edit manually. + * GitDataAI API + * GitDataAI platform REST API + * OpenAPI spec version: 1.0.0 + */ + +export type ChannelArticleLikedUsersParams = { +/** + * @nullable + */ +before?: string | null; +/** + * @nullable + */ +limit?: number | null; +}; diff --git a/src/client/models/channelArticleListParams.ts b/src/client/models/channelArticleListParams.ts new file mode 100644 index 0000000..d5ca3a8 --- /dev/null +++ b/src/client/models/channelArticleListParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.12.3 🍺 + * Do not edit manually. + * GitDataAI API + * GitDataAI platform REST API + * OpenAPI spec version: 1.0.0 + */ + +export type ChannelArticleListParams = { +/** + * @nullable + */ +before?: string | null; +/** + * @nullable + */ +limit?: number | null; +}; diff --git a/src/client/models/index.ts b/src/client/models/index.ts index 8fbabdd..299bd0c 100644 --- a/src/client/models/index.ts +++ b/src/client/models/index.ts @@ -31,6 +31,10 @@ export * from './aiModelResponse'; export * from './aiModelVersionResponse'; export * from './aiProviderResponse'; export * from './approveWorkspaceJoinApply'; +export * from './articleCommentCreateRequest'; +export * from './articleCreateRequest'; +export * from './articleLikeRequest'; +export * from './articleUpdateRequest'; export * from './assignIssueUser'; export * from './assignPrUser'; export * from './authCaptchaParams'; @@ -52,6 +56,9 @@ export * from './captchaQuery'; export * from './captchaResponse'; export * from './categoryCreateRequest'; export * from './categoryUpdateRequest'; +export * from './channelArticleCommentListParams'; +export * from './channelArticleLikedUsersParams'; +export * from './channelArticleListParams'; export * from './channelListMessagesParams'; export * from './channelMessagesAroundParams'; export * from './channelMissedMessagesParams'; diff --git a/src/client/models/roomCreateRequest.ts b/src/client/models/roomCreateRequest.ts index 916f84a..089b497 100644 --- a/src/client/models/roomCreateRequest.ts +++ b/src/client/models/roomCreateRequest.ts @@ -11,6 +11,8 @@ export interface RoomCreateRequest { ai_enabled?: boolean | null; /** @nullable */ category?: string | null; + /** @nullable */ + channel_type?: string | null; public: boolean; room_name: string; workspace: string; diff --git a/src/components/right-drawer.tsx b/src/components/right-drawer.tsx new file mode 100644 index 0000000..37ac073 --- /dev/null +++ b/src/components/right-drawer.tsx @@ -0,0 +1,87 @@ +import { useEffect } from "react"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +type Props = { + open: boolean; + onClose: () => void; + title?: React.ReactNode; + /** Extra actions in the header, right of title */ + actions?: React.ReactNode; + children: React.ReactNode; + /** Width class, e.g. "max-w-[720px]" */ + width?: string; +}; + +/** + * Generic right-side drawer panel with backdrop. + * Self-manages escape key and body scroll lock. + */ +export default function RightDrawer({ + open, + onClose, + title, + actions, + children, + width = "max-w-[720px]", +}: Props) { + // Escape to close + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + // Lock body scroll + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+ + {typeof title === "string" ? ( + + {title} + + ) : ( +
{title}
+ )} + {actions} +
+ + {/* Body */} + {children} +
+
+ ); +} diff --git a/src/hooks/use-drawer.ts b/src/hooks/use-drawer.ts new file mode 100644 index 0000000..874baec --- /dev/null +++ b/src/hooks/use-drawer.ts @@ -0,0 +1,8 @@ +import { useCallback, useState } from "react"; + +export function useDrawer(initialOpen = false) { + const [open, setOpen] = useState(initialOpen); + const openDrawer = useCallback(() => setOpen(true), []); + const closeDrawer = useCallback(() => setOpen(false), []); + return { open, openDrawer, closeDrawer }; +} diff --git a/src/page/workspace/channel/article-card.tsx b/src/page/workspace/channel/article-card.tsx new file mode 100644 index 0000000..5da4cea --- /dev/null +++ b/src/page/workspace/channel/article-card.tsx @@ -0,0 +1,140 @@ +import { Heart, MessageCircle, Eye, Pin } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + type ArticleItem, + articleColor, + articleInitial, + formatRelativeTime, + formatCount, +} from "./article-types"; + +type Props = { + article: ArticleItem; + onClick?: (id: string) => void; + className?: string; +}; + +export default function ArticleCard({ article, onClick, className }: Props) { + const authorName = + article.author.display_name || article.author.username || "Anonymous"; + const color = articleColor(authorName); + const initial = articleInitial(authorName); + + return ( +
onClick?.(article.id)} + > + {/* Cover image */} +
+ {article.cover_url ? ( + {article.title} + ) : ( +
+ + {initial} + +
+ )} + + {/* Pinned badge */} + {article.is_pinned && ( + + + Pinned + + )} +
+ + {/* Body */} +
+ {/* Tags */} + {article.tags.length > 0 && ( +
+ {article.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )} + + {/* Title */} +

+ {article.title} +

+ + {/* Summary */} + {article.summary && ( +

+ {article.summary} +

+ )} + + {/* Spacer */} +
+ + {/* Author + Stats */} +
+ {/* Author avatar */} + {article.author.avatar_url ? ( + {authorName} + ) : ( +
+ {initial} +
+ )} + + {authorName} + + +
+ + + {formatCount(article.view_count)} + + + + {formatCount(article.like_count)} + + + + {formatCount(article.comment_count)} + +
+
+ + {/* Time */} +
+ {formatRelativeTime(article.created_at)} +
+
+
+ ); +} diff --git a/src/page/workspace/channel/article-composer.tsx b/src/page/workspace/channel/article-composer.tsx new file mode 100644 index 0000000..4c224f5 --- /dev/null +++ b/src/page/workspace/channel/article-composer.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { X, Loader2, ImagePlus, Plus, Save, Trash2 } from "lucide-react"; +import { api } from "@/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { ArticleDraft } from "./use-article-draft"; +import type { ArticleItem } from "./article-types"; + +type Props = { + channel: string; + draft: ArticleDraft | null; + onDraftChange: (draft: ArticleDraft) => void; + onClearDraft: () => void; + onClose: () => void; + onCreated: (article: ArticleItem) => void; +}; + +export default function ArticleComposer({ + channel, + draft, + onDraftChange, + onClearDraft, + onClose, + onCreated, +}: Props) { + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [savedAt, setSavedAt] = useState(null); + const draftRef = useRef(draft); + + // Sync external draft into local ref + useEffect(() => { + draftRef.current = draft; + }, [draft]); + + const update = useCallback( + (patch: Partial) => { + const next = { ...(draftRef.current ?? { channel, title: "", coverUrl: "", content: "", summary: "", tags: [] }), ...patch }; + draftRef.current = next; + onDraftChange(next); + }, + [channel, onDraftChange], + ); + + // Auto-save indicator + const autoSaveTimer = useRef>(); + useEffect(() => { + if (!draft) return; + if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); + autoSaveTimer.current = setTimeout(() => { + setSavedAt(new Date().toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })); + }, 800); + return () => { + if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); + }; + }, [draft]); + + const handlePublish = useCallback(async () => { + const cur = draftRef.current; + if (!cur?.title?.trim()) { + setError("Title is required"); + return; + } + if (!cur?.content?.trim()) { + setError("Content is required"); + return; + } + setSaving(true); + setError(""); + try { + const res = await api.post>( + `/api/v1/ws/channels/${channel}/articles`, + { + title: cur.title.trim(), + cover_url: cur.coverUrl.trim() || null, + content: cur.content.trim(), + summary: cur.summary.trim() || null, + tags: cur.tags.length > 0 ? cur.tags : null, + }, + ); + onClearDraft(); + const article = res.data as unknown as ArticleItem; + onCreated(article); + } catch { + setError("Failed to publish. Try again."); + } finally { + setSaving(false); + } + }, [channel, onClearDraft, onCreated]); + + if (!draft) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+ +

Write Article

+ + {/* Auto-save badge */} + {savedAt && ( + + + Draft saved {savedAt} + + )} + + {/* Discard draft */} + + + +
+ + {/* Body */} +
+ {/* Title */} + { + update({ title: e.target.value }); + setError(""); + }} + placeholder="Article title..." + value={draft.title} + /> + + {/* Cover URL */} +
+ + update({ coverUrl: e.target.value })} + placeholder="Cover image URL (optional)" + value={draft.coverUrl} + /> +
+ + {/* Cover preview */} + {draft.coverUrl.trim() && ( +
+ Cover preview { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+ )} + + {/* Content */} +