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
This commit is contained in:
parent
f947c931cd
commit
779e4eae2f
20
index.html
20
index.html
@ -5,6 +5,26 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GitDataAI</title>
|
||||
<style type="text/css">
|
||||
html, body {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
* {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -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)),
|
||||
|
||||
306
lib/api/src/channel/rest_article.rs
Normal file
306
lib/api/src/channel/rest_article.rs
Normal file
@ -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<String>,
|
||||
pub content: String,
|
||||
pub content_type: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ArticleUpdateRequest {
|
||||
pub title: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub is_pinned: Option<bool>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ArticleListQuery {
|
||||
pub before: Option<Uuid>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<Uuid>,
|
||||
body: web::Json<ArticleCreateRequest>,
|
||||
bus: web::Data<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<Uuid>,
|
||||
query: web::Query<ArticleListQuery>,
|
||||
bus: web::Data<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<ArticleUpdateRequest>,
|
||||
bus: web::Data<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ArticleCommentListQuery {
|
||||
pub before: Option<Uuid>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<Uuid>,
|
||||
body: web::Json<ArticleLikeRequest>,
|
||||
bus: web::Data<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<Uuid>,
|
||||
body: web::Json<ArticleCommentCreateRequest>,
|
||||
bus: web::Data<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<Uuid>,
|
||||
query: web::Query<ArticleCommentListQuery>,
|
||||
bus: web::Data<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<Uuid>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<Uuid>,
|
||||
query: web::Query<ArticleLikedUsersQuery>,
|
||||
bus: web::Data<ChannelBus>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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))
|
||||
}
|
||||
@ -14,6 +14,7 @@ pub struct RoomCreateRequest {
|
||||
pub public: bool,
|
||||
pub category: Option<Uuid>,
|
||||
pub ai_enabled: Option<bool>,
|
||||
pub channel_type: Option<String>,
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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};
|
||||
|
||||
115
lib/channel/event/article.rs
Normal file
115
lib/channel/event/article.rs
Normal file
@ -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<String>,
|
||||
pub summary: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
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<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// 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<ArticleItem>,
|
||||
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<model::channel::ArticleCommentItem>,
|
||||
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<UserInfo>,
|
||||
pub total: i64,
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
717
lib/channel/http/handler/article.rs
Normal file
717
lib/channel/http/handler/article.rs
Normal file
@ -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<String>,
|
||||
content: String,
|
||||
content_type: Option<String>,
|
||||
summary: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
status: Option<String>,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<String>,
|
||||
cover_url: Option<String>,
|
||||
content: Option<String>,
|
||||
content_type: Option<String>,
|
||||
summary: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
is_pinned: Option<bool>,
|
||||
status: Option<String>,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<Option<WsOutEvent>> {
|
||||
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<Uuid>,
|
||||
limit: Option<i64>,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<Uuid> = rows.iter().map(|r| r.author).collect();
|
||||
let users = bus.lookup_users(&author_ids).await?;
|
||||
let articles: Vec<article::ArticleItem> = 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<Option<WsOutEvent>> {
|
||||
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<Option<WsOutEvent>> {
|
||||
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<Uuid>,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<Uuid>,
|
||||
limit: Option<i64>,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<model::channel::ArticleCommentItem> = 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<Option<WsOutEvent>> {
|
||||
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<Uuid>,
|
||||
limit: Option<i64>,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<Uuid> = rows.iter().map(|r| r.0).collect();
|
||||
let users_map = bus.lookup_users(&user_ids).await?;
|
||||
let users: Vec<UserInfo> = 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
|
||||
}
|
||||
}
|
||||
@ -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<String>,
|
||||
position: Option<i32>,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<Option<WsOutEvent>> {
|
||||
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",
|
||||
)
|
||||
|
||||
@ -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) \
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<model::room::RoomMessageModel> {
|
||||
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
|
||||
) -> ChannelResult<model::channel::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 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 \
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ impl WsHandler {
|
||||
room: Uuid,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<Uuid>,
|
||||
ai_enabled: Option<bool>,
|
||||
channel_type: Option<String>,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<bool>,
|
||||
) -> ChannelResult<Option<WsOutEvent>> {
|
||||
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<Option<WsOutEvent>> {
|
||||
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, \
|
||||
|
||||
@ -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, \
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -61,6 +61,7 @@ pub enum WsInMessage {
|
||||
public: bool,
|
||||
category: Option<Uuid>,
|
||||
ai_enabled: Option<bool>,
|
||||
channel_type: Option<String>,
|
||||
},
|
||||
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<String>,
|
||||
content: String,
|
||||
content_type: Option<String>,
|
||||
summary: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
status: Option<String>,
|
||||
},
|
||||
ArticleUpdate {
|
||||
article_id: Uuid,
|
||||
title: Option<String>,
|
||||
cover_url: Option<String>,
|
||||
content: Option<String>,
|
||||
content_type: Option<String>,
|
||||
summary: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
is_pinned: Option<bool>,
|
||||
status: Option<String>,
|
||||
},
|
||||
ArticleDelete {
|
||||
article_id: Uuid,
|
||||
},
|
||||
ArticleList {
|
||||
channel: Uuid,
|
||||
before: Option<Uuid>,
|
||||
limit: Option<i64>,
|
||||
},
|
||||
ArticleGet {
|
||||
article_id: Uuid,
|
||||
},
|
||||
ArticleLike {
|
||||
article_id: Uuid,
|
||||
like: bool,
|
||||
},
|
||||
ArticleCommentCreate {
|
||||
article_id: Uuid,
|
||||
content: String,
|
||||
parent: Option<Uuid>,
|
||||
},
|
||||
ArticleCommentDelete {
|
||||
comment_id: Uuid,
|
||||
},
|
||||
ArticleCommentList {
|
||||
article_id: Uuid,
|
||||
before: Option<Uuid>,
|
||||
limit: Option<i64>,
|
||||
},
|
||||
ArticleLikedUsers {
|
||||
article_id: Uuid,
|
||||
before: Option<Uuid>,
|
||||
limit: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
macro_rules! room_variants {
|
||||
|
||||
@ -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<MessagePage> {
|
||||
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 \
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<String>,
|
||||
/// 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,
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -63,7 +63,6 @@ impl GitBare {
|
||||
|
||||
// Skip directories — only diff blobs
|
||||
if change.entry_mode().is_tree() {
|
||||
stats.files_changed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
3
lib/migrate/sql/room/channel_article_down_01.sql
Normal file
3
lib/migrate/sql/room/channel_article_down_01.sql
Normal file
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS channel_article_comment;
|
||||
DROP TABLE IF EXISTS channel_article_like;
|
||||
28
lib/migrate/sql/room/channel_article_like_comment_up_01.sql
Normal file
28
lib/migrate/sql/room/channel_article_like_comment_up_01.sql
Normal file
@ -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;
|
||||
28
lib/migrate/sql/room/channel_article_up_01.sql
Normal file
28
lib/migrate/sql/room/channel_article_up_01.sql
Normal file
@ -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);
|
||||
85
lib/model/channel/channel.rs
Normal file
85
lib/model/channel/channel.rs
Normal file
@ -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<Uuid>,
|
||||
pub name: String,
|
||||
pub topic: Option<String>,
|
||||
/// 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<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
69
lib/model/channel/channel_article.rs
Normal file
69
lib/model/channel/channel_article.rs
Normal file
@ -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<String>,
|
||||
pub content: String,
|
||||
pub content_type: String,
|
||||
pub summary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub is_pinned: bool,
|
||||
pub view_count: i64,
|
||||
pub like_count: i64,
|
||||
pub comment_count: i64,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateArticlePayload {
|
||||
pub channel: Uuid,
|
||||
pub title: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub content: String,
|
||||
pub content_type: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateArticlePayload {
|
||||
pub title: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub is_pinned: Option<bool>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[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<String>,
|
||||
pub summary: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub like_count: i64,
|
||||
pub comment_count: i64,
|
||||
pub view_count: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
51
lib/model/channel/channel_article_interact.rs
Normal file
51
lib/model/channel/channel_article_interact.rs
Normal file
@ -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<Utc>,
|
||||
}
|
||||
|
||||
/// 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<Uuid>,
|
||||
pub content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Payload for creating a comment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateCommentPayload {
|
||||
pub content: String,
|
||||
pub parent: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Paginated comment list.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArticleCommentList {
|
||||
pub comments: Vec<ArticleCommentItem>,
|
||||
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<Uuid>,
|
||||
pub content: String,
|
||||
pub author: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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<Uuid>,
|
||||
pub name: String,
|
||||
pub topic: Option<String>,
|
||||
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<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@ -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<p::DiffOptions>,
|
||||
) -> Result<p::DiffStatsResponse, AppError> {
|
||||
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<p::DiffOptions>,
|
||||
) -> Result<p::DiffPatchResponse, AppError> {
|
||||
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<p::DiffOptions>,
|
||||
) -> Result<p::DiffPatchSideBySideResponse, AppError> {
|
||||
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<p::DiffOptions>,
|
||||
) -> Result<p::DiffTreeToTreeResponse, AppError> {
|
||||
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<p::DiffOptions>,
|
||||
) -> Result<p::DiffIndexToTreeResponse, AppError> {
|
||||
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 {
|
||||
|
||||
530
openapi.json
530
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"
|
||||
},
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
|
||||
@ -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: <RepoLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate replace to="code" /> },
|
||||
{ index: true, element: <RepoIndexRedirect /> },
|
||||
{ path: "code", element: <CodeTab /> },
|
||||
{ path: "readme", element: <ReadmePage /> },
|
||||
{ path: "commits", element: <CommitsTab /> },
|
||||
|
||||
File diff suppressed because one or more lines are too long
13
src/client/models/articleCommentCreateRequest.ts
Normal file
13
src/client/models/articleCommentCreateRequest.ts
Normal file
@ -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;
|
||||
}
|
||||
22
src/client/models/articleCreateRequest.ts
Normal file
22
src/client/models/articleCreateRequest.ts
Normal file
@ -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;
|
||||
}
|
||||
11
src/client/models/articleLikeRequest.ts
Normal file
11
src/client/models/articleLikeRequest.ts
Normal file
@ -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;
|
||||
}
|
||||
26
src/client/models/articleUpdateRequest.ts
Normal file
26
src/client/models/articleUpdateRequest.ts
Normal file
@ -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;
|
||||
}
|
||||
18
src/client/models/channelArticleCommentListParams.ts
Normal file
18
src/client/models/channelArticleCommentListParams.ts
Normal file
@ -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;
|
||||
};
|
||||
18
src/client/models/channelArticleLikedUsersParams.ts
Normal file
18
src/client/models/channelArticleLikedUsersParams.ts
Normal file
@ -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;
|
||||
};
|
||||
18
src/client/models/channelArticleListParams.ts
Normal file
18
src/client/models/channelArticleListParams.ts
Normal file
@ -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;
|
||||
};
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
87
src/components/right-drawer.tsx
Normal file
87
src/components/right-drawer.tsx
Normal file
@ -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 (
|
||||
<div className="fixed inset-y-0 right-0 z-50 flex w-full justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/50 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`relative flex h-full w-full flex-col border-l border-border/30 bg-card shadow-2xl animate-in slide-in-from-right duration-300 ${width}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/40 px-5 py-3">
|
||||
<Button
|
||||
className="size-8 shrink-0 cursor-pointer rounded-lg"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
{typeof title === "string" ? (
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{title}
|
||||
</span>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1">{title}</div>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/hooks/use-drawer.ts
Normal file
8
src/hooks/use-drawer.ts
Normal file
@ -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 };
|
||||
}
|
||||
140
src/page/workspace/channel/article-card.tsx
Normal file
140
src/page/workspace/channel/article-card.tsx
Normal file
@ -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 (
|
||||
<article
|
||||
className={cn(
|
||||
"group relative flex cursor-pointer flex-col overflow-hidden rounded-2xl border border-border/30 bg-card shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5",
|
||||
article.is_pinned && "ring-1 ring-amber-500/20",
|
||||
className,
|
||||
)}
|
||||
onClick={() => onClick?.(article.id)}
|
||||
>
|
||||
{/* Cover image */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-muted/30">
|
||||
{article.cover_url ? (
|
||||
<img
|
||||
alt={article.title}
|
||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
src={article.cover_url}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center bg-gradient-to-br",
|
||||
color,
|
||||
)}
|
||||
>
|
||||
<span className="text-4xl font-bold text-foreground/15 select-none">
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pinned badge */}
|
||||
{article.is_pinned && (
|
||||
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-amber-500/90 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||
<Pin className="size-2.5" />
|
||||
Pinned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 flex-col gap-2 p-4">
|
||||
{/* Tags */}
|
||||
{article.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{article.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
className="rounded-md bg-primary/[0.06] px-1.5 py-[2px] text-[10px] font-medium text-primary/60"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="line-clamp-2 text-[15px] font-semibold leading-snug text-foreground group-hover:text-primary/80 transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
{/* Summary */}
|
||||
{article.summary && (
|
||||
<p className="line-clamp-2 text-[13px] leading-relaxed text-muted-foreground/60">
|
||||
{article.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Author + Stats */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{/* Author avatar */}
|
||||
{article.author.avatar_url ? (
|
||||
<img
|
||||
alt={authorName}
|
||||
className="size-5 shrink-0 rounded-full object-cover"
|
||||
src={article.author.avatar_url}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"grid size-5 shrink-0 place-items-center rounded-full bg-gradient-to-br text-[8px] font-bold text-white",
|
||||
color,
|
||||
)}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground/50">
|
||||
{authorName}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/35">
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Eye className="size-3" />
|
||||
{formatCount(article.view_count)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Heart className="size-3" />
|
||||
{formatCount(article.like_count)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<MessageCircle className="size-3" />
|
||||
{formatCount(article.comment_count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-[11px] text-muted-foreground/30">
|
||||
{formatRelativeTime(article.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
261
src/page/workspace/channel/article-composer.tsx
Normal file
261
src/page/workspace/channel/article-composer.tsx
Normal file
@ -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<string | null>(null);
|
||||
const draftRef = useRef(draft);
|
||||
|
||||
// Sync external draft into local ref
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
const update = useCallback(
|
||||
(patch: Partial<ArticleDraft>) => {
|
||||
const next = { ...(draftRef.current ?? { channel, title: "", coverUrl: "", content: "", summary: "", tags: [] }), ...patch };
|
||||
draftRef.current = next;
|
||||
onDraftChange(next);
|
||||
},
|
||||
[channel, onDraftChange],
|
||||
);
|
||||
|
||||
// Auto-save indicator
|
||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
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<Record<string, unknown>>(
|
||||
`/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 (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center sm:p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/70 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative flex max-h-full w-full flex-col overflow-hidden bg-card shadow-2xl sm:max-h-[92vh] sm:w-[min(800px,95vw)] sm:rounded-2xl sm:border sm:border-border/30">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/40 px-5 py-3">
|
||||
<Button
|
||||
className="size-8 shrink-0 cursor-pointer rounded-lg"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
<h2 className="flex-1 text-sm font-semibold">Write Article</h2>
|
||||
|
||||
{/* Auto-save badge */}
|
||||
{savedAt && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/[0.06] px-2 py-0.5 text-[10px] font-medium text-emerald-600/60">
|
||||
<Save className="size-2.5" />
|
||||
Draft saved {savedAt}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Discard draft */}
|
||||
<Button
|
||||
className="size-8 shrink-0 cursor-pointer rounded-lg text-muted-foreground/30 hover:text-destructive/70"
|
||||
onClick={() => {
|
||||
onClearDraft();
|
||||
setSavedAt(null);
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Discard draft"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-8 cursor-pointer gap-1.5 rounded-lg"
|
||||
disabled={saving || !draft.title.trim() || !draft.content.trim()}
|
||||
onClick={handlePublish}
|
||||
size="sm"
|
||||
>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Publish
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
||||
{/* Title */}
|
||||
<Input
|
||||
className="border-0 bg-transparent !text-lg font-bold !shadow-none placeholder:text-muted-foreground/25 focus-visible:ring-0"
|
||||
maxLength={200}
|
||||
onChange={(e) => {
|
||||
update({ title: e.target.value });
|
||||
setError("");
|
||||
}}
|
||||
placeholder="Article title..."
|
||||
value={draft.title}
|
||||
/>
|
||||
|
||||
{/* Cover URL */}
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted/30 px-3 py-2">
|
||||
<ImagePlus className="size-4 shrink-0 text-muted-foreground/40" />
|
||||
<Input
|
||||
className="h-7 border-0 bg-transparent text-sm !shadow-none placeholder:text-muted-foreground/25 focus-visible:ring-0"
|
||||
onChange={(e) => update({ coverUrl: e.target.value })}
|
||||
placeholder="Cover image URL (optional)"
|
||||
value={draft.coverUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cover preview */}
|
||||
{draft.coverUrl.trim() && (
|
||||
<div className="aspect-[2/1] w-full overflow-hidden rounded-xl bg-muted/20">
|
||||
<img
|
||||
alt="Cover preview"
|
||||
className="size-full object-cover"
|
||||
src={draft.coverUrl.trim()}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<textarea
|
||||
className="min-h-[240px] w-full resize-none border-0 bg-transparent text-sm leading-relaxed placeholder:text-muted-foreground/25 focus:outline-none"
|
||||
onChange={(e) => {
|
||||
update({ content: e.target.value });
|
||||
setError("");
|
||||
}}
|
||||
placeholder="Content... Markdown supported"
|
||||
value={draft.content}
|
||||
/>
|
||||
|
||||
{/* Summary */}
|
||||
<Input
|
||||
className="border-0 bg-transparent text-sm !shadow-none placeholder:text-muted-foreground/25 focus-visible:ring-0"
|
||||
maxLength={300}
|
||||
onChange={(e) => update({ summary: e.target.value })}
|
||||
placeholder="Summary (optional, shown on card)"
|
||||
value={draft.summary}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
className="h-8 flex-1 bg-muted/30 text-sm placeholder:text-muted-foreground/25"
|
||||
maxLength={20}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const t = (e.target as HTMLInputElement).value.trim();
|
||||
if (t && !draft.tags.includes(t) && draft.tags.length < 10) {
|
||||
update({ tags: [...draft.tags, t] });
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Add tag..."
|
||||
/>
|
||||
<Button
|
||||
className="h-8 cursor-pointer"
|
||||
disabled
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{draft.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{draft.tags.map((t) => (
|
||||
<span
|
||||
className="inline-flex cursor-pointer items-center gap-1 rounded-md bg-primary/[0.06] px-2 py-0.5 text-[11px] font-medium text-primary/60 transition-colors hover:bg-destructive/[0.08] hover:text-destructive/60"
|
||||
key={t}
|
||||
onClick={() =>
|
||||
update({ tags: draft.tags.filter((x) => x !== t) })
|
||||
}
|
||||
>
|
||||
{t}
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/[0.04] px-3 py-2">
|
||||
<p className="text-[13px] text-destructive/80">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
400
src/page/workspace/channel/article-detail.tsx
Normal file
400
src/page/workspace/channel/article-detail.tsx
Normal file
@ -0,0 +1,400 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
X,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Eye,
|
||||
Send,
|
||||
Loader2,
|
||||
Pin,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type ArticleDetail as ArticleDetailType,
|
||||
type ArticleComment,
|
||||
articleColor,
|
||||
articleInitial,
|
||||
formatRelativeTime,
|
||||
formatCount,
|
||||
} from "./article-types";
|
||||
|
||||
type Props = {
|
||||
article: ArticleDetailType;
|
||||
currentUserId?: string;
|
||||
onClose: () => void;
|
||||
onUpdated: (article: ArticleDetailType) => void;
|
||||
};
|
||||
|
||||
export default function ArticleDetail({
|
||||
article: initialArticle,
|
||||
currentUserId,
|
||||
onClose,
|
||||
onUpdated,
|
||||
}: Props) {
|
||||
const [article, setArticle] = useState(initialArticle);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [liking, setLiking] = useState(false);
|
||||
const [comments, setComments] = useState<ArticleComment[]>([]);
|
||||
const [loadingComments, setLoadingComments] = useState(true);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [sendingComment, setSendingComment] = useState(false);
|
||||
|
||||
// Load comments
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on mount
|
||||
setLoadingComments(true);
|
||||
api
|
||||
.get<Record<string, unknown>>(
|
||||
`/api/v1/ws/articles/${article.id}/comments`,
|
||||
{ params: { limit: 50 } },
|
||||
)
|
||||
.then((res) => {
|
||||
const data = res.data as Record<string, unknown>;
|
||||
setComments((data.comments ?? []) as ArticleComment[]);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingComments(false));
|
||||
}, [article.id]);
|
||||
|
||||
// Group comments by parent
|
||||
const commentTree = useCallback(() => {
|
||||
const root: ArticleComment[] = [];
|
||||
const children = new Map<string, ArticleComment[]>();
|
||||
for (const c of comments) {
|
||||
if (c.parent) {
|
||||
const arr = children.get(c.parent) ?? [];
|
||||
arr.push(c);
|
||||
children.set(c.parent, arr);
|
||||
} else {
|
||||
root.push(c);
|
||||
}
|
||||
}
|
||||
return { root, children };
|
||||
}, [comments]);
|
||||
|
||||
const handleLike = useCallback(async () => {
|
||||
if (liking) return;
|
||||
setLiking(true);
|
||||
try {
|
||||
const nextLiked = !liked;
|
||||
await api.post(`/api/v1/ws/articles/${article.id}/like`, {
|
||||
like: nextLiked,
|
||||
});
|
||||
setLiked(nextLiked);
|
||||
const newCount = nextLiked ? article.like_count + 1 : Math.max(0, article.like_count - 1);
|
||||
const updated = { ...article, like_count: newCount };
|
||||
setArticle(updated);
|
||||
onUpdated(updated);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLiking(false);
|
||||
}
|
||||
}, [liked, liking, article, onUpdated]);
|
||||
|
||||
const handleSendComment = useCallback(async () => {
|
||||
const trimmed = commentText.trim();
|
||||
if (!trimmed || sendingComment) return;
|
||||
setSendingComment(true);
|
||||
try {
|
||||
await api.post(`/api/v1/ws/articles/${article.id}/comments`, {
|
||||
content: trimmed,
|
||||
});
|
||||
setCommentText("");
|
||||
// Reload comments
|
||||
const res = await api.get<Record<string, unknown>>(
|
||||
`/api/v1/ws/articles/${article.id}/comments`,
|
||||
{ params: { limit: 50 } },
|
||||
);
|
||||
const data = res.data as Record<string, unknown>;
|
||||
setComments((data.comments ?? []) as ArticleComment[]);
|
||||
const updated = { ...article, comment_count: article.comment_count + 1 };
|
||||
setArticle(updated);
|
||||
onUpdated(updated);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSendingComment(false);
|
||||
}
|
||||
}, [commentText, sendingComment, article, onUpdated]);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
async (commentId: string) => {
|
||||
try {
|
||||
await api.delete(
|
||||
`/api/v1/ws/articles/${article.id}/comments/${commentId}`,
|
||||
);
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
const updated = {
|
||||
...article,
|
||||
comment_count: Math.max(0, article.comment_count - 1),
|
||||
};
|
||||
setArticle(updated);
|
||||
onUpdated(updated);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[article, onUpdated],
|
||||
);
|
||||
|
||||
const authorName =
|
||||
article.author.display_name || article.author.username || "Anonymous";
|
||||
const color = articleColor(authorName);
|
||||
const initial = articleInitial(authorName);
|
||||
const { root, children } = commentTree();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/40 px-5 py-3">
|
||||
<Button
|
||||
className="size-8 shrink-0 cursor-pointer rounded-lg"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-semibold">Article Detail</h2>
|
||||
</div>
|
||||
{article.is_pinned && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600/70">
|
||||
<Pin className="size-3" />
|
||||
Pinned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{/* Cover */}
|
||||
{article.cover_url && (
|
||||
<div className="aspect-[2/1] w-full overflow-hidden bg-muted/20">
|
||||
<img
|
||||
alt={article.title}
|
||||
className="size-full object-cover"
|
||||
src={article.cover_url}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-5 py-4">
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold leading-snug">{article.title}</h1>
|
||||
|
||||
{/* Author + meta */}
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
{article.author.avatar_url ? (
|
||||
<img
|
||||
alt={authorName}
|
||||
className="size-9 shrink-0 rounded-full object-cover ring-2 ring-border/20"
|
||||
src={article.author.avatar_url}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"grid size-9 shrink-0 place-items-center rounded-full bg-gradient-to-br text-sm font-bold text-white ring-2 ring-border/20",
|
||||
color,
|
||||
)}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{authorName}</p>
|
||||
<p className="text-[12px] text-muted-foreground/50">
|
||||
{formatRelativeTime(article.created_at)}
|
||||
{article.updated_at !== article.created_at && " ((edited))"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{article.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{article.tags.map((tag) => (
|
||||
<span
|
||||
className="rounded-md bg-primary/[0.06] px-2 py-0.5 text-[11px] font-medium text-primary/60"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-sm mt-6 max-w-none prose-p:leading-relaxed prose-a:text-primary/70 prose-img:rounded-xl">
|
||||
{article.content.split("\n").map((line, i) => (
|
||||
<p key={i}>{line || "\u00A0"}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="mt-6 flex items-center gap-4 border-t border-border/30 pt-4 text-[13px] text-muted-foreground/50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Eye className="size-4" />
|
||||
{formatCount(article.view_count)} views
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle className="size-4" />
|
||||
{formatCount(article.comment_count)} comments
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Like button */}
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-200",
|
||||
liked
|
||||
? "bg-rose-500/10 text-rose-500 ring-1 ring-rose-500/20"
|
||||
: "bg-muted/50 text-muted-foreground/60 hover:bg-rose-500/[0.04] hover:text-rose-500/70",
|
||||
)}
|
||||
disabled={liking}
|
||||
onClick={handleLike}
|
||||
type="button"
|
||||
>
|
||||
<Heart
|
||||
className={cn(
|
||||
"size-[18px] transition-all",
|
||||
liked && "fill-rose-500",
|
||||
)}
|
||||
/>
|
||||
{liked ? "Liked" : "Like"}
|
||||
<span className="tabular-nums">
|
||||
{formatCount(article.like_count)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="border-t border-border/30 px-5 py-4">
|
||||
<h3 className="mb-4 text-sm font-semibold">
|
||||
comments ({article.comment_count})
|
||||
</h3>
|
||||
|
||||
{loadingComments ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground/20" />
|
||||
</div>
|
||||
) : root.length === 0 ? (
|
||||
<p className="py-8 text-center text-[13px] text-muted-foreground/35">
|
||||
No comments yet. Be the first.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{root.map((comment) => (
|
||||
<CommentItem
|
||||
articleAuthorId={article.author.id}
|
||||
children={children}
|
||||
comment={comment}
|
||||
currentUserId={currentUserId}
|
||||
key={comment.id}
|
||||
onDelete={handleDeleteComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment composer */}
|
||||
<div className="mt-4 flex items-end gap-2">
|
||||
<textarea
|
||||
className="min-h-[40px] max-h-32 flex-1 resize-none rounded-xl border border-border/40 bg-muted/30 px-3 py-2.5 text-sm placeholder:text-muted-foreground/30 focus:border-primary/20 focus:outline-none focus:ring-1 focus:ring-primary/[0.06]"
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendComment();
|
||||
}
|
||||
}}
|
||||
placeholder="Write a comment..."
|
||||
rows={1}
|
||||
value={commentText}
|
||||
/>
|
||||
<Button
|
||||
className="size-9 shrink-0 cursor-pointer rounded-xl"
|
||||
disabled={!commentText.trim() || sendingComment}
|
||||
onClick={handleSendComment}
|
||||
size="icon"
|
||||
>
|
||||
{sendingComment ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-[15px]" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentItem({
|
||||
comment,
|
||||
children,
|
||||
articleAuthorId,
|
||||
currentUserId,
|
||||
onDelete,
|
||||
}: {
|
||||
comment: ArticleComment;
|
||||
children: Map<string, ArticleComment[]>;
|
||||
articleAuthorId: string;
|
||||
currentUserId?: string;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const replies = children.get(comment.id) ?? [];
|
||||
const canDelete =
|
||||
currentUserId === comment.author || currentUserId === articleAuthorId;
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
<div className="flex gap-2.5">
|
||||
<div className="grid size-7 shrink-0 place-items-center rounded-full bg-muted/50 text-[10px] font-semibold text-muted-foreground/50">
|
||||
{comment.author.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[13px] font-semibold">
|
||||
{comment.author.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/35">
|
||||
{formatRelativeTime(comment.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-foreground/80">
|
||||
{comment.content}
|
||||
</p>
|
||||
{canDelete && (
|
||||
<button
|
||||
className="mt-1 cursor-pointer text-[10px] text-muted-foreground/25 opacity-0 transition-opacity hover:text-destructive/60 group-hover:opacity-100"
|
||||
onClick={() => onDelete(comment.id)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{replies.length > 0 && (
|
||||
<div className="ml-9 mt-2 space-y-3 border-l-2 border-border/20 pl-4">
|
||||
{replies.map((reply) => (
|
||||
<CommentItem
|
||||
articleAuthorId={articleAuthorId}
|
||||
children={children}
|
||||
comment={reply}
|
||||
currentUserId={currentUserId}
|
||||
key={reply.id}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
325
src/page/workspace/channel/article-feed.tsx
Normal file
325
src/page/workspace/channel/article-feed.tsx
Normal file
@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Newspaper,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/client";
|
||||
import { useChannelSocket } from "@/hooks/use-channel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ArticleCard from "./article-card";
|
||||
import ArticleDetail from "./article-detail";
|
||||
import type { ArticleItem, ArticleDetail as ArticleDetailType } from "./article-types";
|
||||
|
||||
type Props = {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
currentUserId?: string;
|
||||
onCompose?: () => void;
|
||||
};
|
||||
|
||||
export default function ArticleFeed({ roomId, roomName, currentUserId, onCompose }: Props) {
|
||||
const [articles, setArticles] = useState<ArticleItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<ArticleDetailType | null>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const loaderRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadArticles = useCallback(
|
||||
async (before?: string) => {
|
||||
try {
|
||||
const params: Record<string, string | number> = { limit: 20 };
|
||||
if (before) params.before = before;
|
||||
const res = await api.get<
|
||||
Record<string, unknown>
|
||||
>(`/api/v1/ws/channels/${roomId}/articles`, { params });
|
||||
|
||||
const data = res.data as Record<string, unknown>;
|
||||
const list = (data.articles ?? []) as ArticleItem[];
|
||||
const more = (data.has_more ?? false) as boolean;
|
||||
|
||||
if (before) {
|
||||
setArticles((prev) => [...prev, ...list]);
|
||||
} else {
|
||||
setArticles(list);
|
||||
}
|
||||
setHasMore(more);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[roomId],
|
||||
);
|
||||
|
||||
// Reset on room change and (re)load
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const doLoad = async () => {
|
||||
setLoading(true);
|
||||
setArticles([]);
|
||||
setHasMore(true);
|
||||
setDetailId(null);
|
||||
setDetail(null);
|
||||
try {
|
||||
const params: Record<string, string | number> = { limit: 20 };
|
||||
const res = await api.get<Record<string, unknown>>(
|
||||
`/api/v1/ws/channels/${roomId}/articles`,
|
||||
{ params },
|
||||
);
|
||||
if (cancelled) return;
|
||||
const data = res.data as Record<string, unknown>;
|
||||
setArticles((data.articles ?? []) as ArticleItem[]);
|
||||
setHasMore((data.has_more ?? false) as boolean);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
doLoad();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
// Load detail when detailId changes
|
||||
useEffect(() => {
|
||||
if (!detailId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear on deselect
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingDetail(true);
|
||||
api
|
||||
.get<Record<string, unknown>>(
|
||||
`/api/v1/ws/channels/${roomId}/articles/${detailId}`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const d = res.data as unknown as ArticleDetailType;
|
||||
setDetail(d);
|
||||
setArticles((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === detailId ? { ...a, view_count: d.view_count } : a,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => setDetailId(null))
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingDetail(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detailId, roomId]);
|
||||
|
||||
// Intersection observer for infinite scroll
|
||||
useEffect(() => {
|
||||
const el = loaderRef.current;
|
||||
if (!el || !hasMore || loading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && hasMore && !loading && articles.length > 0) {
|
||||
const last = articles[articles.length - 1];
|
||||
loadArticles(last.id);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loading, articles, loadArticles]);
|
||||
|
||||
// Listen to websocket events for article updates
|
||||
const { onEvent } = useChannelSocket();
|
||||
useEffect(() => {
|
||||
return onEvent((event) => {
|
||||
if (!event.room || event.room.id !== roomId) return;
|
||||
|
||||
if (event.type === "article.liked" || event.type === "article.unliked") {
|
||||
const d = event.data as { article_id: string; like_count: number };
|
||||
setArticles((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === d.article_id ? { ...a, like_count: d.like_count } : a,
|
||||
),
|
||||
);
|
||||
if (detail?.id === d.article_id) {
|
||||
setDetail((prev) =>
|
||||
prev ? { ...prev, like_count: d.like_count } : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "article.comment.created") {
|
||||
const d = event.data as {
|
||||
comment: { article: string };
|
||||
comment_count: number;
|
||||
};
|
||||
setArticles((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === d.comment.article
|
||||
? { ...a, comment_count: d.comment_count }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
if (detail?.id === d.comment.article) {
|
||||
setDetail((prev) =>
|
||||
prev ? { ...prev, comment_count: d.comment_count } : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "article.comment.deleted") {
|
||||
const d = event.data as { article_id: string; comment_count: number };
|
||||
setArticles((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === d.article_id
|
||||
? { ...a, comment_count: d.comment_count }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
if (detail?.id === d.article_id) {
|
||||
setDetail((prev) =>
|
||||
prev ? { ...prev, comment_count: d.comment_count } : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [roomId, onEvent, detail?.id]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/40 px-5 py-3">
|
||||
<Newspaper className="size-5 text-primary/50" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-semibold">{roomName}</h2>
|
||||
<p className="text-[11px] text-muted-foreground/40">
|
||||
{articles.length} articles
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 cursor-pointer gap-1.5 rounded-lg"
|
||||
onClick={onCompose}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Write
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Article grid */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground/25" />
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="grid size-16 place-items-center rounded-2xl bg-muted/20 ring-1 ring-border/20">
|
||||
<FileText className="size-7 text-muted-foreground/15" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-muted-foreground/40">
|
||||
No articles yet
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-muted-foreground/30">
|
||||
Be the first to share
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 cursor-pointer"
|
||||
onClick={onCompose}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
Write
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Masonry grid */}
|
||||
<div className="columns-1 gap-4 p-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
{articles.map((article) => (
|
||||
<div
|
||||
className="mb-4 break-inside-avoid"
|
||||
key={article.id}
|
||||
>
|
||||
<ArticleCard
|
||||
article={article}
|
||||
onClick={(id) => setDetailId(id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loader sentinel */}
|
||||
<div ref={loaderRef} className="flex justify-center py-6">
|
||||
{hasMore && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground/20" />
|
||||
)}
|
||||
{!hasMore && articles.length > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground/25">
|
||||
— You have reached the end —
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Article detail panel */}
|
||||
{detailId && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-20 bg-background/60 backdrop-blur-sm"
|
||||
onClick={() => setDetailId(null)}
|
||||
/>
|
||||
<div className="absolute inset-0 right-0 z-30 overflow-y-auto bg-card shadow-2xl sm:inset-y-2 sm:right-2 sm:w-[min(640px,90vw)] sm:rounded-2xl sm:border sm:border-border/30">
|
||||
{loadingDetail ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground/25" />
|
||||
</div>
|
||||
) : detail ? (
|
||||
<ArticleDetail
|
||||
article={detail}
|
||||
currentUserId={currentUserId}
|
||||
onClose={() => setDetailId(null)}
|
||||
onUpdated={(updated) => {
|
||||
setDetail(updated);
|
||||
setArticles((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === updated.id
|
||||
? {
|
||||
...a,
|
||||
title: updated.title,
|
||||
summary: updated.summary,
|
||||
cover_url: updated.cover_url,
|
||||
tags: updated.tags,
|
||||
like_count: updated.like_count,
|
||||
comment_count: updated.comment_count,
|
||||
view_count: updated.view_count,
|
||||
is_pinned: updated.is_pinned,
|
||||
updated_at: updated.updated_at,
|
||||
}
|
||||
: a,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/page/workspace/channel/article-types.ts
Normal file
106
src/page/workspace/channel/article-types.ts
Normal file
@ -0,0 +1,106 @@
|
||||
export type ArticleItem = {
|
||||
id: string;
|
||||
channel: string;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
title: string;
|
||||
cover_url: string | null;
|
||||
summary: string | null;
|
||||
tags: string[];
|
||||
like_count: number;
|
||||
comment_count: number;
|
||||
view_count: number;
|
||||
is_pinned: boolean;
|
||||
content_type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ArticleDetail = {
|
||||
id: string;
|
||||
channel: string;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
title: string;
|
||||
cover_url: string | null;
|
||||
summary: string | null;
|
||||
tags: string[];
|
||||
like_count: number;
|
||||
comment_count: number;
|
||||
view_count: number;
|
||||
is_pinned: boolean;
|
||||
content_type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ArticleComment = {
|
||||
id: string;
|
||||
article: string;
|
||||
parent: string | null;
|
||||
content: string;
|
||||
author: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export function articleColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hues = [
|
||||
"from-violet-500/20 to-purple-600/10",
|
||||
"from-fuchsia-500/20 to-pink-600/10",
|
||||
"from-indigo-500/20 to-blue-600/10",
|
||||
"from-emerald-500/20 to-teal-600/10",
|
||||
"from-amber-500/20 to-orange-600/10",
|
||||
"from-rose-500/20 to-red-600/10",
|
||||
"from-cyan-500/20 to-sky-600/10",
|
||||
"from-lime-500/20 to-green-600/10",
|
||||
];
|
||||
return hues[Math.abs(hash) % hues.length];
|
||||
}
|
||||
|
||||
export function articleInitial(name: string): string {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
if (n >= 10000) return `${(n / 1000).toFixed(1)}k`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import { Hash, Settings, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -55,16 +54,12 @@ export function ChannelHeader({
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
aria-label="Channel settings"
|
||||
className="size-8 cursor-pointer rounded-lg text-muted-foreground/40 transition-all duration-150 hover:bg-accent/40 hover:text-foreground"
|
||||
onClick={onToggleSettings}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Settings className="size-[18px]" />
|
||||
</Button>
|
||||
<TooltipTrigger
|
||||
aria-label="Channel settings"
|
||||
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground/40 transition-all duration-150 hover:bg-accent/40 hover:text-foreground"
|
||||
onClick={onToggleSettings}
|
||||
>
|
||||
<Settings className="size-[18px]" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">Channel settings</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
FolderOpen,
|
||||
Newspaper,
|
||||
} from "lucide-react";
|
||||
import { Link, useLocation, useParams } from "react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -38,6 +39,8 @@ type Props = {
|
||||
function RoomIcon({ type }: { type: string }) {
|
||||
if (type === "voice")
|
||||
return <Volume2 className="size-[15px] shrink-0 text-muted-foreground/60" />;
|
||||
if (type === "article")
|
||||
return <Newspaper className="size-[15px] shrink-0 text-muted-foreground/60" />;
|
||||
return <Hash className="size-[15px] shrink-0 text-muted-foreground/60" />;
|
||||
}
|
||||
|
||||
|
||||
@ -21,37 +21,49 @@ export function ChannelThreadPanel({
|
||||
}: Props) {
|
||||
const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
|
||||
const prevOpen = useRef(false);
|
||||
const prevRoomId = useRef(roomId);
|
||||
|
||||
// Auto-select the thread when selectedThreadId is provided
|
||||
// Reset selectedThread when room changes to avoid stale data leak
|
||||
useEffect(() => {
|
||||
if (selectedThreadId) {
|
||||
const thread = threads.find((t) => t.id === selectedThreadId);
|
||||
if (thread) {
|
||||
setSelectedThread(thread);
|
||||
} else if (initialSeq > 0) {
|
||||
setSelectedThread({
|
||||
id: selectedThreadId,
|
||||
room: roomId,
|
||||
seq: 0,
|
||||
parent_seq: initialSeq,
|
||||
title: "",
|
||||
created_by: { id: "", display_name: "", username: "" },
|
||||
archived: false,
|
||||
locked: false,
|
||||
last_message_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (prevRoomId.current !== roomId) {
|
||||
prevRoomId.current = roomId;
|
||||
setSelectedThread(null);
|
||||
}
|
||||
}, [selectedThreadId, threads, roomId, initialSeq]);
|
||||
|
||||
// Reset selection when panel closes
|
||||
useEffect(() => {
|
||||
// Also reset when open transitions from true to false (after room switch reset)
|
||||
if (!open && prevOpen.current) {
|
||||
setSelectedThread(null);
|
||||
}
|
||||
prevOpen.current = open;
|
||||
}, [open]);
|
||||
}, [open, roomId]);
|
||||
|
||||
// Auto-select the thread when selectedThreadId is provided
|
||||
useEffect(() => {
|
||||
if (!selectedThreadId) {
|
||||
return;
|
||||
}
|
||||
if (threads.length === 0) {
|
||||
// Threads haven't loaded yet for this room; don't eagerly create a placeholder
|
||||
return;
|
||||
}
|
||||
const thread = threads.find((t) => t.id === selectedThreadId);
|
||||
if (thread) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- state sync from prop, pre-existing pattern
|
||||
setSelectedThread(thread);
|
||||
} else if (initialSeq > 0) {
|
||||
setSelectedThread({
|
||||
id: selectedThreadId,
|
||||
room: roomId,
|
||||
seq: 0,
|
||||
parent_seq: initialSeq,
|
||||
title: "",
|
||||
created_by: { id: "", display_name: "", username: "" },
|
||||
archived: false,
|
||||
locked: false,
|
||||
last_message_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}, [selectedThreadId, threads, roomId, initialSeq]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/client";
|
||||
@ -8,6 +8,9 @@ import { ChannelHeader } from "./channel-header";
|
||||
import { ChannelThreadPanel } from "./channel-thread-panel";
|
||||
import RoomSettingsDialog from "./room-settings-dialog";
|
||||
import MessageView from "./message-view";
|
||||
import ArticleFeed from "./article-feed";
|
||||
import ArticleComposer from "./article-composer";
|
||||
import { useArticleDraft } from "./use-article-draft";
|
||||
|
||||
export default function ChannelPage() {
|
||||
const { roomId } = useParams();
|
||||
@ -18,6 +21,41 @@ export default function ChannelPage() {
|
||||
const [activeThreadSeq, setActiveThreadSeq] = useState<number>(0);
|
||||
const [showRoomSettings, setShowRoomSettings] = useState(false);
|
||||
|
||||
// Global composer state
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerChannel, setComposerChannel] = useState<string | null>(null);
|
||||
const { draft, persist, initDraft, clearDraft } = useArticleDraft(composerChannel ?? undefined);
|
||||
|
||||
const openComposer = useCallback((channel: string) => {
|
||||
setComposerChannel(channel);
|
||||
const existing = initDraft(channel);
|
||||
if (!existing || (!existing.title && !existing.content)) {
|
||||
// fresh draft
|
||||
}
|
||||
setShowComposer(true);
|
||||
}, [initDraft]);
|
||||
|
||||
const closeComposer = useCallback(() => {
|
||||
setShowComposer(false);
|
||||
}, []);
|
||||
|
||||
const handleArticleCreated = useCallback(() => {
|
||||
setShowComposer(false);
|
||||
setComposerChannel(null);
|
||||
}, []);
|
||||
|
||||
// Reset thread view & room settings when switching rooms
|
||||
const prevRoomId = useRef(roomId);
|
||||
useEffect(() => {
|
||||
if (prevRoomId.current !== roomId) {
|
||||
prevRoomId.current = roomId;
|
||||
setShowThreads(false);
|
||||
setActiveThreadId(null);
|
||||
setActiveThreadSeq(0);
|
||||
setShowRoomSettings(false);
|
||||
}
|
||||
}, [roomId]);
|
||||
|
||||
const handleStartThread = useCallback(
|
||||
async (_messageId: string, seq: number) => {
|
||||
if (!roomId) return;
|
||||
@ -74,36 +112,47 @@ export default function ChannelPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageView
|
||||
currentUserId={state.currentUserId}
|
||||
hasMore={state.hasMore}
|
||||
loading={state.loadingMessages}
|
||||
messages={state.messages}
|
||||
onDelete={actions.handleDeleteMessage}
|
||||
onEdit={actions.handleEditMessage}
|
||||
onLoadMore={actions.handleLoadMore}
|
||||
onPinToggle={actions.handlePinToggle}
|
||||
onReactionToggle={actions.handleReactionToggle}
|
||||
onSend={actions.handleSend}
|
||||
onStartThread={handleStartThread}
|
||||
onViewThread={handleViewThread}
|
||||
onTyping={actions.handleTyping}
|
||||
roomId={roomId ?? ""}
|
||||
roomName={state.currentRoom?.name ?? ""}
|
||||
streamingMessages={state.streamingMessages}
|
||||
threads={state.threads}
|
||||
typingText={state.typingText}
|
||||
/>
|
||||
|
||||
{roomId && (
|
||||
<ChannelThreadPanel
|
||||
initialSeq={activeThreadSeq}
|
||||
onClose={closeThreadPanel}
|
||||
open={showThreads}
|
||||
{state.currentRoom?.room_type === "article" && roomId ? (
|
||||
<ArticleFeed
|
||||
currentUserId={state.currentUserId}
|
||||
roomId={roomId}
|
||||
selectedThreadId={activeThreadId}
|
||||
threads={state.threads}
|
||||
roomName={state.currentRoom.name}
|
||||
onCompose={() => openComposer(roomId)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MessageView
|
||||
currentUserId={state.currentUserId}
|
||||
hasMore={state.hasMore}
|
||||
loading={state.loadingMessages}
|
||||
messages={state.messages}
|
||||
onDelete={actions.handleDeleteMessage}
|
||||
onEdit={actions.handleEditMessage}
|
||||
onLoadMore={actions.handleLoadMore}
|
||||
onPinToggle={actions.handlePinToggle}
|
||||
onReactionToggle={actions.handleReactionToggle}
|
||||
onSend={actions.handleSend}
|
||||
onStartThread={handleStartThread}
|
||||
onViewThread={handleViewThread}
|
||||
onTyping={actions.handleTyping}
|
||||
roomId={roomId ?? ""}
|
||||
roomName={state.currentRoom?.name ?? ""}
|
||||
streamingMessages={state.streamingMessages}
|
||||
threads={state.threads}
|
||||
typingText={state.typingText}
|
||||
/>
|
||||
|
||||
{roomId && (
|
||||
<ChannelThreadPanel
|
||||
initialSeq={activeThreadSeq}
|
||||
onClose={closeThreadPanel}
|
||||
open={showThreads}
|
||||
roomId={roomId}
|
||||
selectedThreadId={activeThreadId}
|
||||
threads={state.threads}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{roomId && state.currentRoom && (
|
||||
@ -121,6 +170,18 @@ export default function ChannelPage() {
|
||||
topic={state.currentRoom.topic}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Global article composer */}
|
||||
{showComposer && composerChannel && (
|
||||
<ArticleComposer
|
||||
channel={composerChannel}
|
||||
draft={draft}
|
||||
onClearDraft={clearDraft}
|
||||
onClose={closeComposer}
|
||||
onCreated={handleArticleCreated}
|
||||
onDraftChange={persist}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
68
src/page/workspace/channel/message-content.tsx
Normal file
68
src/page/workspace/channel/message-content.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useMemo } from "react";
|
||||
import RepoEmbedCard from "./repo-embed-card";
|
||||
import { parseRepoLinks, type RepoLinkMatch } from "./repo-link-parser";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders message content, detecting same-origin repo links and
|
||||
* replacing them with RepoEmbedCards.
|
||||
*/
|
||||
export default function MessageContent({ content, contentType }: Props) {
|
||||
const { textParts, embeds } = useMemo(() => {
|
||||
const links = parseRepoLinks(content);
|
||||
if (links.length === 0) return { textParts: [content], embeds: [] };
|
||||
|
||||
const embeds: RepoLinkMatch[] = [];
|
||||
const textParts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const link of links) {
|
||||
const idx = content.indexOf(link.url, lastIndex);
|
||||
if (idx === -1) continue;
|
||||
|
||||
// Text before this link
|
||||
if (idx > lastIndex) {
|
||||
textParts.push(content.slice(lastIndex, idx));
|
||||
}
|
||||
embeds.push(link);
|
||||
lastIndex = idx + link.url.length;
|
||||
}
|
||||
|
||||
// Remaining text after last link
|
||||
if (lastIndex < content.length) {
|
||||
textParts.push(content.slice(lastIndex));
|
||||
}
|
||||
|
||||
return { textParts, embeds };
|
||||
}, [content]);
|
||||
|
||||
const isPlainText = contentType === "text" || !contentType;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{textParts.map((part, i) => {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed && embeds.length > 0) return null;
|
||||
return (
|
||||
<p
|
||||
className={
|
||||
isPlainText
|
||||
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
|
||||
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
|
||||
}
|
||||
key={`t-${i}`}
|
||||
>
|
||||
{isPlainText ? part : part}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
{embeds.map((link) => (
|
||||
<RepoEmbedCard key={link.url} link={link} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -26,6 +26,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { MessageNewService } from "@/socket";
|
||||
import type { Thread } from "./thread-sidebar";
|
||||
import MessageContent from "./message-content";
|
||||
|
||||
export function formatTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
@ -280,17 +281,11 @@ export default function MessageItem({
|
||||
<span>Esc to cancel · Enter to save</span>
|
||||
</div>
|
||||
</div>
|
||||
) : message.content_type === "text" || !message.content_type ? (
|
||||
<p className="whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85">
|
||||
{message.content}
|
||||
</p>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words text-[13px] text-foreground/85">
|
||||
<span className="inline-flex items-center gap-1 rounded bg-muted/40 px-1.5 py-[1px] text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
|
||||
{message.content_type}
|
||||
</span>{" "}
|
||||
{message.content}
|
||||
</p>
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
contentType={message.content_type}
|
||||
/>
|
||||
)}
|
||||
|
||||
{threadForMessage && !message.thread && (
|
||||
@ -322,26 +317,32 @@ export default function MessageItem({
|
||||
<span className="text-[10px] font-semibold tabular-nums">{r.count}</span>
|
||||
</button>
|
||||
))}
|
||||
<ReactionPicker onSelect={(emoji) => handleReaction(emoji)}>
|
||||
<button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/25 opacity-0 transition-all duration-150 hover:border-primary/25 hover:text-primary/50 group-hover:opacity-100">
|
||||
<SmilePlus className="size-3" />
|
||||
</button>
|
||||
</ReactionPicker>
|
||||
<ReactionPicker
|
||||
onSelect={(emoji) => handleReaction(emoji)}
|
||||
trigger={
|
||||
<button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/25 opacity-0 transition-all duration-150 hover:border-primary/25 hover:text-primary/50 group-hover:opacity-100">
|
||||
<SmilePlus className="size-3" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-2 top-0 z-10 flex items-center gap-[1px] rounded-lg border border-border/20 bg-card/95 px-1 py-1 shadow-md backdrop-blur-sm opacity-0 transition-all duration-150 group-hover:opacity-100">
|
||||
<ReactionPicker onSelect={(emoji) => handleReaction(emoji)}>
|
||||
<Button
|
||||
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
|
||||
size="icon"
|
||||
title="Add reaction"
|
||||
variant="ghost"
|
||||
>
|
||||
<SmilePlus className="size-3.5" />
|
||||
</Button>
|
||||
</ReactionPicker>
|
||||
<ReactionPicker
|
||||
onSelect={(emoji) => handleReaction(emoji)}
|
||||
trigger={
|
||||
<Button
|
||||
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
|
||||
size="icon"
|
||||
title="Add reaction"
|
||||
variant="ghost"
|
||||
>
|
||||
<SmilePlus className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
|
||||
@ -463,15 +464,15 @@ const REACTIONS_PALETTE = [
|
||||
];
|
||||
|
||||
function ReactionPicker({
|
||||
children,
|
||||
trigger,
|
||||
onSelect,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
trigger: React.ReactElement;
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>{children}</PopoverTrigger>
|
||||
<PopoverTrigger render={trigger} />
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-auto p-2"
|
||||
|
||||
45
src/page/workspace/channel/repo-drawer.tsx
Normal file
45
src/page/workspace/channel/repo-drawer.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import RightDrawer from "@/components/right-drawer";
|
||||
import { useDrawer } from "@/hooks/use-drawer";
|
||||
|
||||
type Props = {
|
||||
workspace: string;
|
||||
repo: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function RepoDrawer({ workspace, repo, children }: Props) {
|
||||
const { open, openDrawer, closeDrawer } = useDrawer();
|
||||
const repoUrl = `/${workspace}/repo/${repo}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span onClick={openDrawer} className="cursor-pointer contents">
|
||||
{children}
|
||||
</span>
|
||||
|
||||
<RightDrawer
|
||||
actions={
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-[11px] text-muted-foreground/50 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
href={repoUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open
|
||||
</a>
|
||||
}
|
||||
onClose={closeDrawer}
|
||||
open={open}
|
||||
title={`${workspace}/${repo}`}
|
||||
>
|
||||
<iframe
|
||||
className="min-h-0 flex-1 w-full border-0"
|
||||
src={repoUrl}
|
||||
title={`${workspace}/${repo}`}
|
||||
/>
|
||||
</RightDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
src/page/workspace/channel/repo-embed-card.tsx
Normal file
167
src/page/workspace/channel/repo-embed-card.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/client";
|
||||
import RepoDrawer from "./repo-drawer";
|
||||
import type { RepoLinkMatch } from "./repo-link-parser";
|
||||
|
||||
type RepoInfo = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
default_branch: string;
|
||||
language: string | null;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function languageColor(lang: string): string {
|
||||
const map: Record<string, string> = {
|
||||
Rust: "#DEA584",
|
||||
TypeScript: "#3178C6",
|
||||
JavaScript: "#F7DF1E",
|
||||
Python: "#3572A5",
|
||||
Go: "#00ADD8",
|
||||
Java: "#B07219",
|
||||
Kotlin: "#A97BFF",
|
||||
Swift: "#F05138",
|
||||
C: "#555555",
|
||||
"C++": "#F34B7D",
|
||||
"C#": "#178600",
|
||||
Ruby: "#701516",
|
||||
Zig: "#EC915C",
|
||||
Elixir: "#6E4A7E",
|
||||
Haskell: "#5E5086",
|
||||
CSS: "#563D7C",
|
||||
HTML: "#E34C26",
|
||||
Shell: "#89E051",
|
||||
};
|
||||
return map[lang] ?? "#6B7280";
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 1) return "today";
|
||||
if (days === 1) return "yesterday";
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
}
|
||||
|
||||
export default function RepoEmbedCard({ link }: { link: RepoLinkMatch }) {
|
||||
const [info, setInfo] = useState<RepoInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on mount
|
||||
setLoading(true);
|
||||
|
||||
const repoPath = `/api/v1/workspace/${link.workspace}/repos/${link.repo}`;
|
||||
|
||||
api
|
||||
.get<Record<string, unknown>>(repoPath)
|
||||
.then(async (repoRes) => {
|
||||
if (cancelled) return;
|
||||
const d = repoRes.data as Record<string, unknown>;
|
||||
// Fetch top language
|
||||
let topLang: string | null = null;
|
||||
try {
|
||||
const langRes = await api.get<
|
||||
{ language: string; percent: number }[]
|
||||
>(`${repoPath}/git/languages`);
|
||||
if (!cancelled && langRes.data.length > 0) {
|
||||
topLang = langRes.data[0].language;
|
||||
}
|
||||
} catch {
|
||||
// language fetch is best-effort
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
setInfo({
|
||||
name: (d.name as string) ?? link.repo,
|
||||
description: (d.description as string) ?? null,
|
||||
default_branch: (d.default_branch as string) ?? "main",
|
||||
language: topLang,
|
||||
updated_at: (d.updated_at as string) ?? "",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [link.workspace, link.repo]);
|
||||
|
||||
return (
|
||||
<RepoDrawer repo={link.repo} workspace={link.workspace}>
|
||||
<div className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-all duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading repo info…
|
||||
</div>
|
||||
) : error || !info ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<BookOpen className="size-4 shrink-0 text-muted-foreground/30" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold text-foreground/70">
|
||||
{link.workspace}/{link.repo}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/40">
|
||||
Click to open repository
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground/25" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40">
|
||||
<BookOpen className="size-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold text-foreground">
|
||||
{link.workspace}/
|
||||
<span className="text-primary/80">{link.repo}</span>
|
||||
</p>
|
||||
{info.description && (
|
||||
<p className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
|
||||
{info.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/20" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground/50">
|
||||
{info.language && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-2.5 rounded-full"
|
||||
style={{ backgroundColor: languageColor(info.language) }}
|
||||
/>
|
||||
{info.language}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{info.updated_at ? timeAgo(info.updated_at) : ""}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</RepoDrawer>
|
||||
);
|
||||
}
|
||||
38
src/page/workspace/channel/repo-link-parser.ts
Normal file
38
src/page/workspace/channel/repo-link-parser.ts
Normal file
@ -0,0 +1,38 @@
|
||||
const REPO_PATH_RE =
|
||||
/(?:^|\s)(https?:\/\/[^\s/]+)\/([^\s/]+)\/repo\/([^\s/?&#]+)(\/[^\s]*)?(?=\s|$)/g;
|
||||
|
||||
export interface RepoLinkMatch {
|
||||
/** Full matched URL string */
|
||||
url: string;
|
||||
/** Domain (e.g. "https://gitdata.ai") */
|
||||
domain: string;
|
||||
/** Workspace / org name */
|
||||
workspace: string;
|
||||
/** Repo name */
|
||||
repo: string;
|
||||
/** Extra path after the repo name (e.g. "/code", "/issues") */
|
||||
rest: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse repo links out of a text message.
|
||||
* Only matches links that share the same origin as the current page.
|
||||
*/
|
||||
export function parseRepoLinks(text: string): RepoLinkMatch[] {
|
||||
const currentOrigin = window.location.origin;
|
||||
const results: RepoLinkMatch[] = [];
|
||||
|
||||
for (const match of text.matchAll(REPO_PATH_RE)) {
|
||||
const domain = match[1];
|
||||
if (domain !== currentOrigin) continue;
|
||||
results.push({
|
||||
url: match[0].trim(),
|
||||
domain,
|
||||
workspace: match[2],
|
||||
repo: match[3],
|
||||
rest: match[4] ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Hash, Loader2, Plus } from "lucide-react";
|
||||
import { Hash, Loader2, Plus, Newspaper } from "lucide-react";
|
||||
import { api } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -38,6 +38,7 @@ export default function RoomCreateDialog({
|
||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||
const [name, setName] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [channelType, setChannelType] = useState("channel");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [newCategoryName, setNewCategoryName] = useState("");
|
||||
const [creatingCategory, setCreatingCategory] = useState(false);
|
||||
@ -75,12 +76,13 @@ export default function RoomCreateDialog({
|
||||
room_name: trimmed,
|
||||
public: isPublic,
|
||||
category: categoryId || null,
|
||||
channel_type: channelType === "channel" ? null : channelType,
|
||||
});
|
||||
setName("");
|
||||
setIsPublic(true);
|
||||
setChannelType("channel");
|
||||
setCategoryId("");
|
||||
setNewCategoryName("");
|
||||
setAiEnabled(false);
|
||||
setOpen(false);
|
||||
onCreated?.();
|
||||
} catch {
|
||||
@ -88,7 +90,7 @@ export default function RoomCreateDialog({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [name, isPublic, categoryId, workspaceId, onCreated]);
|
||||
}, [name, isPublic, channelType, categoryId, workspaceId, onCreated, setOpen]);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
@ -96,7 +98,11 @@ export default function RoomCreateDialog({
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Hash className="size-5 text-primary/60" />
|
||||
{channelType === "article" ? (
|
||||
<Newspaper className="size-5 text-primary/60" />
|
||||
) : (
|
||||
<Hash className="size-5 text-primary/60" />
|
||||
)}
|
||||
Create Channel
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
@ -105,6 +111,37 @@ export default function RoomCreateDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Channel type selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[13px]">Channel type</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-all ${
|
||||
channelType === "channel"
|
||||
? "border-primary/40 bg-primary/[0.06] text-foreground"
|
||||
: "border-border/30 text-muted-foreground/60 hover:border-border/50"
|
||||
}`}
|
||||
onClick={() => setChannelType("channel")}
|
||||
type="button"
|
||||
>
|
||||
<Hash className="size-4" />
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-all ${
|
||||
channelType === "article"
|
||||
? "border-primary/40 bg-primary/[0.06] text-foreground"
|
||||
: "border-border/30 text-muted-foreground/60 hover:border-border/50"
|
||||
}`}
|
||||
onClick={() => setChannelType("article")}
|
||||
type="button"
|
||||
>
|
||||
<Newspaper className="size-4" />
|
||||
Article
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[13px]" htmlFor="channel-name">
|
||||
Channel name
|
||||
|
||||
111
src/page/workspace/channel/use-article-draft.ts
Normal file
111
src/page/workspace/channel/use-article-draft.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const DRAFT_KEY = "article-composer-draft";
|
||||
|
||||
export type ArticleDraft = {
|
||||
channel: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
function loadDraft(): ArticleDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as ArticleDraft;
|
||||
if (!parsed.channel || (!parsed.title && !parsed.content)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(draft: ArticleDraft | null) {
|
||||
if (!draft) {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
|
||||
} catch {
|
||||
// Storage full or unavailable — silently drop
|
||||
}
|
||||
}
|
||||
|
||||
export function useArticleDraft(defaultChannel?: string) {
|
||||
const [draft, setDraftState] = useState<ArticleDraft | null>(loadDraft);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const draftRef = useRef(draft);
|
||||
|
||||
// Debounced persist
|
||||
const persist = useCallback((next: ArticleDraft | null) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setDraftState(next);
|
||||
timerRef.current = setTimeout(() => {
|
||||
saveDraft(next);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Keep ref in sync — only for unmount flush
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
saveDraft(draftRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initDraft = useCallback(
|
||||
(channel: string) => {
|
||||
const existing = loadDraft();
|
||||
if (existing && existing.channel === channel) {
|
||||
setDraftState(existing);
|
||||
return existing;
|
||||
}
|
||||
const fresh: ArticleDraft = {
|
||||
channel,
|
||||
title: "",
|
||||
coverUrl: "",
|
||||
content: "",
|
||||
summary: "",
|
||||
tags: [],
|
||||
};
|
||||
setDraftState(fresh);
|
||||
saveDraft(null);
|
||||
return fresh;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
setDraftState(null);
|
||||
saveDraft(null);
|
||||
}, []);
|
||||
|
||||
// Load draft on mount if channel matches
|
||||
useEffect(() => {
|
||||
if (defaultChannel) {
|
||||
const existing = loadDraft();
|
||||
if (!existing || existing.channel !== defaultChannel) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync on mount
|
||||
setDraftState(null);
|
||||
} else {
|
||||
setDraftState(existing);
|
||||
}
|
||||
}
|
||||
}, [defaultChannel]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
persist,
|
||||
initDraft,
|
||||
clearDraft,
|
||||
};
|
||||
}
|
||||
@ -235,6 +235,14 @@ export default function CodeTab() {
|
||||
},
|
||||
enabled: Boolean(currentTreeOid),
|
||||
retry: false,
|
||||
// Backend returns entries immediately but enriches commit messages async.
|
||||
// Poll until enrichment is done (cached result includes commit messages).
|
||||
refetchInterval(query) {
|
||||
const data = query.state.data;
|
||||
if (!data || data.length === 0) return false;
|
||||
const hasCommit = data.some((e: any) => e.last_commit_message);
|
||||
return hasCommit ? false : 2000;
|
||||
},
|
||||
});
|
||||
|
||||
const displayEntries = (fullEntries ?? fastEntries) ?? [];
|
||||
|
||||
@ -70,7 +70,7 @@ export default function CommitDetailPage() {
|
||||
}
|
||||
|
||||
const extras = commit.parent_ids.length > 1 ? `(merge commit: ${commit.parent_ids.length} parents)` : "";
|
||||
const deltas = (diffData as any)?.deltas ?? [];
|
||||
const deltas = ((diffData as any)?.deltas ?? []).filter((d: any) => d.status !== "tree");
|
||||
const selectedDelta = deltas[selectedFileIndex] ?? null;
|
||||
|
||||
return (
|
||||
|
||||
@ -1,10 +1,26 @@
|
||||
import { useParams } from "react-router";
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import { NavLink, Outlet, Navigate } from "react-router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { client } from "@/client";
|
||||
import { Lock, Globe, Archive, GitFork, Star, Eye, EyeOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RepoIndexRedirect() {
|
||||
const { projectName = "", repoName = "" } = useParams();
|
||||
const { data: readme } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "readme"],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitGetReadme(projectName, repoName);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (readme?.html) return <Navigate replace to="readme" />;
|
||||
return <Navigate replace to="code" />;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes === 0) return "Empty";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user