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:
zhenyi 2026-05-31 03:09:49 +08:00
parent f947c931cd
commit 779e4eae2f
86 changed files with 4503 additions and 176 deletions

View File

@ -5,6 +5,26 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitDataAI</title>
<style type="text/css">
html, body {
scrollbar-width: none;
-ms-overflow-style: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none !important;
}
* {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
*::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
background: transparent !important;
}
</style>
</head>
<body>
<div id="root"></div>

View File

@ -1,4 +1,5 @@
pub mod rest;
pub mod rest_article;
pub mod rest_interact;
pub mod rest_member;
pub mod rest_message;
@ -188,6 +189,35 @@ pub fn configure(cfg: &mut ServiceConfig, bus: ChannelBus) {
actix_web::web::resource("/users/summary/{username}")
.route(actix_web::web::get().to(rest_user::user_summary)),
);
// --- Article routes (waterfall post channels) ---
cfg.service(
actix_web::web::resource("/channels/{channel_id}/articles")
.route(actix_web::web::post().to(rest_article::article_create))
.route(actix_web::web::get().to(rest_article::article_list)),
)
.service(
actix_web::web::resource("/channels/{channel_id}/articles/{article_id}")
.route(actix_web::web::get().to(rest_article::article_get))
.route(actix_web::web::patch().to(rest_article::article_update))
.route(actix_web::web::delete().to(rest_article::article_delete)),
)
.service(
actix_web::web::resource("/articles/{article_id}/like")
.route(actix_web::web::post().to(rest_article::article_like)),
)
.service(
actix_web::web::resource("/articles/{article_id}/comments")
.route(actix_web::web::post().to(rest_article::article_comment_create))
.route(actix_web::web::get().to(rest_article::article_comment_list)),
)
.service(
actix_web::web::resource("/articles/{article_id}/comments/{comment_id}")
.route(actix_web::web::delete().to(rest_article::article_comment_delete)),
)
.service(
actix_web::web::resource("/articles/{article_id}/likes")
.route(actix_web::web::get().to(rest_article::article_liked_users)),
);
cfg.service(
actix_web::web::resource("/token")
.route(actix_web::web::post().to(token::generate_token)),

View 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))
}

View File

