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" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GitDataAI</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
pub mod rest;
|
pub mod rest;
|
||||||
|
pub mod rest_article;
|
||||||
pub mod rest_interact;
|
pub mod rest_interact;
|
||||||
pub mod rest_member;
|
pub mod rest_member;
|
||||||
pub mod rest_message;
|
pub mod rest_message;
|
||||||
@ -188,6 +189,35 @@ pub fn configure(cfg: &mut ServiceConfig, bus: ChannelBus) {
|
|||||||
actix_web::web::resource("/users/summary/{username}")
|
actix_web::web::resource("/users/summary/{username}")
|
||||||
.route(actix_web::web::get().to(rest_user::user_summary)),
|
.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(
|
cfg.service(
|
||||||
actix_web::web::resource("/token")
|
actix_web::web::resource("/token")
|
||||||
.route(actix_web::web::post().to(token::generate_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 public: bool,
|
||||||
pub category: Option<Uuid>,
|
pub category: Option<Uuid>,
|
||||||
pub ai_enabled: Option<bool>,
|
pub ai_enabled: Option<bool>,
|
||||||
|
pub channel_type: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||||
@ -150,6 +151,7 @@ pub async fn room_create(
|
|||||||
public: body.public,
|
public: body.public,
|
||||||
category: body.category,
|
category: body.category,
|
||||||
ai_enabled: body.ai_enabled,
|
ai_enabled: body.ai_enabled,
|
||||||
|
channel_type: body.channel_type.clone(),
|
||||||
};
|
};
|
||||||
let result = WsHandler::handle(&bus, user_id, msg)
|
let result = WsHandler::handle(&bus, user_id, msg)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -298,6 +298,17 @@ use utoipa::openapi::security::{
|
|||||||
crate::channel::rest_voice::voice_deaf,
|
crate::channel::rest_voice::voice_deaf,
|
||||||
crate::channel::rest_voice::screen_share,
|
crate::channel::rest_voice::screen_share,
|
||||||
crate::channel::rest_user::user_summary,
|
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,
|
crate::search::search,
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon)
|
modifiers(&SecurityAddon)
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use std::{
|
|||||||
use cache::AppCache;
|
use cache::AppCache;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use db::AppDatabase;
|
use db::AppDatabase;
|
||||||
use model::room::RoomMessageModel;
|
use model::channel::RoomMessageModel;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use socketio::{Socket, SocketIo};
|
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 {
|
Self {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name.clone(),
|
name: m.name.clone(),
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
pub mod article;
|
||||||
pub mod attachment;
|
pub mod attachment;
|
||||||
pub mod ban;
|
pub mod ban;
|
||||||
pub mod category;
|
pub mod category;
|
||||||
@ -22,7 +23,7 @@ pub mod workspace;
|
|||||||
|
|
||||||
pub use common::{RoomInfo, UserInfo, WorkspaceInfo};
|
pub use common::{RoomInfo, UserInfo, WorkspaceInfo};
|
||||||
|
|
||||||
use model::room::RoomMessageModel;
|
use model::channel::RoomMessageModel;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|||||||
@ -11,7 +11,7 @@ impl EventDispatcher {
|
|||||||
pub fn dispatch_message(
|
pub fn dispatch_message(
|
||||||
room_id: Uuid,
|
room_id: Uuid,
|
||||||
room_name: &str,
|
room_name: &str,
|
||||||
msg: &model::room::RoomMessageModel,
|
msg: &model::channel::RoomMessageModel,
|
||||||
) -> WsOutEvent {
|
) -> WsOutEvent {
|
||||||
let room = RoomInfo {
|
let room = RoomInfo {
|
||||||
id: room_id,
|
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?;
|
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) \
|
"INSERT INTO room_category (wk, name, position, created_at, updated_at) \
|
||||||
VALUES ($1, $2, $3, now(), now()) \
|
VALUES ($1, $2, $3, now(), now()) \
|
||||||
RETURNING id, wk, name, position, collapsed, created_at, updated_at",
|
RETURNING id, wk, name, position, collapsed, created_at, updated_at",
|
||||||
@ -62,7 +62,7 @@ impl WsHandler {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
position: Option<i32>,
|
position: Option<i32>,
|
||||||
) -> ChannelResult<Option<WsOutEvent>> {
|
) -> 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 \
|
"SELECT id, wk, name, position, collapsed, created_at, updated_at \
|
||||||
FROM room_category WHERE id = $1",
|
FROM room_category WHERE id = $1",
|
||||||
)
|
)
|
||||||
@ -108,7 +108,7 @@ impl WsHandler {
|
|||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> ChannelResult<Option<WsOutEvent>> {
|
) -> 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 \
|
"SELECT id, wk, name, position, collapsed, created_at, updated_at \
|
||||||
FROM room_category WHERE id = $1",
|
FROM room_category WHERE id = $1",
|
||||||
)
|
)
|
||||||
@ -116,7 +116,7 @@ impl WsHandler {
|
|||||||
.fetch_one(bus.inner.db.reader())
|
.fetch_one(bus.inner.db.reader())
|
||||||
.await?;
|
.await?;
|
||||||
Self::ensure_workspace_member(bus, _user_id, existing.wk).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 \
|
"DELETE FROM room_category WHERE id = $1 \
|
||||||
RETURNING id, wk, name, position, collapsed, created_at, updated_at",
|
RETURNING id, wk, name, position, collapsed, created_at, updated_at",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -37,7 +37,7 @@ impl WsHandler {
|
|||||||
"forwarded_by": user_id,
|
"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 \
|
"INSERT INTO room_message \
|
||||||
(room, seq, thread, parent, author, content, content_type, system_type, metadata) \
|
(room, seq, thread, parent, author, content, content_type, system_type, metadata) \
|
||||||
VALUES ($1, $2, NULL, NULL, $3, $4, 'forward', NULL, $5) \
|
VALUES ($1, $2, NULL, NULL, $3, $4, 'forward', NULL, $5) \
|
||||||
|
|||||||
@ -136,7 +136,7 @@ impl WsHandler {
|
|||||||
}
|
}
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(super) fn message_data(
|
pub(super) fn message_data(
|
||||||
m: model::room::RoomMessageModel,
|
m: model::channel::RoomMessageModel,
|
||||||
) -> message::MessageNewService {
|
) -> message::MessageNewService {
|
||||||
message::MessageNewService {
|
message::MessageNewService {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
|
|||||||
@ -143,7 +143,7 @@ impl WsHandler {
|
|||||||
|
|
||||||
if should_create {
|
if should_create {
|
||||||
let seq = bus.inner.seq.seq(room).await?;
|
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) \
|
"INSERT INTO room_thread (room, seq, starter_message, title, created_by, created_at, updated_at) \
|
||||||
VALUES ($1, $2, $3, '', $4, now(), now()) \
|
VALUES ($1, $2, $3, '', $4, now(), now()) \
|
||||||
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
|
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 seq = bus.inner.seq.seq(room).await?;
|
||||||
let sender = bus.lookup_user(user_id).await?;
|
let sender = bus.lookup_user(user_id).await?;
|
||||||
let sender_for_response = sender.clone();
|
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) \
|
"INSERT INTO room_message (room, seq, thread, parent, author, content, content_type) \
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||||
RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \
|
RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \
|
||||||
@ -297,7 +297,7 @@ impl WsHandler {
|
|||||||
if old.author != user_id {
|
if old.author != user_id {
|
||||||
return Err(ChannelError::Unauthorized);
|
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() \
|
"UPDATE room_message SET content = $2, edited_at = now(), updated_at = now() \
|
||||||
WHERE id = $1 AND deleted_at IS NULL \
|
WHERE id = $1 AND deleted_at IS NULL \
|
||||||
RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \
|
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() \
|
"UPDATE room_message SET deleted_at = now(), updated_at = now() \
|
||||||
WHERE id = $1 AND deleted_at IS NULL \
|
WHERE id = $1 AND deleted_at IS NULL \
|
||||||
RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \
|
RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \
|
||||||
@ -402,8 +402,8 @@ impl WsHandler {
|
|||||||
pub(super) async fn load_message(
|
pub(super) async fn load_message(
|
||||||
bus: &ChannelBus,
|
bus: &ChannelBus,
|
||||||
message_id: Uuid,
|
message_id: Uuid,
|
||||||
) -> ChannelResult<model::room::RoomMessageModel> {
|
) -> ChannelResult<model::channel::RoomMessageModel> {
|
||||||
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, \
|
"SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \
|
||||||
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
|
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
|
||||||
FROM room_message WHERE id = $1 AND deleted_at IS NULL",
|
FROM room_message WHERE id = $1 AND deleted_at IS NULL",
|
||||||
@ -524,7 +524,7 @@ impl WsHandler {
|
|||||||
.await?;
|
.await?;
|
||||||
let starter_id = starter.map(|r| r.0);
|
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, \
|
"(SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \
|
||||||
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
|
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
|
||||||
FROM room_message \
|
FROM room_message \
|
||||||
@ -548,7 +548,7 @@ impl WsHandler {
|
|||||||
.fetch_all(bus.inner.db.reader())
|
.fetch_all(bus.inner.db.reader())
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} 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, \
|
"(SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \
|
||||||
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
|
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
|
||||||
FROM room_message \
|
FROM room_message \
|
||||||
|
|||||||
@ -12,6 +12,7 @@ pub(crate) const MAX_CATEGORY_NAME_LEN: usize = 50;
|
|||||||
|
|
||||||
mod helpers;
|
mod helpers;
|
||||||
|
|
||||||
|
mod article;
|
||||||
mod ban;
|
mod ban;
|
||||||
mod category;
|
mod category;
|
||||||
mod conversation;
|
mod conversation;
|
||||||
@ -113,10 +114,11 @@ impl WsHandler {
|
|||||||
public,
|
public,
|
||||||
category,
|
category,
|
||||||
ai_enabled,
|
ai_enabled,
|
||||||
|
channel_type,
|
||||||
} => {
|
} => {
|
||||||
Self::room_create(
|
Self::room_create(
|
||||||
bus, user_id, workspace, room_name, public, category,
|
bus, user_id, workspace, room_name, public, category,
|
||||||
ai_enabled,
|
ai_enabled, channel_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -344,6 +346,88 @@ impl WsHandler {
|
|||||||
)
|
)
|
||||||
.await
|
.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,
|
room: Uuid,
|
||||||
) -> ChannelResult<Option<WsOutEvent>> {
|
) -> ChannelResult<Option<WsOutEvent>> {
|
||||||
Self::ensure_room_access(bus, user_id, room).await?;
|
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, \
|
"SELECT id, wk, parent, name, topic, room_type, position, \
|
||||||
is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at \
|
is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at \
|
||||||
FROM room WHERE id = $1 AND deleted_at IS NULL",
|
FROM room WHERE id = $1 AND deleted_at IS NULL",
|
||||||
@ -30,7 +30,7 @@ impl WsHandler {
|
|||||||
"wk": row.wk,
|
"wk": row.wk,
|
||||||
"name": row.name,
|
"name": row.name,
|
||||||
"topic": row.topic,
|
"topic": row.topic,
|
||||||
"room_type": row.room_type,
|
"room_type": row.channel_type,
|
||||||
"is_private": row.is_private,
|
"is_private": row.is_private,
|
||||||
"is_archived": row.is_archived,
|
"is_archived": row.is_archived,
|
||||||
"ai_enabled": row.ai_enabled,
|
"ai_enabled": row.ai_enabled,
|
||||||
@ -49,6 +49,7 @@ impl WsHandler {
|
|||||||
public: bool,
|
public: bool,
|
||||||
category: Option<Uuid>,
|
category: Option<Uuid>,
|
||||||
ai_enabled: Option<bool>,
|
ai_enabled: Option<bool>,
|
||||||
|
channel_type: Option<String>,
|
||||||
) -> ChannelResult<Option<WsOutEvent>> {
|
) -> ChannelResult<Option<WsOutEvent>> {
|
||||||
if room_name.is_empty() || room_name.len() > MAX_ROOM_NAME_LEN {
|
if room_name.is_empty() || room_name.len() > MAX_ROOM_NAME_LEN {
|
||||||
return Err(ChannelError::Validation("invalid room name".into()));
|
return Err(ChannelError::Validation("invalid room name".into()));
|
||||||
@ -56,15 +57,17 @@ impl WsHandler {
|
|||||||
Self::ensure_workspace_member(bus, user_id, workspace).await?;
|
Self::ensure_workspace_member(bus, user_id, workspace).await?;
|
||||||
let is_private = !public;
|
let is_private = !public;
|
||||||
let ai = ai_enabled.unwrap_or(false);
|
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) \
|
"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, \
|
RETURNING id, wk, parent, name, topic, room_type, position, \
|
||||||
is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at",
|
is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at",
|
||||||
)
|
)
|
||||||
.bind(workspace)
|
.bind(workspace)
|
||||||
.bind(category)
|
.bind(category)
|
||||||
.bind(&room_name)
|
.bind(&room_name)
|
||||||
|
.bind(&ctype)
|
||||||
.bind(is_private)
|
.bind(is_private)
|
||||||
.bind(ai)
|
.bind(ai)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@ -112,7 +115,7 @@ impl WsHandler {
|
|||||||
ai_enabled: Option<bool>,
|
ai_enabled: Option<bool>,
|
||||||
) -> ChannelResult<Option<WsOutEvent>> {
|
) -> ChannelResult<Option<WsOutEvent>> {
|
||||||
Self::ensure_room_access(bus, user_id, room).await?;
|
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, \
|
"SELECT id, wk, parent, name, topic, room_type, position, \
|
||||||
is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at \
|
is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at \
|
||||||
FROM room WHERE id = $1 AND deleted_at IS NULL",
|
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_private = public.map(|p| !p).unwrap_or(old.is_private);
|
||||||
let new_category = category.or(old.parent);
|
let new_category = category.or(old.parent);
|
||||||
let new_ai = ai_enabled.unwrap_or(old.ai_enabled);
|
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() \
|
"UPDATE room SET name = $2, is_private = $3, parent = $4, ai_enabled = $5, updated_at = now() \
|
||||||
WHERE id = $1 AND deleted_at IS NULL \
|
WHERE id = $1 AND deleted_at IS NULL \
|
||||||
RETURNING id, wk, parent, name, topic, room_type, position, \
|
RETURNING id, wk, parent, name, topic, room_type, position, \
|
||||||
@ -216,7 +219,7 @@ impl WsHandler {
|
|||||||
room: Uuid,
|
room: Uuid,
|
||||||
) -> ChannelResult<Option<WsOutEvent>> {
|
) -> ChannelResult<Option<WsOutEvent>> {
|
||||||
Self::ensure_room_access(bus, user_id, room).await?;
|
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, \
|
"SELECT id, wk, parent, name, topic, room_type, position, \
|
||||||
is_private, is_archived, created_by, created_at, updated_at, deleted_at \
|
is_private, is_archived, created_by, created_at, updated_at, deleted_at \
|
||||||
FROM room WHERE id = $1 AND deleted_at IS NULL",
|
FROM room WHERE id = $1 AND deleted_at IS NULL",
|
||||||
@ -227,7 +230,7 @@ impl WsHandler {
|
|||||||
if old.created_by != user_id {
|
if old.created_by != user_id {
|
||||||
return Err(ChannelError::AccessDenied);
|
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() \
|
"UPDATE room SET deleted_at = now(), updated_at = now() \
|
||||||
WHERE id = $1 AND deleted_at IS NULL \
|
WHERE id = $1 AND deleted_at IS NULL \
|
||||||
RETURNING id, wk, parent, name, topic, room_type, position, \
|
RETURNING id, wk, parent, name, topic, room_type, position, \
|
||||||
|
|||||||
@ -102,7 +102,7 @@ impl WsHandler {
|
|||||||
.await?;
|
.await?;
|
||||||
let parent_msg_id = parent_id.ok_or(ChannelError::RoomNotFound)?.0;
|
let parent_msg_id = parent_id.ok_or(ChannelError::RoomNotFound)?.0;
|
||||||
let seq = bus.inner.seq.seq(room).await?;
|
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) \
|
"INSERT INTO room_thread (room, seq, starter_message, title, created_by, created_at, updated_at) \
|
||||||
VALUES ($1, $2, $3, '', $4, now(), now()) \
|
VALUES ($1, $2, $3, '', $4, now(), now()) \
|
||||||
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
|
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
|
||||||
@ -150,7 +150,7 @@ impl WsHandler {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ChannelError::RoomNotFound)?;
|
.ok_or(ChannelError::RoomNotFound)?;
|
||||||
Self::ensure_room_access(bus, user_id, existing.0).await?;
|
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() \
|
"UPDATE room_thread SET locked = true, updated_at = now() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
|
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
|
||||||
@ -193,7 +193,7 @@ impl WsHandler {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ChannelError::RoomNotFound)?;
|
.ok_or(ChannelError::RoomNotFound)?;
|
||||||
Self::ensure_room_access(bus, user_id, existing.0).await?;
|
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() \
|
"UPDATE room_thread SET archived = true, archived_at = now(), updated_at = now() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
|
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use serde::Serialize;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::event::{
|
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,
|
forward, invite, member, message, message_read, notify, pin, presence,
|
||||||
reaction, rooms, search, star, thread, voice, workspace,
|
reaction, rooms, search, star, thread, voice, workspace,
|
||||||
};
|
};
|
||||||
@ -244,6 +244,46 @@ pub enum WsOutEvent {
|
|||||||
room: RoomInfo,
|
room: RoomInfo,
|
||||||
data: forward::MessageForwardedService,
|
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 {
|
Response {
|
||||||
request_id: Uuid,
|
request_id: Uuid,
|
||||||
data: serde_json::Value,
|
data: serde_json::Value,
|
||||||
|
|||||||
@ -61,6 +61,7 @@ pub enum WsInMessage {
|
|||||||
public: bool,
|
public: bool,
|
||||||
category: Option<Uuid>,
|
category: Option<Uuid>,
|
||||||
ai_enabled: Option<bool>,
|
ai_enabled: Option<bool>,
|
||||||
|
channel_type: Option<String>,
|
||||||
},
|
},
|
||||||
RoomUpdate {
|
RoomUpdate {
|
||||||
room: Uuid,
|
room: Uuid,
|
||||||
@ -250,6 +251,60 @@ pub enum WsInMessage {
|
|||||||
source_message_id: Uuid,
|
source_message_id: Uuid,
|
||||||
target_room: 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 {
|
macro_rules! room_variants {
|
||||||
|
|||||||
@ -62,7 +62,7 @@ impl MessagePagination {
|
|||||||
|
|
||||||
let messages = match (params.direction, cursor_seq) {
|
let messages = match (params.direction, cursor_seq) {
|
||||||
(PaginationDirection::Before, Some(seq)) => {
|
(PaginationDirection::Before, Some(seq)) => {
|
||||||
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
|
db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
|
||||||
db::sqlx::AssertSqlSafe(format!(
|
db::sqlx::AssertSqlSafe(format!(
|
||||||
"SELECT {RM_COLUMNS} FROM room_message \
|
"SELECT {RM_COLUMNS} FROM room_message \
|
||||||
WHERE room = $1 AND seq < $2 AND deleted_at IS NULL AND thread IS NULL \
|
WHERE room = $1 AND seq < $2 AND deleted_at IS NULL AND thread IS NULL \
|
||||||
@ -76,7 +76,7 @@ impl MessagePagination {
|
|||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
(PaginationDirection::After, Some(seq)) => {
|
(PaginationDirection::After, Some(seq)) => {
|
||||||
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
|
db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
|
||||||
db::sqlx::AssertSqlSafe(format!(
|
db::sqlx::AssertSqlSafe(format!(
|
||||||
"SELECT {RM_COLUMNS} FROM room_message \
|
"SELECT {RM_COLUMNS} FROM room_message \
|
||||||
WHERE room = $1 AND seq > $2 AND deleted_at IS NULL AND thread IS NULL \
|
WHERE room = $1 AND seq > $2 AND deleted_at IS NULL AND thread IS NULL \
|
||||||
@ -90,7 +90,7 @@ impl MessagePagination {
|
|||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
|
db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
|
||||||
db::sqlx::AssertSqlSafe(format!(
|
db::sqlx::AssertSqlSafe(format!(
|
||||||
"SELECT {RM_COLUMNS} FROM room_message \
|
"SELECT {RM_COLUMNS} FROM room_message \
|
||||||
WHERE room = $1 AND deleted_at IS NULL AND thread IS NULL \
|
WHERE room = $1 AND deleted_at IS NULL AND thread IS NULL \
|
||||||
@ -148,7 +148,7 @@ impl MessagePagination {
|
|||||||
message_id: Uuid,
|
message_id: Uuid,
|
||||||
context_size: i64,
|
context_size: i64,
|
||||||
) -> ChannelResult<MessagePage> {
|
) -> ChannelResult<MessagePage> {
|
||||||
let target = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
|
let target = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
|
||||||
db::sqlx::AssertSqlSafe(format!(
|
db::sqlx::AssertSqlSafe(format!(
|
||||||
"SELECT {RM_COLUMNS} FROM room_message \
|
"SELECT {RM_COLUMNS} FROM room_message \
|
||||||
WHERE id = $1 AND room = $2 AND deleted_at IS NULL"
|
WHERE id = $1 AND room = $2 AND deleted_at IS NULL"
|
||||||
@ -160,7 +160,7 @@ impl MessagePagination {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ChannelError::Internal("message not found".to_string()))?;
|
.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!(
|
db::sqlx::AssertSqlSafe(format!(
|
||||||
"SELECT {RM_COLUMNS} FROM room_message \
|
"SELECT {RM_COLUMNS} FROM room_message \
|
||||||
WHERE room = $1 AND seq < $2 AND deleted_at IS NULL \
|
WHERE room = $1 AND seq < $2 AND deleted_at IS NULL \
|
||||||
@ -173,7 +173,7 @@ impl MessagePagination {
|
|||||||
.fetch_all(self.db.reader())
|
.fetch_all(self.db.reader())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let after = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
|
let after = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
|
||||||
db::sqlx::AssertSqlSafe(format!(
|
db::sqlx::AssertSqlSafe(format!(
|
||||||
"SELECT {RM_COLUMNS} FROM room_message \
|
"SELECT {RM_COLUMNS} FROM room_message \
|
||||||
WHERE room = $1 AND seq > $2 AND deleted_at IS NULL \
|
WHERE room = $1 AND seq > $2 AND deleted_at IS NULL \
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use model::room::RoomMessageModel;
|
use model::channel::RoomMessageModel;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::ChannelResult;
|
use crate::ChannelResult;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use cache::AppCache;
|
use cache::AppCache;
|
||||||
use db::{AppDatabase, sqlx};
|
use db::{AppDatabase, sqlx};
|
||||||
use model::room::RoomMessageModel;
|
use model::channel::RoomMessageModel;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ pub struct RoomListItem {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub topic: Option<String>,
|
pub topic: Option<String>,
|
||||||
|
/// Maps to DB `room_type` column. Serialized as `room_type` for frontend compat.
|
||||||
pub room_type: String,
|
pub room_type: String,
|
||||||
pub is_private: bool,
|
pub is_private: bool,
|
||||||
pub ai_enabled: bool,
|
pub ai_enabled: bool,
|
||||||
|
|||||||
@ -61,7 +61,7 @@ impl SearchEngine {
|
|||||||
|
|
||||||
let total = count.0 as u64;
|
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, \
|
"SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \
|
||||||
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
|
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
|
||||||
FROM room_message \
|
FROM room_message \
|
||||||
|
|||||||
@ -64,9 +64,7 @@ impl GitBare {
|
|||||||
// Only diff blobs — skip trees (directories)
|
// Only diff blobs — skip trees (directories)
|
||||||
let entry_mode = change.entry_mode();
|
let entry_mode = change.entry_mode();
|
||||||
if entry_mode.is_tree() {
|
if entry_mode.is_tree() {
|
||||||
stats.files_changed += 1;
|
|
||||||
resource_cache.clear_resource_cache_keep_allocation();
|
resource_cache.clear_resource_cache_keep_allocation();
|
||||||
deltas.push(delta);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -63,7 +63,6 @@ impl GitBare {
|
|||||||
|
|
||||||
// Skip directories — only diff blobs
|
// Skip directories — only diff blobs
|
||||||
if change.entry_mode().is_tree() {
|
if change.entry_mode().is_tree() {
|
||||||
stats.files_changed += 1;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -292,9 +292,7 @@ impl GitBare {
|
|||||||
|
|
||||||
// Skip directories — only diff blobs
|
// Skip directories — only diff blobs
|
||||||
if change.entry_mode().is_tree() {
|
if change.entry_mode().is_tree() {
|
||||||
stats.files_changed += 1;
|
|
||||||
resource_cache.clear_resource_cache_keep_allocation();
|
resource_cache.clear_resource_cache_keep_allocation();
|
||||||
deltas.push(delta);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -105,6 +105,7 @@ impl GitServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Server::builder()
|
Server::builder()
|
||||||
|
.max_frame_size(Some(16 * 1024 * 1024 - 1))
|
||||||
.add_service(archive)
|
.add_service(archive)
|
||||||
.add_service(blame)
|
.add_service(blame)
|
||||||
.add_service(blob)
|
.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_read;
|
||||||
pub mod message_star;
|
pub mod message_star;
|
||||||
pub mod room;
|
|
||||||
pub mod room_attachments;
|
pub mod room_attachments;
|
||||||
pub mod room_categories;
|
pub mod room_categories;
|
||||||
pub mod room_mention;
|
pub mod room_mention;
|
||||||
@ -15,7 +17,17 @@ pub mod user_room_state;
|
|||||||
|
|
||||||
pub use message_read::MessageReadModel;
|
pub use message_read::MessageReadModel;
|
||||||
pub use message_star::MessageStarModel;
|
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_attachments::RoomAttachmentModel;
|
||||||
pub use room_categories::RoomCategoryModel;
|
pub use room_categories::RoomCategoryModel;
|
||||||
pub use room_mention::RoomMentionModel;
|
pub use room_mention::RoomMentionModel;
|
||||||
@ -7,7 +7,7 @@ pub mod logs;
|
|||||||
pub mod notify;
|
pub mod notify;
|
||||||
pub mod pull_request;
|
pub mod pull_request;
|
||||||
pub mod repos;
|
pub mod repos;
|
||||||
pub mod room;
|
pub mod channel;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod workspace;
|
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};
|
use crate::{AppService, error::AppError, git::rpc_err};
|
||||||
|
|
||||||
|
const MAX_GRPC_MSG: usize = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
impl AppService {
|
impl AppService {
|
||||||
pub async fn git_diff_stats(
|
pub async fn git_diff_stats(
|
||||||
&self,
|
&self,
|
||||||
@ -14,7 +16,8 @@ impl AppService {
|
|||||||
options: Option<p::DiffOptions>,
|
options: Option<p::DiffOptions>,
|
||||||
) -> Result<p::DiffStatsResponse, AppError> {
|
) -> Result<p::DiffStatsResponse, AppError> {
|
||||||
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
|
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
|
let resp = client
|
||||||
.diff_stats(tonic::Request::new(p::DiffStatsRequest {
|
.diff_stats(tonic::Request::new(p::DiffStatsRequest {
|
||||||
repo_id: repo.id.to_string(),
|
repo_id: repo.id.to_string(),
|
||||||
@ -38,7 +41,8 @@ impl AppService {
|
|||||||
options: Option<p::DiffOptions>,
|
options: Option<p::DiffOptions>,
|
||||||
) -> Result<p::DiffPatchResponse, AppError> {
|
) -> Result<p::DiffPatchResponse, AppError> {
|
||||||
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
|
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
|
let resp = client
|
||||||
.diff_patch(tonic::Request::new(p::DiffPatchRequest {
|
.diff_patch(tonic::Request::new(p::DiffPatchRequest {
|
||||||
repo_id: repo.id.to_string(),
|
repo_id: repo.id.to_string(),
|
||||||
@ -62,7 +66,8 @@ impl AppService {
|
|||||||
options: Option<p::DiffOptions>,
|
options: Option<p::DiffOptions>,
|
||||||
) -> Result<p::DiffPatchSideBySideResponse, AppError> {
|
) -> Result<p::DiffPatchSideBySideResponse, AppError> {
|
||||||
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
|
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
|
let resp = client
|
||||||
.diff_patch_side_by_side(tonic::Request::new(
|
.diff_patch_side_by_side(tonic::Request::new(
|
||||||
p::DiffPatchSideBySideRequest {
|
p::DiffPatchSideBySideRequest {
|
||||||
@ -88,7 +93,8 @@ impl AppService {
|
|||||||
options: Option<p::DiffOptions>,
|
options: Option<p::DiffOptions>,
|
||||||
) -> Result<p::DiffTreeToTreeResponse, AppError> {
|
) -> Result<p::DiffTreeToTreeResponse, AppError> {
|
||||||
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
|
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
|
let resp = client
|
||||||
.diff_tree_to_tree(tonic::Request::new(p::DiffTreeToTreeRequest {
|
.diff_tree_to_tree(tonic::Request::new(p::DiffTreeToTreeRequest {
|
||||||
repo_id: repo.id.to_string(),
|
repo_id: repo.id.to_string(),
|
||||||
@ -111,7 +117,8 @@ impl AppService {
|
|||||||
options: Option<p::DiffOptions>,
|
options: Option<p::DiffOptions>,
|
||||||
) -> Result<p::DiffIndexToTreeResponse, AppError> {
|
) -> Result<p::DiffIndexToTreeResponse, AppError> {
|
||||||
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
|
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
|
let resp = client
|
||||||
.diff_index_to_tree(tonic::Request::new(
|
.diff_index_to_tree(tonic::Request::new(
|
||||||
p::DiffIndexToTreeRequest {
|
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}": {
|
"/api/v1/ws/categories/{category_id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": [
|
"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": {
|
"/api/v1/ws/csrf": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -11621,6 +12001,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/ws/rooms": {
|
"/api/v1/ws/rooms": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"channel"
|
||||||
|
],
|
||||||
|
"operationId": "channel_list_rooms",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of rooms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"channel"
|
"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": {
|
"AssignIssueUser": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@ -16803,6 +17327,12 @@
|
|||||||
],
|
],
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
|
"channel_type": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"public": {
|
"public": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,6 +17,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { PersonalShell, WorkspaceShell, SettingsShell } from "@/components/shell
|
|||||||
import WorkspaceRepositoriesPage from "@/page/workspace/repositories";
|
import WorkspaceRepositoriesPage from "@/page/workspace/repositories";
|
||||||
import WorkspaceIssuesPage from "@/page/workspace/issues";
|
import WorkspaceIssuesPage from "@/page/workspace/issues";
|
||||||
import IssueDetailPage from "@/page/workspace/issues/detail";
|
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 CodeTab from "@/page/workspace/repo/code";
|
||||||
import CommitsTab from "@/page/workspace/repo/commits";
|
import CommitsTab from "@/page/workspace/repo/commits";
|
||||||
import BranchesTab from "@/page/workspace/repo/branches";
|
import BranchesTab from "@/page/workspace/repo/branches";
|
||||||
@ -88,7 +88,7 @@ function App() {
|
|||||||
path: "repo/:repoName",
|
path: "repo/:repoName",
|
||||||
element: <RepoLayout />,
|
element: <RepoLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate replace to="code" /> },
|
{ index: true, element: <RepoIndexRedirect /> },
|
||||||
{ path: "code", element: <CodeTab /> },
|
{ path: "code", element: <CodeTab /> },
|
||||||
{ path: "readme", element: <ReadmePage /> },
|
{ path: "readme", element: <ReadmePage /> },
|
||||||
{ path: "commits", element: <CommitsTab /> },
|
{ 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 './aiModelVersionResponse';
|
||||||
export * from './aiProviderResponse';
|
export * from './aiProviderResponse';
|
||||||
export * from './approveWorkspaceJoinApply';
|
export * from './approveWorkspaceJoinApply';
|
||||||
|
export * from './articleCommentCreateRequest';
|
||||||
|
export * from './articleCreateRequest';
|
||||||
|
export * from './articleLikeRequest';
|
||||||
|
export * from './articleUpdateRequest';
|
||||||
export * from './assignIssueUser';
|
export * from './assignIssueUser';
|
||||||
export * from './assignPrUser';
|
export * from './assignPrUser';
|
||||||
export * from './authCaptchaParams';
|
export * from './authCaptchaParams';
|
||||||
@ -52,6 +56,9 @@ export * from './captchaQuery';
|
|||||||
export * from './captchaResponse';
|
export * from './captchaResponse';
|
||||||
export * from './categoryCreateRequest';
|
export * from './categoryCreateRequest';
|
||||||
export * from './categoryUpdateRequest';
|
export * from './categoryUpdateRequest';
|
||||||
|
export * from './channelArticleCommentListParams';
|
||||||
|
export * from './channelArticleLikedUsersParams';
|
||||||
|
export * from './channelArticleListParams';
|
||||||
export * from './channelListMessagesParams';
|
export * from './channelListMessagesParams';
|
||||||
export * from './channelMessagesAroundParams';
|
export * from './channelMessagesAroundParams';
|
||||||
export * from './channelMissedMessagesParams';
|
export * from './channelMissedMessagesParams';
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export interface RoomCreateRequest {
|
|||||||
ai_enabled?: boolean | null;
|
ai_enabled?: boolean | null;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
channel_type?: string | null;
|
||||||
public: boolean;
|
public: boolean;
|
||||||
room_name: string;
|
room_name: string;
|
||||||
workspace: 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 { Hash, Settings, Users } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -55,16 +54,12 @@ export function ChannelHeader({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger
|
||||||
<Button
|
aria-label="Channel settings"
|
||||||
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"
|
||||||
className="size-8 cursor-pointer rounded-lg text-muted-foreground/40 transition-all duration-150 hover:bg-accent/40 hover:text-foreground"
|
onClick={onToggleSettings}
|
||||||
onClick={onToggleSettings}
|
>
|
||||||
size="icon"
|
<Settings className="size-[18px]" />
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Settings className="size-[18px]" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="text-xs">Channel settings</TooltipContent>
|
<TooltipContent className="text-xs">Channel settings</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Newspaper,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useLocation, useParams } from "react-router";
|
import { Link, useLocation, useParams } from "react-router";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -38,6 +39,8 @@ type Props = {
|
|||||||
function RoomIcon({ type }: { type: string }) {
|
function RoomIcon({ type }: { type: string }) {
|
||||||
if (type === "voice")
|
if (type === "voice")
|
||||||
return <Volume2 className="size-[15px] shrink-0 text-muted-foreground/60" />;
|
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" />;
|
return <Hash className="size-[15px] shrink-0 text-muted-foreground/60" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,37 +21,49 @@ export function ChannelThreadPanel({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
|
const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
|
||||||
const prevOpen = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedThreadId) {
|
if (prevRoomId.current !== roomId) {
|
||||||
const thread = threads.find((t) => t.id === selectedThreadId);
|
prevRoomId.current = roomId;
|
||||||
if (thread) {
|
setSelectedThread(null);
|
||||||
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]);
|
// Also reset when open transitions from true to false (after room switch reset)
|
||||||
|
|
||||||
// Reset selection when panel closes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open && prevOpen.current) {
|
if (!open && prevOpen.current) {
|
||||||
setSelectedThread(null);
|
setSelectedThread(null);
|
||||||
}
|
}
|
||||||
prevOpen.current = open;
|
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;
|
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 { useParams } from "react-router";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { api } from "@/client";
|
import { api } from "@/client";
|
||||||
@ -8,6 +8,9 @@ import { ChannelHeader } from "./channel-header";
|
|||||||
import { ChannelThreadPanel } from "./channel-thread-panel";
|
import { ChannelThreadPanel } from "./channel-thread-panel";
|
||||||
import RoomSettingsDialog from "./room-settings-dialog";
|
import RoomSettingsDialog from "./room-settings-dialog";
|
||||||
import MessageView from "./message-view";
|
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() {
|
export default function ChannelPage() {
|
||||||
const { roomId } = useParams();
|
const { roomId } = useParams();
|
||||||
@ -18,6 +21,41 @@ export default function ChannelPage() {
|
|||||||
const [activeThreadSeq, setActiveThreadSeq] = useState<number>(0);
|
const [activeThreadSeq, setActiveThreadSeq] = useState<number>(0);
|
||||||
const [showRoomSettings, setShowRoomSettings] = useState(false);
|
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(
|
const handleStartThread = useCallback(
|
||||||
async (_messageId: string, seq: number) => {
|
async (_messageId: string, seq: number) => {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
@ -74,36 +112,47 @@ export default function ChannelPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageView
|
{state.currentRoom?.room_type === "article" && roomId ? (
|
||||||
currentUserId={state.currentUserId}
|
<ArticleFeed
|
||||||
hasMore={state.hasMore}
|
currentUserId={state.currentUserId}
|
||||||
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}
|
roomId={roomId}
|
||||||
selectedThreadId={activeThreadId}
|
roomName={state.currentRoom.name}
|
||||||
threads={state.threads}
|
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 && (
|
{roomId && state.currentRoom && (
|
||||||
@ -121,6 +170,18 @@ export default function ChannelPage() {
|
|||||||
topic={state.currentRoom.topic}
|
topic={state.currentRoom.topic}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Global article composer */}
|
||||||
|
{showComposer && composerChannel && (
|
||||||
|
<ArticleComposer
|
||||||
|
channel={composerChannel}
|
||||||
|
draft={draft}
|
||||||
|
onClearDraft={clearDraft}
|
||||||
|
onClose={closeComposer}
|
||||||
|
onCreated={handleArticleCreated}
|
||||||
|
onDraftChange={persist}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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 { Textarea } from "@/components/ui/textarea";
|
||||||
import type { MessageNewService } from "@/socket";
|
import type { MessageNewService } from "@/socket";
|
||||||
import type { Thread } from "./thread-sidebar";
|
import type { Thread } from "./thread-sidebar";
|
||||||
|
import MessageContent from "./message-content";
|
||||||
|
|
||||||
export function formatTime(iso: string) {
|
export function formatTime(iso: string) {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@ -280,17 +281,11 @@ export default function MessageItem({
|
|||||||
<span>Esc to cancel · Enter to save</span>
|
<span>Esc to cancel · Enter to save</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<MessageContent
|
||||||
<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">
|
content={message.content}
|
||||||
{message.content_type}
|
contentType={message.content_type}
|
||||||
</span>{" "}
|
/>
|
||||||
{message.content}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{threadForMessage && !message.thread && (
|
{threadForMessage && !message.thread && (
|
||||||
@ -322,26 +317,32 @@ export default function MessageItem({
|
|||||||
<span className="text-[10px] font-semibold tabular-nums">{r.count}</span>
|
<span className="text-[10px] font-semibold tabular-nums">{r.count}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<ReactionPicker onSelect={(emoji) => handleReaction(emoji)}>
|
<ReactionPicker
|
||||||
<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">
|
onSelect={(emoji) => handleReaction(emoji)}
|
||||||
<SmilePlus className="size-3" />
|
trigger={
|
||||||
</button>
|
<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">
|
||||||
</ReactionPicker>
|
<SmilePlus className="size-3" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
<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)}>
|
<ReactionPicker
|
||||||
<Button
|
onSelect={(emoji) => handleReaction(emoji)}
|
||||||
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
|
trigger={
|
||||||
size="icon"
|
<Button
|
||||||
title="Add reaction"
|
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
>
|
title="Add reaction"
|
||||||
<SmilePlus className="size-3.5" />
|
variant="ghost"
|
||||||
</Button>
|
>
|
||||||
</ReactionPicker>
|
<SmilePlus className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
|
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({
|
function ReactionPicker({
|
||||||
children,
|
trigger,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
trigger: React.ReactElement;
|
||||||
onSelect: (emoji: string) => void;
|
onSelect: (emoji: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger>{children}</PopoverTrigger>
|
<PopoverTrigger render={trigger} />
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
align="start"
|
align="start"
|
||||||
className="w-auto p-2"
|
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 { useCallback, useState } from "react";
|
||||||
import { Hash, Loader2, Plus } from "lucide-react";
|
import { Hash, Loader2, Plus, Newspaper } from "lucide-react";
|
||||||
import { api } from "@/client";
|
import { api } from "@/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -38,6 +38,7 @@ export default function RoomCreateDialog({
|
|||||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [isPublic, setIsPublic] = useState(true);
|
const [isPublic, setIsPublic] = useState(true);
|
||||||
|
const [channelType, setChannelType] = useState("channel");
|
||||||
const [categoryId, setCategoryId] = useState("");
|
const [categoryId, setCategoryId] = useState("");
|
||||||
const [newCategoryName, setNewCategoryName] = useState("");
|
const [newCategoryName, setNewCategoryName] = useState("");
|
||||||
const [creatingCategory, setCreatingCategory] = useState(false);
|
const [creatingCategory, setCreatingCategory] = useState(false);
|
||||||
@ -75,12 +76,13 @@ export default function RoomCreateDialog({
|
|||||||
room_name: trimmed,
|
room_name: trimmed,
|
||||||
public: isPublic,
|
public: isPublic,
|
||||||
category: categoryId || null,
|
category: categoryId || null,
|
||||||
|
channel_type: channelType === "channel" ? null : channelType,
|
||||||
});
|
});
|
||||||
setName("");
|
setName("");
|
||||||
setIsPublic(true);
|
setIsPublic(true);
|
||||||
|
setChannelType("channel");
|
||||||
setCategoryId("");
|
setCategoryId("");
|
||||||
setNewCategoryName("");
|
setNewCategoryName("");
|
||||||
setAiEnabled(false);
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onCreated?.();
|
onCreated?.();
|
||||||
} catch {
|
} catch {
|
||||||
@ -88,7 +90,7 @@ export default function RoomCreateDialog({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [name, isPublic, categoryId, workspaceId, onCreated]);
|
}, [name, isPublic, channelType, categoryId, workspaceId, onCreated, setOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onOpenChange={setOpen} open={open}>
|
<Dialog onOpenChange={setOpen} open={open}>
|
||||||
@ -96,7 +98,11 @@ export default function RoomCreateDialog({
|
|||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-base">
|
<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
|
Create Channel
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@ -105,6 +111,37 @@ export default function RoomCreateDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label className="text-[13px]" htmlFor="channel-name">
|
<Label className="text-[13px]" htmlFor="channel-name">
|
||||||
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),
|
enabled: Boolean(currentTreeOid),
|
||||||
retry: false,
|
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) ?? [];
|
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 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;
|
const selectedDelta = deltas[selectedFileIndex] ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,10 +1,26 @@
|
|||||||
import { useParams } from "react-router";
|
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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { client } from "@/client";
|
import { client } from "@/client";
|
||||||
import { Lock, Globe, Archive, GitFork, Star, Eye, EyeOff } from "lucide-react";
|
import { Lock, Globe, Archive, GitFork, Star, Eye, EyeOff } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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) {
|
function formatSize(bytes: number) {
|
||||||
if (bytes === 0) return "Empty";
|
if (bytes === 0) return "Empty";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user