@ -14,6 +14,7 @@ pub struct RoomCreateRequest {
pub public: bool,
pub category: Option<Uuid>,
pub ai_enabled: Option<bool>,
pub channel_type: Option<String>,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
@ -150,6 +151,7 @@ pub async fn room_create(
public: body.public,
category: body.category,
ai_enabled: body.ai_enabled,
channel_type: body.channel_type.clone(),
};
let result = WsHandler::handle(&bus, user_id, msg)
.await

View File

@ -298,6 +298,17 @@ use utoipa::openapi::security::{
crate::channel::rest_voice::voice_deaf,
crate::channel::rest_voice::screen_share,
crate::channel::rest_user::user_summary,
crate::channel::rest_room::list_rooms,
crate::channel::rest_article::article_create,
crate::channel::rest_article::article_list,
crate::channel::rest_article::article_get,
crate::channel::rest_article::article_update,
crate::channel::rest_article::article_delete,
crate::channel::rest_article::article_like,
crate::channel::rest_article::article_comment_create,
crate::channel::rest_article::article_comment_list,
crate::channel::rest_article::article_comment_delete,
crate::channel::rest_article::article_liked_users,
crate::search::search,
),
modifiers(&SecurityAddon)

View File

@ -6,7 +6,7 @@ use std::{
use cache::AppCache;
use dashmap::DashMap;
use db::AppDatabase;
use model::room::RoomMessageModel;
use model::channel::RoomMessageModel;
use serde::Deserialize;
use serde::Serialize;
use socketio::{Socket, SocketIo};

View 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,
}

View File

@ -41,7 +41,7 @@ impl RoomInfo {
}
}
pub fn from_model(m: &model::room::RoomModel) -> Self {
pub fn from_model(m: &model::channel::ChannelModel) -> Self {
Self {
id: m.id,
name: m.name.clone(),

View File

@ -1,3 +1,4 @@
pub mod article;
pub mod attachment;
pub mod ban;
pub mod category;
@ -22,7 +23,7 @@ pub mod workspace;
pub use common::{RoomInfo, UserInfo, WorkspaceInfo};
use model::room::RoomMessageModel;
use model::channel::RoomMessageModel;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;

View File

@ -11,7 +11,7 @@ impl EventDispatcher {
pub fn dispatch_message(
room_id: Uuid,
room_name: &str,
msg: &model::room::RoomMessageModel,
msg: &model::channel::RoomMessageModel,
) -> WsOutEvent {
let room = RoomInfo {
id: room_id,

View 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
}
}

View File

@ -22,7 +22,7 @@ impl WsHandler {
));
}
Self::ensure_workspace_member(bus, user_id, workspace).await?;
let row = db::sqlx::query_as::<_, model::room::RoomCategoryModel>(
let row = db::sqlx::query_as::<_, model::channel::RoomCategoryModel>(
"INSERT INTO room_category (wk, name, position, created_at, updated_at) \
VALUES ($1, $2, $3, now(), now()) \
RETURNING id, wk, name, position, collapsed, created_at, updated_at",
@ -62,7 +62,7 @@ impl WsHandler {
name: Option<String>,
position: Option<i32>,
) -> ChannelResult<Option<WsOutEvent>> {
let old = db::sqlx::query_as::<_, model::room::RoomCategoryModel>(
let old = db::sqlx::query_as::<_, model::channel::RoomCategoryModel>(
"SELECT id, wk, name, position, collapsed, created_at, updated_at \
FROM room_category WHERE id = $1",
)
@ -108,7 +108,7 @@ impl WsHandler {
_user_id: Uuid,
id: Uuid,
) -> ChannelResult<Option<WsOutEvent>> {
let existing = db::sqlx::query_as::<_, model::room::RoomCategoryModel>(
let existing = db::sqlx::query_as::<_, model::channel::RoomCategoryModel>(
"SELECT id, wk, name, position, collapsed, created_at, updated_at \
FROM room_category WHERE id = $1",
)
@ -116,7 +116,7 @@ impl WsHandler {
.fetch_one(bus.inner.db.reader())
.await?;
Self::ensure_workspace_member(bus, _user_id, existing.wk).await?;
let row = db::sqlx::query_as::<_, model::room::RoomCategoryModel>(
let row = db::sqlx::query_as::<_, model::channel::RoomCategoryModel>(
"DELETE FROM room_category WHERE id = $1 \
RETURNING id, wk, name, position, collapsed, created_at, updated_at",
)

View File

@ -37,7 +37,7 @@ impl WsHandler {
"forwarded_by": user_id,
});
let row = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
let row = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
"INSERT INTO room_message \
(room, seq, thread, parent, author, content, content_type, system_type, metadata) \
VALUES ($1, $2, NULL, NULL, $3, $4, 'forward', NULL, $5) \

View File

@ -136,7 +136,7 @@ impl WsHandler {
}
#[allow(dead_code)]
pub(super) fn message_data(
m: model::room::RoomMessageModel,
m: model::channel::RoomMessageModel,
) -> message::MessageNewService {
message::MessageNewService {
id: m.id,

View File

@ -143,7 +143,7 @@ impl WsHandler {
if should_create {
let seq = bus.inner.seq.seq(room).await?;
let thread_row = db::sqlx::query_as::<_, model::room::RoomThreadModel>(
let thread_row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>(
"INSERT INTO room_thread (room, seq, starter_message, title, created_by, created_at, updated_at) \
VALUES ($1, $2, $3, '', $4, now(), now()) \
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
@ -226,7 +226,7 @@ impl WsHandler {
let seq = bus.inner.seq.seq(room).await?;
let sender = bus.lookup_user(user_id).await?;
let sender_for_response = sender.clone();
let row = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
let row = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
"INSERT INTO room_message (room, seq, thread, parent, author, content, content_type) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \
@ -297,7 +297,7 @@ impl WsHandler {
if old.author != user_id {
return Err(ChannelError::Unauthorized);
}
let row = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
let row = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
"UPDATE room_message SET content = $2, edited_at = now(), updated_at = now() \
WHERE id = $1 AND deleted_at IS NULL \
RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \
@ -367,7 +367,7 @@ impl WsHandler {
));
}
}
let row = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
let row = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
"UPDATE room_message SET deleted_at = now(), updated_at = now() \
WHERE id = $1 AND deleted_at IS NULL \
RETURNING id, room, seq, thread, parent, author, content, content_type, pinned, \
@ -402,8 +402,8 @@ impl WsHandler {
pub(super) async fn load_message(
bus: &ChannelBus,
message_id: Uuid,
) -> ChannelResult<model::room::RoomMessageModel> {
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
) -> ChannelResult<model::channel::RoomMessageModel> {
db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
"SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
FROM room_message WHERE id = $1 AND deleted_at IS NULL",
@ -524,7 +524,7 @@ impl WsHandler {
.await?;
let starter_id = starter.map(|r| r.0);
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
"(SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
FROM room_message \
@ -548,7 +548,7 @@ impl WsHandler {
.fetch_all(bus.inner.db.reader())
.await?
} else {
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
"(SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
FROM room_message \

View File

@ -12,6 +12,7 @@ pub(crate) const MAX_CATEGORY_NAME_LEN: usize = 50;
mod helpers;
mod article;
mod ban;
mod category;
mod conversation;
@ -113,10 +114,11 @@ impl WsHandler {
public,
category,
ai_enabled,
channel_type,
} => {
Self::room_create(
bus, user_id, workspace, room_name, public, category,
ai_enabled,
ai_enabled, channel_type,
)
.await
}
@ -344,6 +346,88 @@ impl WsHandler {
)
.await
}
WsInMessage::ArticleCreate {
channel,
title,
cover_url,
content,
content_type,
summary,
tags,
status,
} => {
Self::article_create(
bus, user_id, channel, title, cover_url, content,
content_type, summary, tags, status,
)
.await
}
WsInMessage::ArticleUpdate {
article_id,
title,
cover_url,
content,
content_type,
summary,
tags,
is_pinned,
status,
} => {
Self::article_update(
bus, user_id, article_id, title, cover_url, content,
content_type, summary, tags, is_pinned, status,
)
.await
}
WsInMessage::ArticleDelete { article_id } => {
Self::article_delete(bus, user_id, article_id).await
}
WsInMessage::ArticleList {
channel,
before,
limit,
} => {
Self::article_list(bus, user_id, channel, before, limit).await
}
WsInMessage::ArticleGet { article_id } => {
Self::article_get(bus, user_id, article_id).await
}
WsInMessage::ArticleLike { article_id, like } => {
Self::article_like(bus, user_id, article_id, like).await
}
WsInMessage::ArticleCommentCreate {
article_id,
content,
parent,
} => {
Self::article_comment_create(
bus, user_id, article_id, content, parent,
)
.await
}
WsInMessage::ArticleCommentDelete { comment_id } => {
Self::article_comment_delete(bus, user_id, comment_id).await
}
WsInMessage::ArticleCommentList {
article_id,
before,
limit,
} => {
Self::article_comment_list(
bus, user_id, article_id, before, limit,
)
.await
}
WsInMessage::ArticleLikedUsers {
article_id,
before,
limit,
} => {
Self::article_liked_users(
bus, user_id, article_id, before, limit,
)
.await
}
}
}
}

View File

@ -15,7 +15,7 @@ impl WsHandler {
room: Uuid,
) -> ChannelResult<Option<WsOutEvent>> {
Self::ensure_room_access(bus, user_id, room).await?;
let row = db::sqlx::query_as::<_, model::room::RoomModel>(
let row = db::sqlx::query_as::<_, model::channel::ChannelModel>(
"SELECT id, wk, parent, name, topic, room_type, position, \
is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at \
FROM room WHERE id = $1 AND deleted_at IS NULL",
@ -30,7 +30,7 @@ impl WsHandler {
"wk": row.wk,
"name": row.name,
"topic": row.topic,
"room_type": row.room_type,
"room_type": row.channel_type,
"is_private": row.is_private,
"is_archived": row.is_archived,
"ai_enabled": row.ai_enabled,
@ -49,6 +49,7 @@ impl WsHandler {
public: bool,
category: Option<Uuid>,
ai_enabled: Option<bool>,
channel_type: Option<String>,
) -> ChannelResult<Option<WsOutEvent>> {
if room_name.is_empty() || room_name.len() > MAX_ROOM_NAME_LEN {
return Err(ChannelError::Validation("invalid room name".into()));
@ -56,15 +57,17 @@ impl WsHandler {
Self::ensure_workspace_member(bus, user_id, workspace).await?;
let is_private = !public;
let ai = ai_enabled.unwrap_or(false);
let row = db::sqlx::query_as::<_, model::room::RoomModel>(
let ctype = channel_type.unwrap_or_else(|| "channel".to_string());
let row = db::sqlx::query_as::<_, model::channel::ChannelModel>(
"INSERT INTO room (wk, parent, name, room_type, is_private, ai_enabled, created_by, created_at, updated_at) \
VALUES ($1, $2, $3, 'channel', $4, $5, $6, now(), now()) \
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now()) \
RETURNING id, wk, parent, name, topic, room_type, position, \
is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at",
)
.bind(workspace)
.bind(category)
.bind(&room_name)
.bind(&ctype)
.bind(is_private)
.bind(ai)
.bind(user_id)
@ -112,7 +115,7 @@ impl WsHandler {
ai_enabled: Option<bool>,
) -> ChannelResult<Option<WsOutEvent>> {
Self::ensure_room_access(bus, user_id, room).await?;
let old = db::sqlx::query_as::<_, model::room::RoomModel>(
let old = db::sqlx::query_as::<_, model::channel::ChannelModel>(
"SELECT id, wk, parent, name, topic, room_type, position, \
is_private, is_archived, ai_enabled, created_by, created_at, updated_at, deleted_at \
FROM room WHERE id = $1 AND deleted_at IS NULL",
@ -124,7 +127,7 @@ impl WsHandler {
let new_private = public.map(|p| !p).unwrap_or(old.is_private);
let new_category = category.or(old.parent);
let new_ai = ai_enabled.unwrap_or(old.ai_enabled);
let row = db::sqlx::query_as::<_, model::room::RoomModel>(
let row = db::sqlx::query_as::<_, model::channel::ChannelModel>(
"UPDATE room SET name = $2, is_private = $3, parent = $4, ai_enabled = $5, updated_at = now() \
WHERE id = $1 AND deleted_at IS NULL \
RETURNING id, wk, parent, name, topic, room_type, position, \
@ -216,7 +219,7 @@ impl WsHandler {
room: Uuid,
) -> ChannelResult<Option<WsOutEvent>> {
Self::ensure_room_access(bus, user_id, room).await?;
let old = db::sqlx::query_as::<_, model::room::RoomModel>(
let old = db::sqlx::query_as::<_, model::channel::ChannelModel>(
"SELECT id, wk, parent, name, topic, room_type, position, \
is_private, is_archived, created_by, created_at, updated_at, deleted_at \
FROM room WHERE id = $1 AND deleted_at IS NULL",
@ -227,7 +230,7 @@ impl WsHandler {
if old.created_by != user_id {
return Err(ChannelError::AccessDenied);
}
let row = db::sqlx::query_as::<_, model::room::RoomModel>(
let row = db::sqlx::query_as::<_, model::channel::ChannelModel>(
"UPDATE room SET deleted_at = now(), updated_at = now() \
WHERE id = $1 AND deleted_at IS NULL \
RETURNING id, wk, parent, name, topic, room_type, position, \

View File

@ -102,7 +102,7 @@ impl WsHandler {
.await?;
let parent_msg_id = parent_id.ok_or(ChannelError::RoomNotFound)?.0;
let seq = bus.inner.seq.seq(room).await?;
let row = db::sqlx::query_as::<_, model::room::RoomThreadModel>(
let row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>(
"INSERT INTO room_thread (room, seq, starter_message, title, created_by, created_at, updated_at) \
VALUES ($1, $2, $3, '', $4, now(), now()) \
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
@ -150,7 +150,7 @@ impl WsHandler {
.await?
.ok_or(ChannelError::RoomNotFound)?;
Self::ensure_room_access(bus, user_id, existing.0).await?;
let row = db::sqlx::query_as::<_, model::room::RoomThreadModel>(
let row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>(
"UPDATE room_thread SET locked = true, updated_at = now() \
WHERE id = $1 \
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \
@ -193,7 +193,7 @@ impl WsHandler {
.await?
.ok_or(ChannelError::RoomNotFound)?;
Self::ensure_room_access(bus, user_id, existing.0).await?;
let row = db::sqlx::query_as::<_, model::room::RoomThreadModel>(
let row = db::sqlx::query_as::<_, model::channel::RoomThreadModel>(
"UPDATE room_thread SET archived = true, archived_at = now(), updated_at = now() \
WHERE id = $1 \
RETURNING id, room, seq, starter_message, title, created_by, archived, locked, \

View File

@ -2,7 +2,7 @@ use serde::Serialize;
use uuid::Uuid;
use crate::event::{
RoomInfo, WorkspaceInfo, attachment, ban, category, conversation, draft,
RoomInfo, WorkspaceInfo, article, attachment, ban, category, conversation, draft,
forward, invite, member, message, message_read, notify, pin, presence,
reaction, rooms, search, star, thread, voice, workspace,
};
@ -244,6 +244,46 @@ pub enum WsOutEvent {
room: RoomInfo,
data: forward::MessageForwardedService,
},
ArticleCreated {
room: RoomInfo,
data: article::ArticleCreatedService,
},
ArticleUpdated {
room: RoomInfo,
data: article::ArticleUpdatedService,
},
ArticleDeleted {
room: RoomInfo,
data: article::ArticleDeletedService,
},
ArticleList {
data: article::ArticleListService,
},
ArticleDetail {
data: article::ArticleDetail,
},
ArticleLiked {
room: RoomInfo,
data: article::ArticleLikedService,
},
ArticleUnliked {
room: RoomInfo,
data: article::ArticleUnlikedService,
},
ArticleCommentCreated {
room: RoomInfo,
data: article::ArticleCommentCreatedService,
},
ArticleCommentDeleted {
room: RoomInfo,
data: article::ArticleCommentDeletedService,
},
ArticleCommentList {
data: article::ArticleCommentListService,
},
ArticleLikedUsers {
data: article::ArticleLikedUsersService,
},
Response {
request_id: Uuid,
data: serde_json::Value,

View File

@ -61,6 +61,7 @@ pub enum WsInMessage {
public: bool,
category: Option<Uuid>,
ai_enabled: Option<bool>,
channel_type: Option<String>,
},
RoomUpdate {
room: Uuid,
@ -250,6 +251,60 @@ pub enum WsInMessage {
source_message_id: Uuid,
target_room: Uuid,
},
ArticleCreate {
channel: Uuid,
title: String,
cover_url: Option<String>,
content: String,
content_type: Option<String>,
summary: Option<String>,
tags: Option<Vec<String>>,
status: Option<String>,
},
ArticleUpdate {
article_id: Uuid,
title: Option<String>,
cover_url: Option<String>,
content: Option<String>,
content_type: Option<String>,
summary: Option<String>,
tags: Option<Vec<String>>,
is_pinned: Option<bool>,
status: Option<String>,
},
ArticleDelete {
article_id: Uuid,
},
ArticleList {
channel: Uuid,
before: Option<Uuid>,
limit: Option<i64>,
},
ArticleGet {
article_id: Uuid,
},
ArticleLike {
article_id: Uuid,
like: bool,
},
ArticleCommentCreate {
article_id: Uuid,
content: String,
parent: Option<Uuid>,
},
ArticleCommentDelete {
comment_id: Uuid,
},
ArticleCommentList {
article_id: Uuid,
before: Option<Uuid>,
limit: Option<i64>,
},
ArticleLikedUsers {
article_id: Uuid,
before: Option<Uuid>,
limit: Option<i64>,
},
}
macro_rules! room_variants {

View File

@ -62,7 +62,7 @@ impl MessagePagination {
let messages = match (params.direction, cursor_seq) {
(PaginationDirection::Before, Some(seq)) => {
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
db::sqlx::AssertSqlSafe(format!(
"SELECT {RM_COLUMNS} FROM room_message \
WHERE room = $1 AND seq < $2 AND deleted_at IS NULL AND thread IS NULL \
@ -76,7 +76,7 @@ impl MessagePagination {
.await?
}
(PaginationDirection::After, Some(seq)) => {
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
db::sqlx::AssertSqlSafe(format!(
"SELECT {RM_COLUMNS} FROM room_message \
WHERE room = $1 AND seq > $2 AND deleted_at IS NULL AND thread IS NULL \
@ -90,7 +90,7 @@ impl MessagePagination {
.await?
}
_ => {
db::sqlx::query_as::<_, model::room::RoomMessageModel>(
db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
db::sqlx::AssertSqlSafe(format!(
"SELECT {RM_COLUMNS} FROM room_message \
WHERE room = $1 AND deleted_at IS NULL AND thread IS NULL \
@ -148,7 +148,7 @@ impl MessagePagination {
message_id: Uuid,
context_size: i64,
) -> ChannelResult<MessagePage> {
let target = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
let target = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
db::sqlx::AssertSqlSafe(format!(
"SELECT {RM_COLUMNS} FROM room_message \
WHERE id = $1 AND room = $2 AND deleted_at IS NULL"
@ -160,7 +160,7 @@ impl MessagePagination {
.await?
.ok_or(ChannelError::Internal("message not found".to_string()))?;
let before = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
let before = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
db::sqlx::AssertSqlSafe(format!(
"SELECT {RM_COLUMNS} FROM room_message \
WHERE room = $1 AND seq < $2 AND deleted_at IS NULL \
@ -173,7 +173,7 @@ impl MessagePagination {
.fetch_all(self.db.reader())
.await?;
let after = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
let after = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
db::sqlx::AssertSqlSafe(format!(
"SELECT {RM_COLUMNS} FROM room_message \
WHERE room = $1 AND seq > $2 AND deleted_at IS NULL \

View File

@ -1,7 +1,7 @@
use std::collections::HashMap;
use uuid::Uuid;
use model::room::RoomMessageModel;
use model::channel::RoomMessageModel;
use serde::{Deserialize, Serialize};
use crate::ChannelResult;

View File

@ -1,6 +1,6 @@
use cache::AppCache;
use db::{AppDatabase, sqlx};
use model::room::RoomMessageModel;
use model::channel::RoomMessageModel;
use serde::Serialize;
use uuid::Uuid;
@ -21,6 +21,7 @@ pub struct RoomListItem {
pub id: Uuid,
pub name: String,
pub topic: Option<String>,
/// Maps to DB `room_type` column. Serialized as `room_type` for frontend compat.
pub room_type: String,
pub is_private: bool,
pub ai_enabled: bool,

View File

@ -61,7 +61,7 @@ impl SearchEngine {
let total = count.0 as u64;
let messages = db::sqlx::query_as::<_, model::room::RoomMessageModel>(
let messages = db::sqlx::query_as::<_, model::channel::RoomMessageModel>(
"SELECT id, room, seq, thread, parent, author, content, content_type, pinned, \
system_type, metadata, edited_at, created_at, updated_at, deleted_at \
FROM room_message \

View File

@ -64,9 +64,7 @@ impl GitBare {
// Only diff blobs — skip trees (directories)
let entry_mode = change.entry_mode();
if entry_mode.is_tree() {
stats.files_changed += 1;
resource_cache.clear_resource_cache_keep_allocation();
deltas.push(delta);
continue;
}

View File

@ -63,7 +63,6 @@ impl GitBare {
// Skip directories — only diff blobs
if change.entry_mode().is_tree() {
stats.files_changed += 1;
continue;
}

View File

@ -292,9 +292,7 @@ impl GitBare {
// Skip directories — only diff blobs
if change.entry_mode().is_tree() {
stats.files_changed += 1;
resource_cache.clear_resource_cache_keep_allocation();
deltas.push(delta);
continue;
}

View File

@ -105,6 +105,7 @@ impl GitServer {
});
Server::builder()
.max_frame_size(Some(16 * 1024 * 1024 - 1))
.add_service(archive)
.add_service(blame)
.add_service(blob)

View 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;

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS channel_article_comment;
DROP TABLE IF EXISTS channel_article_like;

View 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;

View 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);

View 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
}
}

View 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>,
}

View 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>,
}

View File

@ -1,6 +1,8 @@
pub mod channel;
pub mod channel_article;
pub mod channel_article_interact;
pub mod message_read;
pub mod message_star;
pub mod room;
pub mod room_attachments;
pub mod room_categories;
pub mod room_mention;
@ -15,7 +17,17 @@ pub mod user_room_state;
pub use message_read::MessageReadModel;
pub use message_star::MessageStarModel;
pub use room::RoomModel;
pub use channel::ChannelModel;
pub use channel::ChannelType;
pub use channel_article::ChannelArticleModel;
pub use channel_article::ChannelArticleCard;
pub use channel_article::CreateArticlePayload;
pub use channel_article::UpdateArticlePayload;
pub use channel_article_interact::ArticleLikeModel;
pub use channel_article_interact::ArticleCommentModel;
pub use channel_article_interact::ArticleCommentItem;
pub use channel_article_interact::ArticleCommentList;
pub use channel_article_interact::CreateCommentPayload;
pub use room_attachments::RoomAttachmentModel;
pub use room_categories::RoomCategoryModel;
pub use room_mention::RoomMentionModel;

View File

@ -7,7 +7,7 @@ pub mod logs;
pub mod notify;
pub mod pull_request;
pub mod repos;
pub mod room;
pub mod channel;
pub mod system;
pub mod users;
pub mod workspace;

View File

@ -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>>,
}

View File

@ -3,6 +3,8 @@ use session::Session;
use crate::{AppService, error::AppError, git::rpc_err};
const MAX_GRPC_MSG: usize = 50 * 1024 * 1024; // 50MB
impl AppService {
pub async fn git_diff_stats(
&self,
@ -14,7 +16,8 @@ impl AppService {
options: Option<p::DiffOptions>,
) -> Result<p::DiffStatsResponse, AppError> {
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
let mut client = DiffServiceClient::new(self.git.clone());
let mut client = DiffServiceClient::new(self.git.clone())
.max_decoding_message_size(MAX_GRPC_MSG);
let resp = client
.diff_stats(tonic::Request::new(p::DiffStatsRequest {
repo_id: repo.id.to_string(),
@ -38,7 +41,8 @@ impl AppService {
options: Option<p::DiffOptions>,
) -> Result<p::DiffPatchResponse, AppError> {
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
let mut client = DiffServiceClient::new(self.git.clone());
let mut client = DiffServiceClient::new(self.git.clone())
.max_decoding_message_size(MAX_GRPC_MSG);
let resp = client
.diff_patch(tonic::Request::new(p::DiffPatchRequest {
repo_id: repo.id.to_string(),
@ -62,7 +66,8 @@ impl AppService {
options: Option<p::DiffOptions>,
) -> Result<p::DiffPatchSideBySideResponse, AppError> {
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
let mut client = DiffServiceClient::new(self.git.clone());
let mut client = DiffServiceClient::new(self.git.clone())
.max_decoding_message_size(MAX_GRPC_MSG);
let resp = client
.diff_patch_side_by_side(tonic::Request::new(
p::DiffPatchSideBySideRequest {
@ -88,7 +93,8 @@ impl AppService {
options: Option<p::DiffOptions>,
) -> Result<p::DiffTreeToTreeResponse, AppError> {
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
let mut client = DiffServiceClient::new(self.git.clone());
let mut client = DiffServiceClient::new(self.git.clone())
.max_decoding_message_size(MAX_GRPC_MSG);
let resp = client
.diff_tree_to_tree(tonic::Request::new(p::DiffTreeToTreeRequest {
repo_id: repo.id.to_string(),
@ -111,7 +117,8 @@ impl AppService {
options: Option<p::DiffOptions>,
) -> Result<p::DiffIndexToTreeResponse, AppError> {
let repo = self.git_require_member(ctx, wk_name, repo_name).await?;
let mut client = DiffServiceClient::new(self.git.clone());
let mut client = DiffServiceClient::new(self.git.clone())
.max_decoding_message_size(MAX_GRPC_MSG);
let resp = client
.diff_index_to_tree(tonic::Request::new(
p::DiffIndexToTreeRequest {

View File

@ -11295,6 +11295,201 @@
]
}
},
"/api/v1/ws/articles/{article_id}/comments": {
"get": {
"tags": [
"channel"
],
"operationId": "channel_article_comment_list",
"parameters": [
{
"name": "before",
"in": "query",
"required": false,
"schema": {
"type": [
"string",
"null"
],
"format": "uuid"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": [
"integer",
"null"
],
"format": "int64"
}
},
{
"name": "article_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Comment list"
}
}
},
"post": {
"tags": [
"channel"
],
"operationId": "channel_article_comment_create",
"parameters": [
{
"name": "article_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArticleCommentCreateRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Comment created"
}
}
}
},
"/api/v1/ws/articles/{article_id}/comments/{comment_id}": {
"delete": {
"tags": [
"channel"
],
"operationId": "channel_article_comment_delete",
"parameters": [
{
"name": "article_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "comment_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"204": {
"description": "Comment deleted"
}
}
}
},
"/api/v1/ws/articles/{article_id}/like": {
"post": {
"tags": [
"channel"
],
"operationId": "channel_article_like",
"parameters": [
{
"name": "article_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArticleLikeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Like toggled"
}
}
}
},
"/api/v1/ws/articles/{article_id}/likes": {
"get": {
"tags": [
"channel"
],
"operationId": "channel_article_liked_users",
"parameters": [
{
"name": "before",
"in": "query",
"required": false,
"schema": {
"type": [
"string",
"null"
],
"format": "uuid"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": [
"integer",
"null"
],
"format": "int64"
}
},
{
"name": "article_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "List of users who liked"
}
}
}
},
"/api/v1/ws/categories/{category_id}": {
"delete": {
"tags": [
@ -11351,6 +11546,191 @@
}
}
},
"/api/v1/ws/channels/{channel_id}/articles": {
"get": {
"tags": [
"channel"
],
"operationId": "channel_article_list",
"parameters": [
{
"name": "before",
"in": "query",
"required": false,
"schema": {
"type": [
"string",
"null"
],
"format": "uuid"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": [
"integer",
"null"
],
"format": "int64"
}
},
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Article list (waterfall feed)"
}
}
},
"post": {
"tags": [
"channel"
],
"operationId": "channel_article_create",
"parameters": [
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArticleCreateRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Article created"
}
}
}
},
"/api/v1/ws/channels/{channel_id}/articles/{article_id}": {
"get": {
"tags": [
"channel"
],
"operationId": "channel_article_get",
"parameters": [
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "article_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Article detail"
}
}
},
"delete": {
"tags": [
"channel"
],
"operationId": "channel_article_delete",
"parameters": [
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "article_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"204": {
"description": "Article deleted"
}
}
},
"patch": {
"tags": [
"channel"
],
"operationId": "channel_article_update",
"parameters": [
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "article_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArticleUpdateRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Article updated"
}
}
}
},
"/api/v1/ws/csrf": {
"get": {
"tags": [
@ -11621,6 +12001,17 @@
}
},
"/api/v1/ws/rooms": {
"get": {
"tags": [
"channel"
],
"operationId": "channel_list_rooms",
"responses": {
"200": {
"description": "List of rooms"
}
}
},
"post": {
"tags": [
"channel"
@ -13556,6 +13947,139 @@
}
}
},
"ArticleCommentCreateRequest": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"type": "string"
},
"parent": {
"type": [
"string",
"null"
],
"format": "uuid"
}
}
},
"ArticleCreateRequest": {
"type": "object",
"required": [
"title",
"content"
],
"properties": {
"content": {
"type": "string"
},
"content_type": {
"type": [
"string",
"null"
]
},
"cover_url": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
"null"
]
},
"summary": {
"type": [
"string",
"null"
]
},
"tags": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"title": {
"type": "string"
}
}
},
"ArticleLikeRequest": {
"type": "object",
"required": [
"like"
],
"properties": {
"like": {
"type": "boolean"
}
}
},
"ArticleUpdateRequest": {
"type": "object",
"properties": {
"content": {
"type": [
"string",
"null"
]
},
"content_type": {
"type": [
"string",
"null"
]
},
"cover_url": {
"type": [
"string",
"null"
]
},
"is_pinned": {
"type": [
"boolean",
"null"
]
},
"status": {
"type": [
"string",
"null"
]
},
"summary": {
"type": [
"string",
"null"
]
},
"tags": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"title": {
"type": [
"string",
"null"
]
}
}
},
"AssignIssueUser": {
"type": "object",
"required": [
@ -16803,6 +17327,12 @@
],
"format": "uuid"
},
"channel_type": {
"type": [
"string",
"null"
]
},
"public": {
"type": "boolean"
},

View File

@ -17,6 +17,8 @@
}
}
.hero {
position: relative;

View File

@ -14,7 +14,7 @@ import { PersonalShell, WorkspaceShell, SettingsShell } from "@/components/shell
import WorkspaceRepositoriesPage from "@/page/workspace/repositories";
import WorkspaceIssuesPage from "@/page/workspace/issues";
import IssueDetailPage from "@/page/workspace/issues/detail";
import RepoLayout from "@/page/workspace/repo/layout";
import RepoLayout, { RepoIndexRedirect } from "@/page/workspace/repo/layout";
import CodeTab from "@/page/workspace/repo/code";
import CommitsTab from "@/page/workspace/repo/commits";
import BranchesTab from "@/page/workspace/repo/branches";
@ -88,7 +88,7 @@ function App() {
path: "repo/:repoName",
element: <RepoLayout />,
children: [
{ index: true, element: <Navigate replace to="code" /> },
{ index: true, element: <RepoIndexRedirect /> },
{ path: "code", element: <CodeTab /> },
{ path: "readme", element: <ReadmePage /> },
{ path: "commits", element: <CommitsTab /> },

File diff suppressed because one or more lines are too long

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
};

View 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;
};

View 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;
};

View File

@ -31,6 +31,10 @@ export * from './aiModelResponse';
export * from './aiModelVersionResponse';
export * from './aiProviderResponse';
export * from './approveWorkspaceJoinApply';
export * from './articleCommentCreateRequest';
export * from './articleCreateRequest';
export * from './articleLikeRequest';
export * from './articleUpdateRequest';
export * from './assignIssueUser';
export * from './assignPrUser';
export * from './authCaptchaParams';
@ -52,6 +56,9 @@ export * from './captchaQuery';
export * from './captchaResponse';
export * from './categoryCreateRequest';
export * from './categoryUpdateRequest';
export * from './channelArticleCommentListParams';
export * from './channelArticleLikedUsersParams';
export * from './channelArticleListParams';
export * from './channelListMessagesParams';
export * from './channelMessagesAroundParams';
export * from './channelMissedMessagesParams';

View File

@ -11,6 +11,8 @@ export interface RoomCreateRequest {
ai_enabled?: boolean | null;
/** @nullable */
category?: string | null;
/** @nullable */
channel_type?: string | null;
public: boolean;
room_name: string;
workspace: string;

View 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
View 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 };
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View File

@ -1,5 +1,4 @@
import { Hash, Settings, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
@ -55,16 +54,12 @@ export function ChannelHeader({
)}
<Tooltip>
<TooltipTrigger>
<Button
aria-label="Channel settings"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/40 transition-all duration-150 hover:bg-accent/40 hover:text-foreground"
onClick={onToggleSettings}
size="icon"
variant="ghost"
>
<Settings className="size-[18px]" />
</Button>
<TooltipTrigger
aria-label="Channel settings"
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground/40 transition-all duration-150 hover:bg-accent/40 hover:text-foreground"
onClick={onToggleSettings}
>
<Settings className="size-[18px]" />
</TooltipTrigger>
<TooltipContent className="text-xs">Channel settings</TooltipContent>
</Tooltip>

View File

@ -7,6 +7,7 @@ import {
Plus,
MessageSquare,
FolderOpen,
Newspaper,
} from "lucide-react";
import { Link, useLocation, useParams } from "react-router";
import { cn } from "@/lib/utils";
@ -38,6 +39,8 @@ type Props = {
function RoomIcon({ type }: { type: string }) {
if (type === "voice")
return <Volume2 className="size-[15px] shrink-0 text-muted-foreground/60" />;
if (type === "article")
return <Newspaper className="size-[15px] shrink-0 text-muted-foreground/60" />;
return <Hash className="size-[15px] shrink-0 text-muted-foreground/60" />;
}

View File

@ -21,37 +21,49 @@ export function ChannelThreadPanel({
}: Props) {
const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
const prevOpen = useRef(false);
const prevRoomId = useRef(roomId);
// Auto-select the thread when selectedThreadId is provided
// Reset selectedThread when room changes to avoid stale data leak
useEffect(() => {
if (selectedThreadId) {
const thread = threads.find((t) => t.id === selectedThreadId);
if (thread) {
setSelectedThread(thread);
} else if (initialSeq > 0) {
setSelectedThread({
id: selectedThreadId,
room: roomId,
seq: 0,
parent_seq: initialSeq,
title: "",
created_by: { id: "", display_name: "", username: "" },
archived: false,
locked: false,
last_message_at: null,
created_at: new Date().toISOString(),
});
}
if (prevRoomId.current !== roomId) {
prevRoomId.current = roomId;
setSelectedThread(null);
}
}, [selectedThreadId, threads, roomId, initialSeq]);
// Reset selection when panel closes
useEffect(() => {
// Also reset when open transitions from true to false (after room switch reset)
if (!open && prevOpen.current) {
setSelectedThread(null);
}
prevOpen.current = open;
}, [open]);
}, [open, roomId]);
// Auto-select the thread when selectedThreadId is provided
useEffect(() => {
if (!selectedThreadId) {
return;
}
if (threads.length === 0) {
// Threads haven't loaded yet for this room; don't eagerly create a placeholder
return;
}
const thread = threads.find((t) => t.id === selectedThreadId);
if (thread) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- state sync from prop, pre-existing pattern
setSelectedThread(thread);
} else if (initialSeq > 0) {
setSelectedThread({
id: selectedThreadId,
room: roomId,
seq: 0,
parent_seq: initialSeq,
title: "",
created_by: { id: "", display_name: "", username: "" },
archived: false,
locked: false,
last_message_at: null,
created_at: new Date().toISOString(),
});
}
}, [selectedThreadId, threads, roomId, initialSeq]);
if (!open) return null;

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router";
import { useQueryClient } from "@tanstack/react-query";
import { api } from "@/client";
@ -8,6 +8,9 @@ import { ChannelHeader } from "./channel-header";
import { ChannelThreadPanel } from "./channel-thread-panel";
import RoomSettingsDialog from "./room-settings-dialog";
import MessageView from "./message-view";
import ArticleFeed from "./article-feed";
import ArticleComposer from "./article-composer";
import { useArticleDraft } from "./use-article-draft";
export default function ChannelPage() {
const { roomId } = useParams();
@ -18,6 +21,41 @@ export default function ChannelPage() {
const [activeThreadSeq, setActiveThreadSeq] = useState<number>(0);
const [showRoomSettings, setShowRoomSettings] = useState(false);
// Global composer state
const [showComposer, setShowComposer] = useState(false);
const [composerChannel, setComposerChannel] = useState<string | null>(null);
const { draft, persist, initDraft, clearDraft } = useArticleDraft(composerChannel ?? undefined);
const openComposer = useCallback((channel: string) => {
setComposerChannel(channel);
const existing = initDraft(channel);
if (!existing || (!existing.title && !existing.content)) {
// fresh draft
}
setShowComposer(true);
}, [initDraft]);
const closeComposer = useCallback(() => {
setShowComposer(false);
}, []);
const handleArticleCreated = useCallback(() => {
setShowComposer(false);
setComposerChannel(null);
}, []);
// Reset thread view & room settings when switching rooms
const prevRoomId = useRef(roomId);
useEffect(() => {
if (prevRoomId.current !== roomId) {
prevRoomId.current = roomId;
setShowThreads(false);
setActiveThreadId(null);
setActiveThreadSeq(0);
setShowRoomSettings(false);
}
}, [roomId]);
const handleStartThread = useCallback(
async (_messageId: string, seq: number) => {
if (!roomId) return;
@ -74,36 +112,47 @@ export default function ChannelPage() {
</div>
)}
<MessageView
currentUserId={state.currentUserId}
hasMore={state.hasMore}
loading={state.loadingMessages}
messages={state.messages}
onDelete={actions.handleDeleteMessage}
onEdit={actions.handleEditMessage}
onLoadMore={actions.handleLoadMore}
onPinToggle={actions.handlePinToggle}
onReactionToggle={actions.handleReactionToggle}
onSend={actions.handleSend}
onStartThread={handleStartThread}
onViewThread={handleViewThread}
onTyping={actions.handleTyping}
roomId={roomId ?? ""}
roomName={state.currentRoom?.name ?? ""}
streamingMessages={state.streamingMessages}
threads={state.threads}
typingText={state.typingText}
/>
{roomId && (
<ChannelThreadPanel
initialSeq={activeThreadSeq}
onClose={closeThreadPanel}
open={showThreads}
{state.currentRoom?.room_type === "article" && roomId ? (
<ArticleFeed
currentUserId={state.currentUserId}
roomId={roomId}
selectedThreadId={activeThreadId}
threads={state.threads}
roomName={state.currentRoom.name}
onCompose={() => openComposer(roomId)}
/>
) : (
<>
<MessageView
currentUserId={state.currentUserId}
hasMore={state.hasMore}
loading={state.loadingMessages}
messages={state.messages}
onDelete={actions.handleDeleteMessage}
onEdit={actions.handleEditMessage}
onLoadMore={actions.handleLoadMore}
onPinToggle={actions.handlePinToggle}
onReactionToggle={actions.handleReactionToggle}
onSend={actions.handleSend}
onStartThread={handleStartThread}
onViewThread={handleViewThread}
onTyping={actions.handleTyping}
roomId={roomId ?? ""}
roomName={state.currentRoom?.name ?? ""}
streamingMessages={state.streamingMessages}
threads={state.threads}
typingText={state.typingText}
/>
{roomId && (
<ChannelThreadPanel
initialSeq={activeThreadSeq}
onClose={closeThreadPanel}
open={showThreads}
roomId={roomId}
selectedThreadId={activeThreadId}
threads={state.threads}
/>
)}
</>
)}
{roomId && state.currentRoom && (
@ -121,6 +170,18 @@ export default function ChannelPage() {
topic={state.currentRoom.topic}
/>
)}
{/* Global article composer */}
{showComposer && composerChannel && (
<ArticleComposer
channel={composerChannel}
draft={draft}
onClearDraft={clearDraft}
onClose={closeComposer}
onCreated={handleArticleCreated}
onDraftChange={persist}
/>
)}
</div>
);
}

View 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>
);
}

View File

@ -26,6 +26,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Textarea } from "@/components/ui/textarea";
import type { MessageNewService } from "@/socket";
import type { Thread } from "./thread-sidebar";
import MessageContent from "./message-content";
export function formatTime(iso: string) {
const d = new Date(iso);
@ -280,17 +281,11 @@ export default function MessageItem({
<span>Esc to cancel · Enter to save</span>
</div>
</div>
) : message.content_type === "text" || !message.content_type ? (
<p className="whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85">
{message.content}
</p>
) : (
<p className="whitespace-pre-wrap break-words text-[13px] text-foreground/85">
<span className="inline-flex items-center gap-1 rounded bg-muted/40 px-1.5 py-[1px] text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
{message.content_type}
</span>{" "}
{message.content}
</p>
<MessageContent
content={message.content}
contentType={message.content_type}
/>
)}
{threadForMessage && !message.thread && (
@ -322,26 +317,32 @@ export default function MessageItem({
<span className="text-[10px] font-semibold tabular-nums">{r.count}</span>
</button>
))}
<ReactionPicker onSelect={(emoji) => handleReaction(emoji)}>
<button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/25 opacity-0 transition-all duration-150 hover:border-primary/25 hover:text-primary/50 group-hover:opacity-100">
<SmilePlus className="size-3" />
</button>
</ReactionPicker>
<ReactionPicker
onSelect={(emoji) => handleReaction(emoji)}
trigger={
<button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/25 opacity-0 transition-all duration-150 hover:border-primary/25 hover:text-primary/50 group-hover:opacity-100">
<SmilePlus className="size-3" />
</button>
}
/>
</div>
)}
</div>
<div className="absolute right-2 top-0 z-10 flex items-center gap-[1px] rounded-lg border border-border/20 bg-card/95 px-1 py-1 shadow-md backdrop-blur-sm opacity-0 transition-all duration-150 group-hover:opacity-100">
<ReactionPicker onSelect={(emoji) => handleReaction(emoji)}>
<Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
size="icon"
title="Add reaction"
variant="ghost"
>
<SmilePlus className="size-3.5" />
</Button>
</ReactionPicker>
<ReactionPicker
onSelect={(emoji) => handleReaction(emoji)}
trigger={
<Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
size="icon"
title="Add reaction"
variant="ghost"
>
<SmilePlus className="size-3.5" />
</Button>
}
/>
<Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
@ -463,15 +464,15 @@ const REACTIONS_PALETTE = [
];
function ReactionPicker({
children,
trigger,
onSelect,
}: {
children: React.ReactNode;
trigger: React.ReactElement;
onSelect: (emoji: string) => void;
}) {
return (
<Popover>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverTrigger render={trigger} />
<PopoverContent
align="start"
className="w-auto p-2"

View 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>
</>
);
}

View 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>
);
}

View 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;
}

View File

@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Hash, Loader2, Plus } from "lucide-react";
import { Hash, Loader2, Plus, Newspaper } from "lucide-react";
import { api } from "@/client";
import { Button } from "@/components/ui/button";
import {
@ -38,6 +38,7 @@ export default function RoomCreateDialog({
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const [name, setName] = useState("");
const [isPublic, setIsPublic] = useState(true);
const [channelType, setChannelType] = useState("channel");
const [categoryId, setCategoryId] = useState("");
const [newCategoryName, setNewCategoryName] = useState("");
const [creatingCategory, setCreatingCategory] = useState(false);
@ -75,12 +76,13 @@ export default function RoomCreateDialog({
room_name: trimmed,
public: isPublic,
category: categoryId || null,
channel_type: channelType === "channel" ? null : channelType,
});
setName("");
setIsPublic(true);
setChannelType("channel");
setCategoryId("");
setNewCategoryName("");
setAiEnabled(false);
setOpen(false);
onCreated?.();
} catch {
@ -88,7 +90,7 @@ export default function RoomCreateDialog({
} finally {
setSaving(false);
}
}, [name, isPublic, categoryId, workspaceId, onCreated]);
}, [name, isPublic, channelType, categoryId, workspaceId, onCreated, setOpen]);
return (
<Dialog onOpenChange={setOpen} open={open}>
@ -96,7 +98,11 @@ export default function RoomCreateDialog({
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<Hash className="size-5 text-primary/60" />
{channelType === "article" ? (
<Newspaper className="size-5 text-primary/60" />
) : (
<Hash className="size-5 text-primary/60" />
)}
Create Channel
</DialogTitle>
<DialogDescription>
@ -105,6 +111,37 @@ export default function RoomCreateDialog({
</DialogHeader>
<div className="space-y-4">
{/* Channel type selector */}
<div className="space-y-2">
<Label className="text-[13px]">Channel type</Label>
<div className="flex gap-2">
<button
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-all ${
channelType === "channel"
? "border-primary/40 bg-primary/[0.06] text-foreground"
: "border-border/30 text-muted-foreground/60 hover:border-border/50"
}`}
onClick={() => setChannelType("channel")}
type="button"
>
<Hash className="size-4" />
Chat
</button>
<button
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-all ${
channelType === "article"
? "border-primary/40 bg-primary/[0.06] text-foreground"
: "border-border/30 text-muted-foreground/60 hover:border-border/50"
}`}
onClick={() => setChannelType("article")}
type="button"
>
<Newspaper className="size-4" />
Article
</button>
</div>
</div>
<div className="space-y-2">
<Label className="text-[13px]" htmlFor="channel-name">
Channel name

View 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,
};
}

View File

@ -235,6 +235,14 @@ export default function CodeTab() {
},
enabled: Boolean(currentTreeOid),
retry: false,
// Backend returns entries immediately but enriches commit messages async.
// Poll until enrichment is done (cached result includes commit messages).
refetchInterval(query) {
const data = query.state.data;
if (!data || data.length === 0) return false;
const hasCommit = data.some((e: any) => e.last_commit_message);
return hasCommit ? false : 2000;
},
});
const displayEntries = (fullEntries ?? fastEntries) ?? [];

View File

@ -70,7 +70,7 @@ export default function CommitDetailPage() {
}
const extras = commit.parent_ids.length > 1 ? `(merge commit: ${commit.parent_ids.length} parents)` : "";
const deltas = (diffData as any)?.deltas ?? [];
const deltas = ((diffData as any)?.deltas ?? []).filter((d: any) => d.status !== "tree");
const selectedDelta = deltas[selectedFileIndex] ?? null;
return (

View File

@ -1,10 +1,26 @@
import { useParams } from "react-router";
import { NavLink, Outlet } from "react-router";
import { NavLink, Outlet, Navigate } from "react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/client";
import { Lock, Globe, Archive, GitFork, Star, Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
export function RepoIndexRedirect() {
const { projectName = "", repoName = "" } = useParams();
const { data: readme } = useQuery({
queryKey: ["repo", projectName, repoName, "readme"],
queryFn: async () => {
const res = await client.gitGetReadme(projectName, repoName);
return res.data;
},
enabled: Boolean(projectName) && Boolean(repoName),
retry: false,
});
if (readme?.html) return <Navigate replace to="readme" />;
return <Navigate replace to="code" />;
}
function formatSize(bytes: number) {
if (bytes === 0) return "Empty";
if (bytes < 1024) return `${bytes} B`;