Compare commits
10 Commits
c520690f1e
...
5ce6d9f2da
Author | SHA1 | Date | |
---|---|---|---|
5ce6d9f2da | |||
40695b7ad3 | |||
b337184a59 | |||
862974bb35 | |||
8b14ab06a2 | |||
e1b5a2aaa0 | |||
c9b8bd7b07 | |||
69eb225c1e | |||
c3539cfc11 | |||
f1e891413a |
2
thoughts-backend/Cargo.lock
generated
2
thoughts-backend/Cargo.lock
generated
@@ -1273,9 +1273,9 @@ dependencies = [
|
|||||||
name = "doc"
|
name = "doc"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
|
||||||
"axum 0.8.4",
|
"axum 0.8.4",
|
||||||
"models",
|
"models",
|
||||||
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-scalar",
|
"utoipa-scalar",
|
||||||
"utoipa-swagger-ui",
|
"utoipa-swagger-ui",
|
||||||
|
@@ -8,7 +8,6 @@ use app::state::AppState;
|
|||||||
|
|
||||||
use crate::routers::create_router;
|
use crate::routers::create_router;
|
||||||
|
|
||||||
// TODO: middleware, logging, authentication
|
|
||||||
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
|
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
|
||||||
create_router(AppState {
|
create_router(AppState {
|
||||||
conn,
|
conn,
|
||||||
|
@@ -5,6 +5,7 @@ pub mod auth;
|
|||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod friends;
|
pub mod friends;
|
||||||
pub mod root;
|
pub mod root;
|
||||||
|
pub mod search;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
@@ -30,6 +31,7 @@ pub fn create_router(state: AppState) -> Router {
|
|||||||
.nest("/feed", create_feed_router())
|
.nest("/feed", create_feed_router())
|
||||||
.nest("/tags", tag::create_tag_router())
|
.nest("/tags", tag::create_tag_router())
|
||||||
.nest("/friends", friends::create_friends_router())
|
.nest("/friends", friends::create_friends_router())
|
||||||
|
.nest("/search", search::create_search_router())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
}
|
}
|
||||||
|
53
thoughts-backend/api/src/routers/search.rs
Normal file
53
thoughts-backend/api/src/routers/search.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use crate::{error::ApiError, extractor::OptionalAuthUser};
|
||||||
|
use app::{persistence::search, state::AppState};
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use models::schemas::{
|
||||||
|
search::SearchResultsSchema,
|
||||||
|
thought::{ThoughtListSchema, ThoughtSchema},
|
||||||
|
user::UserListSchema,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::IntoParams;
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
q: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "",
|
||||||
|
params(SearchQuery),
|
||||||
|
responses((status = 200, body = SearchResultsSchema))
|
||||||
|
)]
|
||||||
|
async fn search_all(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
Query(query): Query<SearchQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
|
||||||
|
let (users, thoughts) = tokio::try_join!(
|
||||||
|
search::search_users(&state.conn, &query.q),
|
||||||
|
search::search_thoughts(&state.conn, &query.q, viewer_id)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let thought_schemas: Vec<ThoughtSchema> =
|
||||||
|
thoughts.into_iter().map(ThoughtSchema::from).collect();
|
||||||
|
|
||||||
|
let response = SearchResultsSchema {
|
||||||
|
users: UserListSchema::from(users),
|
||||||
|
thoughts: ThoughtListSchema::from(thought_schemas),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_search_router() -> Router<AppState> {
|
||||||
|
Router::new().route("/", get(search_all))
|
||||||
|
}
|
@@ -11,7 +11,10 @@ use app::{
|
|||||||
persistence::thought::{create_thought, delete_thought, get_thought},
|
persistence::thought::{create_thought, delete_thought, get_thought},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
use models::{
|
||||||
|
params::thought::CreateThoughtParams,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
|
||||||
|
};
|
||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -118,8 +121,33 @@ async fn thoughts_delete(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{id}/thread",
|
||||||
|
params(
|
||||||
|
("id" = Uuid, Path, description = "Thought ID")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Thought thread found", body = ThoughtThreadSchema),
|
||||||
|
(status = 404, description = "Not Found", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_thought_thread(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
let thread = app::persistence::thought::get_thought_with_replies(&state.conn, id, viewer_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
Ok(Json(thread))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_thought_router() -> Router<AppState> {
|
pub fn create_thought_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", post(thoughts_post))
|
.route("/", post(thoughts_post))
|
||||||
|
.route("/{id}/thread", get(get_thought_thread))
|
||||||
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
|
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
|
||||||
}
|
}
|
||||||
|
@@ -345,10 +345,17 @@ async fn get_me(
|
|||||||
let following = get_following(&state.conn, auth_user.id).await?;
|
let following = get_following(&state.conn, auth_user.id).await?;
|
||||||
|
|
||||||
let response = MeSchema {
|
let response = MeSchema {
|
||||||
user: UserSchema::from((user, top_friends)),
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
bio: user.bio,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
header_url: user.header_url,
|
||||||
|
custom_css: user.custom_css,
|
||||||
|
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
|
||||||
|
joined_at: user.created_at.into(),
|
||||||
following: following.into_iter().map(UserSchema::from).collect(),
|
following: following.into_iter().map(UserSchema::from).collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(axum::Json(response))
|
Ok(axum::Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
|
pub mod search;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
65
thoughts-backend/app/src/persistence/search.rs
Normal file
65
thoughts-backend/app/src/persistence/search.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use models::{
|
||||||
|
domains::{thought, user},
|
||||||
|
schemas::thought::ThoughtWithAuthor,
|
||||||
|
};
|
||||||
|
use sea_orm::{
|
||||||
|
prelude::{Expr, Uuid},
|
||||||
|
DatabaseConnection, DbErr, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait,
|
||||||
|
Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::persistence::follow;
|
||||||
|
|
||||||
|
fn is_visible(
|
||||||
|
author_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
friend_ids: &[Uuid],
|
||||||
|
visibility: &thought::Visibility,
|
||||||
|
) -> bool {
|
||||||
|
match visibility {
|
||||||
|
thought::Visibility::Public => true,
|
||||||
|
thought::Visibility::Private => viewer_id.map_or(false, |v| v == author_id),
|
||||||
|
thought::Visibility::FriendsOnly => {
|
||||||
|
viewer_id.map_or(false, |v| v == author_id || friend_ids.contains(&author_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_thoughts(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
query: &str,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
|
let mut friend_ids = Vec::new();
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We must join with the user table to get the author's username
|
||||||
|
let thoughts_with_authors = thought::Entity::find()
|
||||||
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(Expr::cust_with_values(
|
||||||
|
"thought.search_document @@ websearch_to_tsquery('english', $1)",
|
||||||
|
[Value::from(query)],
|
||||||
|
))
|
||||||
|
.into_model::<ThoughtWithAuthor>() // Convert directly in the query
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Apply visibility filtering in Rust after the search
|
||||||
|
Ok(thoughts_with_authors
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| is_visible(t.author_id, viewer_id, &friend_ids, &t.visibility))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_users(db: &DatabaseConnection, query: &str) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
user::Entity::find()
|
||||||
|
.filter(Expr::cust_with_values(
|
||||||
|
"\"user\".search_document @@ websearch_to_tsquery('english', $1)",
|
||||||
|
[Value::from(query)],
|
||||||
|
))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
@@ -7,7 +7,7 @@ use sea_orm::{
|
|||||||
use models::{
|
use models::{
|
||||||
domains::{tag, thought, thought_tag, user},
|
domains::{tag, thought, thought_tag, user},
|
||||||
params::thought::CreateThoughtParams,
|
params::thought::CreateThoughtParams,
|
||||||
schemas::thought::ThoughtWithAuthor,
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -201,7 +201,7 @@ pub async fn get_thoughts_by_tag_name(
|
|||||||
Ok(visible_thoughts)
|
Ok(visible_thoughts)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_visibility_filter(
|
pub fn apply_visibility_filter(
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
viewer_id: Option<Uuid>,
|
viewer_id: Option<Uuid>,
|
||||||
friend_ids: &[Uuid],
|
friend_ids: &[Uuid],
|
||||||
@@ -210,17 +210,103 @@ fn apply_visibility_filter(
|
|||||||
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
|
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
|
||||||
|
|
||||||
if let Some(viewer) = viewer_id {
|
if let Some(viewer) = viewer_id {
|
||||||
// Viewers can see their own thoughts of any visibility
|
|
||||||
if user_id == viewer {
|
if user_id == viewer {
|
||||||
condition = condition
|
condition = condition
|
||||||
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
|
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
|
||||||
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
|
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
|
||||||
}
|
} else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
|
||||||
// If the thought's author is a friend of the viewer, they can see it
|
|
||||||
else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
|
|
||||||
condition =
|
condition =
|
||||||
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
|
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
condition.into()
|
condition.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_thought_with_replies(
|
||||||
|
db: &DbConn,
|
||||||
|
thought_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Option<ThoughtThreadSchema>, DbErr> {
|
||||||
|
let root_thought = match get_thought(db, thought_id, viewer_id).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut all_thoughts_in_thread = vec![root_thought.clone()];
|
||||||
|
let mut ids_to_fetch = vec![root_thought.id];
|
||||||
|
let mut friend_ids = vec![];
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
while !ids_to_fetch.is_empty() {
|
||||||
|
let replies = thought::Entity::find()
|
||||||
|
.filter(thought::Column::ReplyToId.is_in(ids_to_fetch))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if replies.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids_to_fetch = replies.iter().map(|r| r.id).collect();
|
||||||
|
all_thoughts_in_thread.extend(replies);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut thought_schemas = vec![];
|
||||||
|
for thought in all_thoughts_in_thread {
|
||||||
|
if let Some(author) = user::Entity::find_by_id(thought.author_id).one(db).await? {
|
||||||
|
let is_visible = match thought.visibility {
|
||||||
|
thought::Visibility::Public => true,
|
||||||
|
thought::Visibility::Private => viewer_id.map_or(false, |v| v == thought.author_id),
|
||||||
|
thought::Visibility::FriendsOnly => viewer_id.map_or(false, |v| {
|
||||||
|
v == thought.author_id || friend_ids.contains(&thought.author_id)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_visible {
|
||||||
|
thought_schemas.push(ThoughtSchema::from_models(&thought, &author));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_thread(
|
||||||
|
thought_id: Uuid,
|
||||||
|
schemas_map: &std::collections::HashMap<Uuid, ThoughtSchema>,
|
||||||
|
replies_map: &std::collections::HashMap<Uuid, Vec<Uuid>>,
|
||||||
|
) -> Option<ThoughtThreadSchema> {
|
||||||
|
schemas_map.get(&thought_id).map(|thought_schema| {
|
||||||
|
let replies = replies_map
|
||||||
|
.get(&thought_id)
|
||||||
|
.unwrap_or(&vec![])
|
||||||
|
.iter()
|
||||||
|
.filter_map(|reply_id| build_thread(*reply_id, schemas_map, replies_map))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ThoughtThreadSchema {
|
||||||
|
id: thought_schema.id,
|
||||||
|
author_username: thought_schema.author_username.clone(),
|
||||||
|
content: thought_schema.content.clone(),
|
||||||
|
visibility: thought_schema.visibility.clone(),
|
||||||
|
reply_to_id: thought_schema.reply_to_id,
|
||||||
|
created_at: thought_schema.created_at.clone(),
|
||||||
|
replies,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let schemas_map: std::collections::HashMap<Uuid, ThoughtSchema> =
|
||||||
|
thought_schemas.into_iter().map(|s| (s.id, s)).collect();
|
||||||
|
|
||||||
|
let mut replies_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for thought in schemas_map.values() {
|
||||||
|
if let Some(parent_id) = thought.reply_to_id {
|
||||||
|
if schemas_map.contains_key(&parent_id) {
|
||||||
|
replies_map.entry(parent_id).or_default().push(thought.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_thread(root_thought.id, &schemas_map, &replies_map))
|
||||||
|
}
|
||||||
|
@@ -5,7 +5,7 @@ use sea_query::ValueTypeErr;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, Debug)]
|
#[derive(Serialize, ToSchema, Debug, Clone)]
|
||||||
#[schema(example = "2025-09-05T12:34:56Z")]
|
#[schema(example = "2025-09-05T12:34:56Z")]
|
||||||
pub struct DateTimeWithTimeZoneWrapper(String);
|
pub struct DateTimeWithTimeZoneWrapper(String);
|
||||||
|
|
||||||
|
@@ -10,6 +10,7 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
utoipa = { workspace = true, features = ["axum_extras"] }
|
utoipa = { workspace = true, features = ["axum_extras"] }
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = [
|
utoipa-swagger-ui = { version = "9.0.2", features = [
|
||||||
"axum",
|
"axum",
|
||||||
@@ -19,5 +20,5 @@ utoipa-scalar = { version = "0.3.0", features = [
|
|||||||
"axum",
|
"axum",
|
||||||
], default-features = false }
|
], default-features = false }
|
||||||
|
|
||||||
api = { path = "../api" }
|
# api = { path = "../api" }
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
|
@@ -6,27 +6,8 @@ use utoipa::{
|
|||||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
mod api_key;
|
|
||||||
mod auth;
|
|
||||||
mod feed;
|
|
||||||
mod friends;
|
|
||||||
mod root;
|
|
||||||
mod tag;
|
|
||||||
mod thought;
|
|
||||||
mod user;
|
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
nest(
|
|
||||||
(path = "/", api = root::RootApi),
|
|
||||||
(path = "/auth", api = auth::AuthApi),
|
|
||||||
(path = "/users", api = user::UserApi),
|
|
||||||
(path = "/users/me/api-keys", api = api_key::ApiKeyApi),
|
|
||||||
(path = "/thoughts", api = thought::ThoughtApi),
|
|
||||||
(path = "/feed", api = feed::FeedApi),
|
|
||||||
(path = "/tags", api = tag::TagApi),
|
|
||||||
(path = "/friends", api = friends::FriendsApi),
|
|
||||||
),
|
|
||||||
tags(
|
tags(
|
||||||
(name = "root", description = "Root API"),
|
(name = "root", description = "Root API"),
|
||||||
(name = "auth", description = "Authentication API"),
|
(name = "auth", description = "Authentication API"),
|
||||||
@@ -35,6 +16,7 @@ mod user;
|
|||||||
(name = "feed", description = "Feed API"),
|
(name = "feed", description = "Feed API"),
|
||||||
(name = "tag", description = "Tag Discovery API"),
|
(name = "tag", description = "Tag Discovery API"),
|
||||||
(name = "friends", description = "Friends API"),
|
(name = "friends", description = "Friends API"),
|
||||||
|
(name = "search", description = "Search API"),
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
)]
|
)]
|
||||||
@@ -55,12 +37,14 @@ impl Modify for SecurityAddon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ApiDoc {
|
pub trait ApiDocExt {
|
||||||
fn attach_doc(self) -> Self;
|
fn attach_doc(self) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiDoc for Router {
|
impl ApiDocExt for Router {
|
||||||
fn attach_doc(self) -> Self {
|
fn attach_doc(self) -> Self {
|
||||||
|
tracing::info!("Attaching API documentation");
|
||||||
|
|
||||||
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
|
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
|
||||||
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
|
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
|
||||||
}
|
}
|
||||||
|
21
thoughts-backend/doc/src/search.rs
Normal file
21
thoughts-backend/doc/src/search.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use api::{models::ApiErrorResponse, routers::search::*};
|
||||||
|
use models::schemas::{
|
||||||
|
search::SearchResultsSchema,
|
||||||
|
thought::{ThoughtListSchema, ThoughtSchema},
|
||||||
|
user::{UserListSchema, UserSchema},
|
||||||
|
};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(search_all),
|
||||||
|
components(schemas(
|
||||||
|
SearchResultsSchema,
|
||||||
|
ApiErrorResponse,
|
||||||
|
ThoughtSchema,
|
||||||
|
ThoughtListSchema,
|
||||||
|
UserSchema,
|
||||||
|
UserListSchema
|
||||||
|
))
|
||||||
|
)]
|
||||||
|
pub(super) struct SearchApi;
|
@@ -2,15 +2,19 @@ use api::{
|
|||||||
models::{ApiErrorResponse, ParamsErrorResponse},
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
routers::thought::*,
|
routers::thought::*,
|
||||||
};
|
};
|
||||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
use models::{
|
||||||
|
params::thought::CreateThoughtParams,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
|
||||||
|
};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(thoughts_post, thoughts_delete, get_thought_by_id),
|
paths(thoughts_post, thoughts_delete, get_thought_by_id, get_thought_thread),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateThoughtParams,
|
CreateThoughtParams,
|
||||||
ThoughtSchema,
|
ThoughtSchema,
|
||||||
|
ThoughtThreadSchema,
|
||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
ParamsErrorResponse
|
ParamsErrorResponse
|
||||||
))
|
))
|
||||||
|
@@ -7,6 +7,7 @@ mod m20250906_130237_add_tags;
|
|||||||
mod m20250906_134056_add_api_keys;
|
mod m20250906_134056_add_api_keys;
|
||||||
mod m20250906_145148_add_reply_to_thoughts;
|
mod m20250906_145148_add_reply_to_thoughts;
|
||||||
mod m20250906_145755_add_visibility_to_thoughts;
|
mod m20250906_145755_add_visibility_to_thoughts;
|
||||||
|
mod m20250906_231359_add_full_text_search;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20250906_134056_add_api_keys::Migration),
|
Box::new(m20250906_134056_add_api_keys::Migration),
|
||||||
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
|
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
|
||||||
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
|
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
|
||||||
|
Box::new(m20250906_231359_add_full_text_search::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,48 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// --- Users Table ---
|
||||||
|
// Add the tsvector column for users
|
||||||
|
manager.get_connection().execute_unprepared(
|
||||||
|
"ALTER TABLE \"user\" ADD COLUMN \"search_document\" tsvector \
|
||||||
|
GENERATED ALWAYS AS (to_tsvector('english', username || ' ' || coalesce(display_name, ''))) STORED"
|
||||||
|
).await?;
|
||||||
|
// Add the GIN index for users
|
||||||
|
manager.get_connection().execute_unprepared(
|
||||||
|
"CREATE INDEX \"user_search_document_idx\" ON \"user\" USING GIN(\"search_document\")"
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// --- Thoughts Table ---
|
||||||
|
// Add the tsvector column for thoughts
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
"ALTER TABLE \"thought\" ADD COLUMN \"search_document\" tsvector \
|
||||||
|
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Add the GIN index for thoughts
|
||||||
|
manager.get_connection().execute_unprepared(
|
||||||
|
"CREATE INDEX \"thought_search_document_idx\" ON \"thought\" USING GIN(\"search_document\")"
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("ALTER TABLE \"user\" DROP COLUMN \"search_document\"")
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("ALTER TABLE \"thought\" DROP COLUMN \"search_document\"")
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
@@ -25,6 +25,8 @@ pub struct Model {
|
|||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
|
||||||
|
pub search_document: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
@@ -19,6 +19,8 @@ pub struct Model {
|
|||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
pub updated_at: DateTimeWithTimeZone,
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
|
||||||
|
pub search_document: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
@@ -17,7 +17,7 @@ pub struct UpdateUserParams {
|
|||||||
#[serde(rename = "displayName")]
|
#[serde(rename = "displayName")]
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
|
|
||||||
#[validate(length(max = 160))]
|
#[validate(length(max = 4000))]
|
||||||
#[schema(example = "Est. 2004")]
|
#[schema(example = "Est. 2004")]
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
|
|
||||||
|
@@ -8,7 +8,9 @@ use uuid::Uuid;
|
|||||||
pub struct ApiKeySchema {
|
pub struct ApiKeySchema {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(rename = "keyPrefix")]
|
||||||
pub key_prefix: String,
|
pub key_prefix: String,
|
||||||
|
#[serde(rename = "createdAt")]
|
||||||
pub created_at: DateTimeWithTimeZoneWrapper,
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +18,6 @@ pub struct ApiKeySchema {
|
|||||||
pub struct ApiKeyResponse {
|
pub struct ApiKeyResponse {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub key: ApiKeySchema,
|
pub key: ApiKeySchema,
|
||||||
/// The full plaintext API key. This is only returned on creation.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none", rename = "plaintextKey")]
|
#[serde(skip_serializing_if = "Option::is_none", rename = "plaintextKey")]
|
||||||
pub plaintext_key: Option<String>,
|
pub plaintext_key: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -37,6 +38,7 @@ impl ApiKeyResponse {
|
|||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct ApiKeyListSchema {
|
pub struct ApiKeyListSchema {
|
||||||
|
#[serde(rename = "apiKeys")]
|
||||||
pub api_keys: Vec<ApiKeySchema>,
|
pub api_keys: Vec<ApiKeySchema>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
|
pub mod search;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
9
thoughts-backend/models/src/schemas/search.rs
Normal file
9
thoughts-backend/models/src/schemas/search.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use super::{thought::ThoughtListSchema, user::UserListSchema};
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct SearchResultsSchema {
|
||||||
|
pub users: UserListSchema,
|
||||||
|
pub thoughts: ThoughtListSchema,
|
||||||
|
}
|
@@ -8,18 +8,16 @@ use serde::Serialize;
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, FromQueryResult, Debug)]
|
#[derive(Serialize, ToSchema, FromQueryResult, Debug, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ThoughtSchema {
|
pub struct ThoughtSchema {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
#[schema(example = "frutiger")]
|
#[schema(example = "frutiger")]
|
||||||
#[serde(rename = "authorUsername")]
|
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
#[schema(example = "This is my first thought! #welcome")]
|
#[schema(example = "This is my first thought! #welcome")]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
#[serde(rename = "replyToId")]
|
|
||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
#[serde(rename = "createdAt")]
|
|
||||||
pub created_at: DateTimeWithTimeZoneWrapper,
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +35,7 @@ impl ThoughtSchema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ThoughtListSchema {
|
pub struct ThoughtListSchema {
|
||||||
pub thoughts: Vec<ThoughtSchema>,
|
pub thoughts: Vec<ThoughtSchema>,
|
||||||
}
|
}
|
||||||
@@ -70,3 +69,15 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ThoughtThreadSchema {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub author_username: String,
|
||||||
|
pub content: String,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
pub replies: Vec<ThoughtThreadSchema>,
|
||||||
|
}
|
||||||
|
@@ -6,21 +6,16 @@ use uuid::Uuid;
|
|||||||
use crate::domains::user;
|
use crate::domains::user;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserSchema {
|
pub struct UserSchema {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
#[serde(rename = "displayName")]
|
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
#[serde(rename = "avatarUrl")]
|
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
#[serde(rename = "headerUrl")]
|
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
#[serde(rename = "customCss")]
|
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
#[serde(rename = "topFriends")]
|
|
||||||
pub top_friends: Vec<String>,
|
pub top_friends: Vec<String>,
|
||||||
#[serde(rename = "joinedAt")]
|
|
||||||
pub joined_at: DateTimeWithTimeZoneWrapper,
|
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +45,7 @@ impl From<user::Model> for UserSchema {
|
|||||||
avatar_url: user.avatar_url,
|
avatar_url: user.avatar_url,
|
||||||
header_url: user.header_url,
|
header_url: user.header_url,
|
||||||
custom_css: user.custom_css,
|
custom_css: user.custom_css,
|
||||||
top_friends: vec![], // Defaults to an empty list
|
top_friends: vec![],
|
||||||
joined_at: user.created_at.into(),
|
joined_at: user.created_at.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,8 +65,16 @@ impl From<Vec<user::Model>> for UserListSchema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MeSchema {
|
pub struct MeSchema {
|
||||||
#[serde(flatten)]
|
pub id: Uuid,
|
||||||
pub user: UserSchema,
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
pub top_friends: Vec<String>,
|
||||||
|
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||||
pub following: Vec<UserSchema>,
|
pub following: Vec<UserSchema>,
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
use api::{setup_db, setup_router};
|
use api::{setup_db, setup_router};
|
||||||
use doc::ApiDoc;
|
use doc::ApiDocExt;
|
||||||
use utils::migrate;
|
use utils::migrate;
|
||||||
|
|
||||||
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {
|
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
use api::{setup_config, setup_db, setup_router};
|
use api::{setup_config, setup_db, setup_router};
|
||||||
use doc::ApiDoc;
|
|
||||||
use utils::{create_dev_db, migrate};
|
use utils::{create_dev_db, migrate};
|
||||||
|
|
||||||
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
||||||
tracing::info!("Worker {} started", child_num);
|
|
||||||
|
|
||||||
let conn = setup_db(db_url, prefork).await;
|
let conn = setup_db(db_url, prefork).await;
|
||||||
|
|
||||||
if child_num == 0 {
|
if child_num == 0 {
|
||||||
@@ -13,7 +10,7 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
|
|||||||
|
|
||||||
let config = setup_config();
|
let config = setup_config();
|
||||||
|
|
||||||
let router = setup_router(conn, &config).attach_doc();
|
let router = setup_router(conn, &config);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
|
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
|
||||||
axum::serve(listener, router).await.expect("start server");
|
axum::serve(listener, router).await.expect("start server");
|
||||||
@@ -44,11 +41,12 @@ fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
tracing::info!("Starting server...");
|
||||||
let config = setup_config();
|
let config = setup_config();
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
|
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
|
||||||
listener.set_nonblocking(true).expect("non blocking failed");
|
listener.set_nonblocking(true).expect("non blocking failed");
|
||||||
println!("listening on http://{}", listener.local_addr().unwrap());
|
tracing::info!("listening on http://{}", listener.local_addr().unwrap());
|
||||||
|
|
||||||
#[cfg(feature = "prefork")]
|
#[cfg(feature = "prefork")]
|
||||||
if config.prefork {
|
if config.prefork {
|
||||||
|
@@ -4,6 +4,7 @@ mod auth;
|
|||||||
mod feed;
|
mod feed;
|
||||||
mod follow;
|
mod follow;
|
||||||
mod main;
|
mod main;
|
||||||
|
mod search;
|
||||||
mod tag;
|
mod tag;
|
||||||
mod thought;
|
mod thought;
|
||||||
mod user;
|
mod user;
|
||||||
|
198
thoughts-backend/tests/api/search.rs
Normal file
198
thoughts-backend/tests/api/search.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_get_request, make_jwt_request};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search_all() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
// 1. Setup users and data
|
||||||
|
let user1 =
|
||||||
|
create_user_with_password(&app.db, "search_user1", "password123", "s1@test.com").await;
|
||||||
|
let user2 =
|
||||||
|
create_user_with_password(&app.db, "search_user2", "password123", "s2@test.com").await;
|
||||||
|
let _user3 =
|
||||||
|
create_user_with_password(&app.db, "stranger_user", "password123", "s3@test.com").await;
|
||||||
|
|
||||||
|
// Make user1 and user2 friends
|
||||||
|
app::persistence::follow::follow_user(&app.db, user1.id, user2.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
app::persistence::follow::follow_user(&app.db, user2.id, user1.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let token1 = login_user(app.router.clone(), "search_user1", "password123").await;
|
||||||
|
let token2 = login_user(app.router.clone(), "search_user2", "password123").await;
|
||||||
|
let token3 = login_user(app.router.clone(), "stranger_user", "password123").await;
|
||||||
|
|
||||||
|
// User1 posts thoughts with different visibilities
|
||||||
|
let thought_public =
|
||||||
|
json!({ "content": "A very public thought about Rust.", "visibility": "Public" })
|
||||||
|
.to_string();
|
||||||
|
let thought_friends =
|
||||||
|
json!({ "content": "A friendly thought, just for pals.", "visibility": "FriendsOnly" })
|
||||||
|
.to_string();
|
||||||
|
let thought_private =
|
||||||
|
json!({ "content": "A private thought, for my eyes only.", "visibility": "Private" })
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_public),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_friends),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_private),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 2. Run search tests
|
||||||
|
|
||||||
|
// -- User Search --
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=search_user1", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["users"]["users"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["users"]["users"][0]["username"], "search_user1");
|
||||||
|
|
||||||
|
// -- Thought Search (Public) --
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=public", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Guest should find public thought"
|
||||||
|
);
|
||||||
|
assert!(v["thoughts"]["thoughts"][0]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("public"));
|
||||||
|
|
||||||
|
// -- Thought Search (FriendsOnly) --
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=friendly",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Author should find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=friendly",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Friend should find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=friendly",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token3,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Stranger should NOT find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=friendly", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Guest should NOT find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Thought Search (Private) --
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=private",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Author should find private thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=private",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Friend should NOT find private thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=private", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Guest should NOT find private thought"
|
||||||
|
);
|
||||||
|
}
|
@@ -249,3 +249,73 @@ async fn test_get_thought_by_id_visibility() {
|
|||||||
"Friend should NOT see private thought"
|
"Friend should NOT see private thought"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_thought_thread() {
|
||||||
|
let app = setup().await;
|
||||||
|
let _user1 =
|
||||||
|
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
|
||||||
|
let _user2 =
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
|
||||||
|
let user3 =
|
||||||
|
create_user_with_password(&app.db, "user3", "password123", "user3@example.com").await;
|
||||||
|
|
||||||
|
let token1 = login_user(app.router.clone(), "user1", "password123").await;
|
||||||
|
let token2 = login_user(app.router.clone(), "user2", "password123").await;
|
||||||
|
|
||||||
|
// 1. user1 posts a root thought
|
||||||
|
let root_id = post_thought_and_get_id(&app.router, "Root thought", "Public", &token1).await;
|
||||||
|
|
||||||
|
// 2. user2 replies to the root thought
|
||||||
|
let reply1_body = json!({ "content": "First reply", "replyToId": root_id }).to_string();
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(reply1_body),
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let reply1: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let reply1_id = reply1["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// 3. user1 replies to user2's reply
|
||||||
|
let reply2_body =
|
||||||
|
json!({ "content": "Reply to the reply", "replyToId": reply1_id }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(reply2_body),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 4. Fetch the entire thread
|
||||||
|
let response = make_get_request(
|
||||||
|
app.router.clone(),
|
||||||
|
&format!("/thoughts/{}/thread", root_id),
|
||||||
|
Some(user3.id), // Fetch as a third user to test visibility
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let thread: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
// 5. Assert the structure
|
||||||
|
assert_eq!(thread["content"], "Root thought");
|
||||||
|
assert_eq!(thread["authorUsername"], "user1");
|
||||||
|
assert_eq!(thread["replies"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let reply_level_1 = &thread["replies"][0];
|
||||||
|
assert_eq!(reply_level_1["content"], "First reply");
|
||||||
|
assert_eq!(reply_level_1["authorUsername"], "user2");
|
||||||
|
assert_eq!(reply_level_1["replies"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let reply_level_2 = &reply_level_1["replies"][0];
|
||||||
|
assert_eq!(reply_level_2["content"], "Reply to the reply");
|
||||||
|
assert_eq!(reply_level_2["authorUsername"], "user1");
|
||||||
|
assert!(reply_level_2["replies"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
@@ -5,7 +5,7 @@ export default function AuthLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gray-100">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
BIN
thoughts-frontend/app/frutiger-bold.woff
Normal file
BIN
thoughts-frontend/app/frutiger-bold.woff
Normal file
Binary file not shown.
BIN
thoughts-frontend/app/frutiger.woff
Normal file
BIN
thoughts-frontend/app/frutiger.woff
Normal file
Binary file not shown.
@@ -41,28 +41,33 @@
|
|||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
--background-start: var(--color-sky-blue);
|
||||||
|
--background-end: var(--color-lime-300);
|
||||||
|
|
||||||
|
/* Frutiger Aero Gradients */
|
||||||
|
--gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%;
|
||||||
|
--gradient-fa-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%;
|
||||||
|
--gradient-fa-card: 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%,
|
||||||
|
hsl(var(--card)) 100%;
|
||||||
|
--gradient-fa-gloss: 135deg, rgba(255, 255, 255, 0.2) 0%,
|
||||||
|
rgba(255, 255, 255, 0) 100%;
|
||||||
|
|
||||||
|
--shadow-fa-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-fa-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-fa-lg: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
--fa-inner: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
--text-shadow-default: 0 1px 1px rgba(0, 0, 0, 0.2);
|
||||||
|
--text-shadow-sm: 0 1px 0px rgba(255, 255, 255, 0.4);
|
||||||
|
--text-shadow-md: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--text-shadow-lg: 0 4px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
--font-display: var(--font-frutiger), "Arial", "Helvetica", "sans-serif";
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
@@ -76,27 +81,40 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
|
--background: hsl(0 0% 98%); /* Light off-white */
|
||||||
|
--foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--muted: hsl(210 20% 96.1%);
|
||||||
|
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||||
|
|
||||||
|
--popover: hsl(0 0% 100%);
|
||||||
|
--popover-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--card: hsl(0 0% 100%); /* Pure white for a crisp look */
|
||||||
|
--card-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--border: hsl(214.3 31.8% 91.4%);
|
||||||
|
--input: hsl(214.3 31.8% 91.4%);
|
||||||
|
--ring: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--primary: hsl(217 91% 60%); /* Vibrant Blue */
|
||||||
|
--primary-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--secondary: hsl(155 70% 55%); /* Vibrant Green */
|
||||||
|
--secondary-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--destructive: hsl(0 84.2% 60.2%);
|
||||||
|
--destructive-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--accent: hsl(210 20% 96.1%);
|
||||||
|
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--radius: 0.75rem; /* Larger border radius */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
@@ -110,13 +128,187 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
|
||||||
|
--background: hsl(222.2 47.4% 11.2%);
|
||||||
|
--foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--muted: hsl(217.2 32.4% 14.8%);
|
||||||
|
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||||
|
|
||||||
|
--popover: hsl(222.2 47.4% 11.2%);
|
||||||
|
--popover-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--card: hsl(217.2 32.4% 14.8%);
|
||||||
|
--card-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--border: hsl(217.2 32.4% 14.8%);
|
||||||
|
--input: hsl(217.2 32.4% 14.8%);
|
||||||
|
--ring: hsl(212.7 26.8% 83.9%);
|
||||||
|
|
||||||
|
--primary: hsl(217 91% 60%); /* Vibrant Blue (same as light) */
|
||||||
|
--primary-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--secondary: hsl(155 70% 55%); /* Vibrant Green (same as light) */
|
||||||
|
--secondary-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--destructive: hsl(0 62.8% 30.6%);
|
||||||
|
--destructive-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--accent: hsl(217.2 32.4% 14.8%);
|
||||||
|
--accent-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
/* Frutiger Aero Gradients for dark mode (slightly adjusted) */
|
||||||
|
--color-fa-gradient-blue: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
hsl(217 91% 45%) 0%,
|
||||||
|
hsl(200 90% 55%) 100%
|
||||||
|
);
|
||||||
|
--color-fa-gradient-green: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
hsl(155 70% 40%) 0%,
|
||||||
|
hsl(170 80% 50%) 100%
|
||||||
|
);
|
||||||
|
--color-fa-gradient-card: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
hsl(var(--card)) 0%,
|
||||||
|
hsl(var(--card)) 90%,
|
||||||
|
hsl(var(--card)) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
background-image: url("/background.jpeg");
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossy-effect::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 50%;
|
||||||
|
border-radius: var(--radius); /* Inherit parent's border radius */
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 255, 255, 0.4) 0%,
|
||||||
|
rgba(255, 255, 255, 0.1) 100%
|
||||||
|
);
|
||||||
|
opacity: 0.8;
|
||||||
|
pointer-events: none; /* Allow clicks to pass through */
|
||||||
|
z-index: 1; /* Ensure it's above the background but below content */
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossy-effect.bottom::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 30%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.1) 0%,
|
||||||
|
rgba(0, 0, 0, 0) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-gradient-blue {
|
||||||
|
background: linear-gradient(var(--gradient-fa-blue));
|
||||||
|
}
|
||||||
|
.fa-gradient-green {
|
||||||
|
background: linear-gradient(var(--gradient-fa-green));
|
||||||
|
}
|
||||||
|
.fa-gradient-card {
|
||||||
|
background: linear-gradient(var(--gradient-fa-card));
|
||||||
|
}
|
||||||
|
.fa-gloss {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.fa-gloss::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 50%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(var(--gradient-fa-gloss));
|
||||||
|
opacity: 0.8;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.fa-gloss.bottom::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 30%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.1) 0%,
|
||||||
|
rgba(0, 0, 0, 0) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.shadow-fa-sm {
|
||||||
|
box-shadow: var(--shadow-fa-sm), var(--fa-inner);
|
||||||
|
}
|
||||||
|
.shadow-fa-md {
|
||||||
|
box-shadow: var(--shadow-fa-md), var(--fa-inner);
|
||||||
|
}
|
||||||
|
.shadow-fa-lg {
|
||||||
|
box-shadow: var(--shadow-fa-lg), var(--fa-inner);
|
||||||
|
}
|
||||||
|
.text-shadow-default {
|
||||||
|
text-shadow: var(--text-shadow-default);
|
||||||
|
}
|
||||||
|
.text-shadow-sm {
|
||||||
|
text-shadow: var(--text-shadow-sm);
|
||||||
|
}
|
||||||
|
.text-shadow-md {
|
||||||
|
text-shadow: var(--text-shadow-md);
|
||||||
|
}
|
||||||
|
.text-shadow-lg {
|
||||||
|
text-shadow: var(--text-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-effect {
|
||||||
|
@apply bg-card/70 backdrop-blur-lg border border-white/20 shadow-fa-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gloss-highlight::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60%;
|
||||||
|
border-radius: inherit; /* This is key for matching the parent's border radius */
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 255, 255, 0.5) 0%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,22 +4,29 @@ import "./globals.css";
|
|||||||
import { AuthProvider } from "@/hooks/use-auth";
|
import { AuthProvider } from "@/hooks/use-auth";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
|
import localFont from "next/font/local";
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Thoughts",
|
title: "Thoughts",
|
||||||
description: "A social network for sharing thoughts",
|
description: "A social network for sharing thoughts",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const frutiger = localFont({
|
||||||
|
src: [
|
||||||
|
{
|
||||||
|
path: "./frutiger.woff",
|
||||||
|
weight: "normal",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "./frutiger-bold.woff",
|
||||||
|
weight: "bold",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variable: "--font-frutiger",
|
||||||
|
});
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -27,9 +34,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={`${frutiger.className} antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
@@ -1,5 +1,12 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getFeed, getMe, getUserProfile, Me, User } from "@/lib/api";
|
import {
|
||||||
|
getFeed,
|
||||||
|
getFriends,
|
||||||
|
getMe,
|
||||||
|
getUserProfile,
|
||||||
|
Me,
|
||||||
|
User,
|
||||||
|
} from "@/lib/api";
|
||||||
import { PostThoughtForm } from "@/components/post-thought-form";
|
import { PostThoughtForm } from "@/components/post-thought-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -24,7 +31,10 @@ async function FeedPage({ token }: { token: string }) {
|
|||||||
getMe(token).catch(() => null) as Promise<Me | null>,
|
getMe(token).catch(() => null) as Promise<Me | null>,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const authors = [...new Set(feedData.thoughts.map((t) => t.authorUsername))];
|
const allThoughts = feedData.thoughts;
|
||||||
|
const thoughtThreads = buildThoughtThreads(feedData.thoughts);
|
||||||
|
|
||||||
|
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
||||||
const userProfiles = await Promise.all(
|
const userProfiles = await Promise.all(
|
||||||
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
||||||
);
|
);
|
||||||
@@ -35,15 +45,14 @@ async function FeedPage({ token }: { token: string }) {
|
|||||||
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||||
);
|
);
|
||||||
|
|
||||||
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(
|
const friends = (await getFriends(token)).users.map((user) => user.username);
|
||||||
feedData.thoughts
|
const shouldDisplayTopFriends = me?.topFriends && me.topFriends.length > 0;
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
<aside className="hidden lg:block lg:col-span-1">
|
<aside className="hidden lg:block lg:col-span-1">
|
||||||
<div className="sticky top-20 space-y-6">
|
<div className="sticky top-20 space-y-6 glass-effect glossy-effect bottom rounded-md p-4">
|
||||||
<h2 className="text-lg font-semibold">Filters & Sorting</h2>
|
<h2 className="text-lg font-semibold">Filters & Sorting</h2>
|
||||||
<p className="text-sm text-muted-foreground">Coming soon...</p>
|
<p className="text-sm text-muted-foreground">Coming soon...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,20 +60,19 @@ async function FeedPage({ token }: { token: string }) {
|
|||||||
|
|
||||||
<main className="col-span-1 lg:col-span-2 space-y-6">
|
<main className="col-span-1 lg:col-span-2 space-y-6">
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<h1 className="text-3xl font-bold">Your Feed</h1>
|
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
|
||||||
</header>
|
</header>
|
||||||
<PostThoughtForm />
|
<PostThoughtForm />
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{topLevelThoughts.map((thought) => (
|
{thoughtThreads.map((thought) => (
|
||||||
<ThoughtThread
|
<ThoughtThread
|
||||||
key={thought.id}
|
key={thought.id}
|
||||||
thought={thought}
|
thought={thought}
|
||||||
repliesByParentId={repliesByParentId}
|
|
||||||
authorDetails={authorDetails}
|
authorDetails={authorDetails}
|
||||||
currentUser={me}
|
currentUser={me}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{topLevelThoughts.length === 0 && (
|
{thoughtThreads.length === 0 && (
|
||||||
<p className="text-center text-muted-foreground pt-8">
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
Your feed is empty. Follow some users to see their thoughts!
|
Your feed is empty. Follow some users to see their thoughts!
|
||||||
</p>
|
</p>
|
||||||
@@ -74,8 +82,11 @@ async function FeedPage({ token }: { token: string }) {
|
|||||||
|
|
||||||
<aside className="hidden lg:block lg:col-span-1">
|
<aside className="hidden lg:block lg:col-span-1">
|
||||||
<div className="sticky top-20 space-y-6">
|
<div className="sticky top-20 space-y-6">
|
||||||
{me?.topFriends && <TopFriends usernames={me.topFriends} />}
|
{shouldDisplayTopFriends && (
|
||||||
|
<TopFriends mode="top-friends" usernames={me.topFriends} />
|
||||||
|
)}
|
||||||
<PopularTags />
|
<PopularTags />
|
||||||
|
{token && <TopFriends mode="friends" usernames={friends || []} />}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
76
thoughts-frontend/app/search/page.tsx
Normal file
76
thoughts-frontend/app/search/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getMe, search, User } from "@/lib/api";
|
||||||
|
import { UserListCard } from "@/components/user-list-card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ThoughtList } from "@/components/thought-list";
|
||||||
|
|
||||||
|
interface SearchPageProps {
|
||||||
|
searchParams: { q?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||||
|
const query = searchParams.q || "";
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold mt-8">Search Thoughts</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Find users and thoughts across the platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [results, me] = await Promise.all([
|
||||||
|
search(query, token).catch(() => null),
|
||||||
|
token ? getMe(token).catch(() => null) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||||
|
if (results) {
|
||||||
|
results.users.users.forEach((user: User) => {
|
||||||
|
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="text-3xl font-bold">Search Results</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Showing results for: "{query}"
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{results ? (
|
||||||
|
<Tabs defaultValue="thoughts" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="thoughts">
|
||||||
|
Thoughts ({results.thoughts.thoughts.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">
|
||||||
|
Users ({results.users.users.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="thoughts">
|
||||||
|
<ThoughtList
|
||||||
|
thoughts={results.thoughts.thoughts}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="users">
|
||||||
|
<UserListCard users={results.users.users} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
No results found or an error occurred.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
27
thoughts-frontend/app/settings/api-keys/page.tsx
Normal file
27
thoughts-frontend/app/settings/api-keys/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getApiKeys } from "@/lib/api";
|
||||||
|
import { ApiKeyList } from "@/components/api-keys-list";
|
||||||
|
|
||||||
|
export default async function ApiKeysPage() {
|
||||||
|
const token = (await cookies()).get("auth_token")?.value;
|
||||||
|
if (!token) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialApiKeys = await getApiKeys(token).catch(() => ({
|
||||||
|
apiKeys: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
|
||||||
|
<h3 className="text-lg font-medium">API Keys</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage API keys for third-party applications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ApiKeyList initialApiKeys={initialApiKeys.apiKeys} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -7,7 +7,10 @@ const sidebarNavItems = [
|
|||||||
title: "Profile",
|
title: "Profile",
|
||||||
href: "/settings/profile",
|
href: "/settings/profile",
|
||||||
},
|
},
|
||||||
// You can add more links here later, e.g., "Account", "API Keys"
|
{
|
||||||
|
title: "API Keys",
|
||||||
|
href: "/settings/api-keys",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsLayout({
|
export default function SettingsLayout({
|
||||||
@@ -17,7 +20,7 @@ export default function SettingsLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-5xl space-y-6 p-10 pb-16">
|
<div className="container mx-auto max-w-5xl space-y-6 p-10 pb-16">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5 p-4 glass-effect rounded-md shadow-fa-lg">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage your account settings and profile.
|
Manage your account settings and profile.
|
||||||
|
@@ -19,7 +19,7 @@ export default async function EditProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 ">
|
<div className="space-y-6 ">
|
||||||
<div>
|
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
|
||||||
<h3 className="text-lg font-medium">Profile</h3>
|
<h3 className="text-lg font-medium">Profile</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This is how others will see you on the site.
|
This is how others will see you on the site.
|
||||||
|
@@ -24,6 +24,7 @@ export default async function TagPage({ params }: TagPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allThoughts = thoughtsResult.value.thoughts;
|
const allThoughts = thoughtsResult.value.thoughts;
|
||||||
|
const thoughtThreads = buildThoughtThreads(allThoughts);
|
||||||
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||||
|
|
||||||
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
||||||
@@ -36,9 +37,6 @@ export default async function TagPage({ params }: TagPageProps) {
|
|||||||
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||||
);
|
);
|
||||||
|
|
||||||
const { topLevelThoughts, repliesByParentId } =
|
|
||||||
buildThoughtThreads(allThoughts);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
<header className="my-6">
|
<header className="my-6">
|
||||||
@@ -48,16 +46,15 @@ export default async function TagPage({ params }: TagPageProps) {
|
|||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<main className="space-y-6">
|
<main className="space-y-6">
|
||||||
{topLevelThoughts.map((thought) => (
|
{thoughtThreads.map((thought) => (
|
||||||
<ThoughtThread
|
<ThoughtThread
|
||||||
key={thought.id}
|
key={thought.id}
|
||||||
thought={thought}
|
thought={thought}
|
||||||
repliesByParentId={repliesByParentId}
|
|
||||||
authorDetails={authorDetails}
|
authorDetails={authorDetails}
|
||||||
currentUser={me}
|
currentUser={me}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{topLevelThoughts.length === 0 && (
|
{thoughtThreads.length === 0 && (
|
||||||
<p className="text-center text-muted-foreground pt-8">
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
No thoughts found for this tag.
|
No thoughts found for this tag.
|
||||||
</p>
|
</p>
|
||||||
|
@@ -1,13 +1,12 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import {
|
import {
|
||||||
getThoughtById,
|
getThoughtThread,
|
||||||
getUserThoughts,
|
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
getMe,
|
getMe,
|
||||||
Me,
|
Me,
|
||||||
Thought,
|
User,
|
||||||
|
ThoughtThread as ThoughtThreadType,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { buildThoughtThreads } from "@/lib/utils";
|
|
||||||
import { ThoughtThread } from "@/components/thought-thread";
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
@@ -15,57 +14,43 @@ interface ThoughtPageProps {
|
|||||||
params: { thoughtId: string };
|
params: { thoughtId: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findConversationRoot(
|
function collectAuthors(thread: ThoughtThreadType): string[] {
|
||||||
startThought: Thought,
|
const authors = new Set<string>([thread.authorUsername]);
|
||||||
token: string | null
|
for (const reply of thread.replies) {
|
||||||
): Promise<Thought> {
|
collectAuthors(reply).forEach((author) => authors.add(author));
|
||||||
let currentThought = startThought;
|
|
||||||
while (currentThought.replyToId) {
|
|
||||||
const parentThought = await getThoughtById(
|
|
||||||
currentThought.replyToId,
|
|
||||||
token
|
|
||||||
).catch(() => null);
|
|
||||||
if (!parentThought) break;
|
|
||||||
currentThought = parentThought;
|
|
||||||
}
|
}
|
||||||
return currentThought;
|
return Array.from(authors);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ThoughtPage({ params }: ThoughtPageProps) {
|
export default async function ThoughtPage({ params }: ThoughtPageProps) {
|
||||||
const { thoughtId } = params;
|
const { thoughtId } = params;
|
||||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
const initialThought = await getThoughtById(thoughtId, token).catch(
|
const [threadResult, meResult] = await Promise.allSettled([
|
||||||
() => null
|
getThoughtThread(thoughtId, token),
|
||||||
);
|
|
||||||
|
|
||||||
if (!initialThought) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootThought = await findConversationRoot(initialThought, token);
|
|
||||||
|
|
||||||
const [thoughtsResult, meResult] = await Promise.allSettled([
|
|
||||||
getUserThoughts(rootThought.authorUsername, token),
|
|
||||||
token ? getMe(token) : Promise.resolve(null),
|
token ? getMe(token) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (thoughtsResult.status === "rejected") {
|
if (threadResult.status === "rejected") {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const allThoughts = thoughtsResult.value.thoughts;
|
const thread = threadResult.value;
|
||||||
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||||
|
|
||||||
const author = await getUserProfile(rootThought.authorUsername, token).catch(
|
// Fetch details for all authors in the thread efficiently
|
||||||
() => null
|
const authorUsernames = collectAuthors(thread);
|
||||||
|
const userProfiles = await Promise.all(
|
||||||
|
authorUsernames.map((username) =>
|
||||||
|
getUserProfile(username, token).catch(() => null)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
|
||||||
if (author) {
|
|
||||||
authorDetails.set(author.username, { avatarUrl: author.avatarUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { repliesByParentId } = buildThoughtThreads(allThoughts);
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||||
|
userProfiles
|
||||||
|
.filter((u): u is User => !!u)
|
||||||
|
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
@@ -74,8 +59,7 @@ export default async function ThoughtPage({ params }: ThoughtPageProps) {
|
|||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<ThoughtThread
|
<ThoughtThread
|
||||||
thought={rootThought}
|
thought={thread}
|
||||||
repliesByParentId={repliesByParentId}
|
|
||||||
authorDetails={authorDetails}
|
authorDetails={authorDetails}
|
||||||
currentUser={me}
|
currentUser={me}
|
||||||
/>
|
/>
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
getFollowersList,
|
getFollowersList,
|
||||||
getFollowingList,
|
getFollowingList,
|
||||||
|
getFriends,
|
||||||
getMe,
|
getMe,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
getUserThoughts,
|
getUserThoughts,
|
||||||
@@ -55,7 +56,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
|
|
||||||
const thoughts =
|
const thoughts =
|
||||||
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : [];
|
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : [];
|
||||||
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(thoughts);
|
const thoughtThreads = buildThoughtThreads(thoughts);
|
||||||
|
|
||||||
const followersCount =
|
const followersCount =
|
||||||
followersResult.status === "fulfilled"
|
followersResult.status === "fulfilled"
|
||||||
@@ -75,32 +76,53 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||||
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||||
|
|
||||||
|
const friends =
|
||||||
|
typeof token === "string"
|
||||||
|
? (await getFriends(token)).users.map((user) => user.username)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const shouldDisplayTopFriends = token && friends.length > 8;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div id={`profile-page-${user.username}`}>
|
||||||
{user.customCss && (
|
{user.customCss && (
|
||||||
<style dangerouslySetInnerHTML={{ __html: user.customCss }} />
|
<style dangerouslySetInnerHTML={{ __html: user.customCss }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
id="profile-header"
|
||||||
className="h-48 bg-gray-200 bg-cover bg-center profile-header"
|
className="h-48 bg-gray-200 bg-cover bg-center profile-header"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: user.headerUrl ? `url(${user.headerUrl})` : "none",
|
backgroundImage: user.headerUrl ? `url(${user.headerUrl})` : "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8">
|
<main
|
||||||
|
id="main-container"
|
||||||
|
className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8"
|
||||||
|
>
|
||||||
{/* Left Sidebar (Profile Card & Top Friends) */}
|
{/* Left Sidebar (Profile Card & Top Friends) */}
|
||||||
<aside className="col-span-1 lg:col-span-1 space-y-6">
|
<aside id="left-sidebar" className="col-span-1 lg:col-span-1 space-y-6">
|
||||||
<div className="sticky top-20 space-y-6">
|
<div id="left-sidebar__inner" className="sticky top-20 space-y-6">
|
||||||
<Card className="p-6 bg-card/80 backdrop-blur-lg">
|
<Card id="profile-card" className="p-6 bg-card/80 backdrop-blur-lg">
|
||||||
<div className="flex justify-between items-start">
|
<div
|
||||||
<div className="flex items-end gap-4">
|
id="profile-card__inner"
|
||||||
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
|
className="flex justify-between items-start"
|
||||||
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
>
|
||||||
|
<div id="profile-card__avatar" className="flex items-end gap-4">
|
||||||
|
<div
|
||||||
|
id="profile-card__avatar-image"
|
||||||
|
className="w-24 h-24 rounded-full border-4 border-background shrink-0"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
<div>
|
<div id="profile-card__action">
|
||||||
{isOwnProfile ? (
|
{isOwnProfile ? (
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href="/settings/profile">
|
<Link href="/settings/profile">
|
||||||
@@ -116,19 +138,30 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div id="profile-card__info" className="mt-4">
|
||||||
<h1 className="text-2xl font-bold">
|
<h1 id="profile-card__name" className="text-2xl font-bold">
|
||||||
{user.displayName || user.username}
|
{user.displayName || user.username}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p
|
||||||
|
id="profile-card__username"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
@{user.username}
|
@{user.username}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-sm whitespace-pre-wrap">{user.bio}</p>
|
<p
|
||||||
|
id="profile-card__bio"
|
||||||
|
className="mt-4 text-sm whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
{user.bio}
|
||||||
|
</p>
|
||||||
|
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<div className="flex items-center gap-4 mt-4 text-sm">
|
<div
|
||||||
|
id="profile-card__stats"
|
||||||
|
className="flex items-center gap-4 mt-4 text-sm"
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/users/${user.username}/following`}
|
href={`/users/${user.username}/following`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
@@ -150,7 +183,10 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-4 text-sm text-muted-foreground">
|
<div
|
||||||
|
id="profile-card__joined"
|
||||||
|
className="flex items-center gap-2 mt-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
Joined {new Date(user.joinedAt).toLocaleDateString()}
|
Joined {new Date(user.joinedAt).toLocaleDateString()}
|
||||||
@@ -158,22 +194,30 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<TopFriends usernames={user.topFriends} />
|
{shouldDisplayTopFriends && (
|
||||||
|
<TopFriends mode="top-friends" usernames={user.topFriends} />
|
||||||
|
)}
|
||||||
|
{token && <TopFriends mode="friends" usernames={friends || []} />}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="col-span-1 lg:col-span-3 space-y-4">
|
<div
|
||||||
{topLevelThoughts.map((thought) => (
|
id="profile-card__thoughts"
|
||||||
|
className="col-span-1 lg:col-span-3 space-y-4"
|
||||||
|
>
|
||||||
|
{thoughtThreads.map((thought) => (
|
||||||
<ThoughtThread
|
<ThoughtThread
|
||||||
key={thought.id}
|
key={thought.id}
|
||||||
thought={thought}
|
thought={thought}
|
||||||
repliesByParentId={repliesByParentId}
|
|
||||||
authorDetails={authorDetails}
|
authorDetails={authorDetails}
|
||||||
currentUser={me}
|
currentUser={me}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{topLevelThoughts.length === 0 && (
|
{thoughtThreads.length === 0 && (
|
||||||
<Card className="flex items-center justify-center h-48">
|
<Card
|
||||||
|
id="profile-card__no-thoughts"
|
||||||
|
className="flex items-center justify-center h-48"
|
||||||
|
>
|
||||||
<p className="text-center text-muted-foreground">
|
<p className="text-center text-muted-foreground">
|
||||||
This user hasn't posted any public thoughts yet.
|
This user hasn't posted any public thoughts yet.
|
||||||
</p>
|
</p>
|
||||||
|
214
thoughts-frontend/components/api-keys-list.tsx
Normal file
214
thoughts-frontend/components/api-keys-list.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// thoughts-frontend/components/api-key-list.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import {
|
||||||
|
ApiKey,
|
||||||
|
CreateApiKeySchema,
|
||||||
|
createApiKey,
|
||||||
|
deleteApiKey,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Copy, KeyRound, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
interface ApiKeyListProps {
|
||||||
|
initialApiKeys: ApiKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyList({ initialApiKeys }: ApiKeyListProps) {
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>(initialApiKeys);
|
||||||
|
const [newKey, setNewKey] = useState<string | null>(null);
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof CreateApiKeySchema>>({
|
||||||
|
resolver: zodResolver(CreateApiKeySchema),
|
||||||
|
defaultValues: { name: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof CreateApiKeySchema>) {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const newKeyResponse = await createApiKey(values, token);
|
||||||
|
setKeys((prev) => [...prev, newKeyResponse]);
|
||||||
|
setNewKey(newKeyResponse.plaintextKey ?? null);
|
||||||
|
form.reset();
|
||||||
|
toast.success("API Key created successfully.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create API key.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (keyId: string) => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await deleteApiKey(keyId, token);
|
||||||
|
setKeys((prev) => prev.filter((key) => key.id !== keyId));
|
||||||
|
toast.success("API Key deleted successfully.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete API key.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success("Key copied to clipboard!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Existing Keys</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
These are the API keys associated with your account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{keys.length > 0 ? (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<li
|
||||||
|
key={key.id}
|
||||||
|
className="flex items-center justify-between p-4 glass-effect rounded-md shadow-fa-sm glossy-effect bottom"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<KeyRound className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{key.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{`Created on ${format(key.createdAt, "PPP")}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-mono text-muted-foreground mt-1">
|
||||||
|
{`${key.keyPrefix}...`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the key "{key.name}
|
||||||
|
". This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => handleDelete(key.id)}>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have no API keys.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display New Key */}
|
||||||
|
{newKey && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>New API Key Generated</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Please copy this key and store it securely. You will not be able
|
||||||
|
to see it again.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center gap-4">
|
||||||
|
<Input readOnly value={newKey} className="font-mono" />
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(newKey)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button onClick={() => setNewKey(null)}>Done</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create New API Key</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Give your new key a descriptive name.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<CardContent>
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Key Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g., My Cool App" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="px-6 py-4">
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Key"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -50,6 +50,7 @@ export function FollowButton({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
variant={isFollowing ? "secondary" : "default"}
|
variant={isFollowing ? "secondary" : "default"}
|
||||||
|
data-following={isFollowing}
|
||||||
>
|
>
|
||||||
{isFollowing ? (
|
{isFollowing ? (
|
||||||
<>
|
<>
|
||||||
|
@@ -5,27 +5,27 @@ import Link from "next/link";
|
|||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { UserNav } from "./user-nav";
|
import { UserNav } from "./user-nav";
|
||||||
import { MainNav } from "./main-nav";
|
import { MainNav } from "./main-nav";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-primary/20 bg-background/80 glass-effect glossy-effect bottom rounded-none">
|
||||||
<div className="w-full flex h-14 items-center px-2">
|
<div className="container flex h-14 items-center px-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href="/" className="flex items-center gap-1">
|
<Link href="/" className="flex items-center gap-1">
|
||||||
<span className="hidden font-bold sm:inline-block">Thoughts</span>
|
<span className="hidden font-bold text-primary sm:inline-block">
|
||||||
|
Thoughts
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<MainNav />
|
<MainNav />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-end space-x-2">
|
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||||
<ThemeToggle />
|
|
||||||
{token ? (
|
{token ? (
|
||||||
<UserNav />
|
<UserNav />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild size="sm">
|
||||||
<Link href="/login">Login</Link>
|
<Link href="/login">Login</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SearchInput } from "./search-input";
|
||||||
|
|
||||||
export function MainNav() {
|
export function MainNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -17,6 +18,7 @@ export function MainNav() {
|
|||||||
>
|
>
|
||||||
Feed
|
Feed
|
||||||
</Link>
|
</Link>
|
||||||
|
<SearchInput />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -23,22 +23,25 @@ export async function PopularTags() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="p-4">
|
||||||
<CardHeader>
|
<CardHeader className="p-0 pb-2">
|
||||||
<CardTitle>Popular Tags</CardTitle>
|
<CardTitle className="text-lg">Popular Tags</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
<CardContent className="flex flex-wrap gap-2 p-0">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<Link href={`/tags/${tag}`} key={tag}>
|
<Link href={`/tags/${tag}`} key={tag}>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="hover:bg-accent cursor-pointer"
|
className="hover:shadow-lg transition-shadow text-shadow-sm cursor-pointer"
|
||||||
>
|
>
|
||||||
<Hash className="mr-1 h-3 w-3" />
|
<Hash className="mr-1 h-3 w-3" />
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{tags.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No popular tags yet.</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
@@ -46,7 +46,7 @@ export function PostThoughtForm() {
|
|||||||
toast.success("Your thought has been posted!");
|
toast.success("Your thought has been posted!");
|
||||||
form.reset();
|
form.reset();
|
||||||
router.refresh(); // This is the key to updating the feed
|
router.refresh(); // This is the key to updating the feed
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast.error("Failed to post thought. Please try again.");
|
toast.error("Failed to post thought. Please try again.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -55,7 +55,7 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 pt-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 p-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
@@ -64,7 +64,7 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Post your reply..."
|
placeholder="Post your reply..."
|
||||||
className="resize-none"
|
className="resize-none bg-white glass-effect glossy-efect bottom shadow-fa-sm"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
29
thoughts-frontend/components/search-input.tsx
Normal file
29
thoughts-frontend/components/search-input.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Search as SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export function SearchInput() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const query = formData.get("q") as string;
|
||||||
|
if (query) {
|
||||||
|
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSearch} className="relative w-full max-w-sm">
|
||||||
|
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
name="q"
|
||||||
|
placeholder="Search for users or thoughts..."
|
||||||
|
className="pl-9 md:min-w-[250px]"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
@@ -29,10 +29,8 @@ export function SettingsNav({ className, items, ...props }: SettingsNavProps) {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: "ghost" }),
|
||||||
pathname === item.href
|
pathname === item.href ? "bg-muted" : "hover:underline",
|
||||||
? "bg-muted hover:bg-muted"
|
"justify-start glass-effect glossy-effect bottom shadow-fa-md"
|
||||||
: "hover:bg-transparent hover:underline",
|
|
||||||
"justify-start"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
|
@@ -38,6 +38,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ReplyForm } from "@/components/reply-form";
|
import { ReplyForm } from "@/components/reply-form";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ThoughtCardProps {
|
interface ThoughtCardProps {
|
||||||
thought: Thought;
|
thought: Thought;
|
||||||
@@ -83,16 +84,21 @@ export function ThoughtCard({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
id={thought.id}
|
id={thought.id}
|
||||||
className={!isReply ? "bg-card rounded-xl border shadow-sm" : ""}
|
className={cn(
|
||||||
|
"bg-transparent backdrop-blur-lg shadow-fa-md rounded-xl overflow-hidden glossy-effect bottom",
|
||||||
|
isReply
|
||||||
|
? "bg-white/80 glass-effect glossy-effect bottom shadow-fa-sm p-2"
|
||||||
|
: ""
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{thought.replyToId && isReply && (
|
{thought.replyToId && isReply && (
|
||||||
<div className="px-4 pt-2 text-sm text-muted-foreground flex items-center gap-2">
|
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
<CornerUpLeft className="h-4 w-4" />
|
<CornerUpLeft className="h-4 w-4 text-primary/70" />
|
||||||
<span>
|
<span>
|
||||||
Replying to{" "}
|
Replying to{" "}
|
||||||
<Link
|
<Link
|
||||||
href={`#${thought.replyToId}`}
|
href={`#${thought.replyToId}`}
|
||||||
className="hover:underline text-primary"
|
className="hover:underline text-primary text-shadow-sm"
|
||||||
>
|
>
|
||||||
parent thought
|
parent thought
|
||||||
</Link>
|
</Link>
|
||||||
@@ -100,16 +106,18 @@ export function ThoughtCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card className="mt-2">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/users/${author.username}`}
|
href={`/users/${author.username}`}
|
||||||
className="flex items-center gap-4"
|
className="flex items-center gap-4 text-shadow-md"
|
||||||
>
|
>
|
||||||
<UserAvatar src={author.avatarUrl} alt={author.username} />
|
<UserAvatar src={author.avatarUrl} alt={author.username} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold">{author.username}</span>
|
<span className="font-bold">{author.username}</span>
|
||||||
<span className="text-sm text-muted-foreground">{timeAgo}</span>
|
<span className="text-sm text-muted-foreground text-shadow-sm">
|
||||||
|
{timeAgo}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -138,11 +146,13 @@ export function ThoughtCard({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="whitespace-pre-wrap break-words">{thought.content}</p>
|
<p className="whitespace-pre-wrap break-words text-shadow-sm">
|
||||||
|
{thought.content}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{token && (
|
{token && (
|
||||||
<CardFooter className="border-t px-4 pt-2 pb-2">
|
<CardFooter className="border-t px-4 pt-2 pb-2 border-border/50">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -155,7 +165,7 @@ export function ThoughtCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isReplyOpen && (
|
{isReplyOpen && (
|
||||||
<div className="border-t p-4">
|
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20 ">
|
||||||
<ReplyForm
|
<ReplyForm
|
||||||
parentThoughtId={thought.id}
|
parentThoughtId={thought.id}
|
||||||
onReplySuccess={() => setIsReplyOpen(false)}
|
onReplySuccess={() => setIsReplyOpen(false)}
|
||||||
|
47
thoughts-frontend/components/thought-list.tsx
Normal file
47
thoughts-frontend/components/thought-list.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Me, Thought } from "@/lib/api";
|
||||||
|
import { ThoughtCard } from "./thought-card";
|
||||||
|
import { Card, CardContent } from "./ui/card";
|
||||||
|
|
||||||
|
interface ThoughtListProps {
|
||||||
|
thoughts: Thought[];
|
||||||
|
authorDetails: Map<string, { avatarUrl?: string | null }>;
|
||||||
|
currentUser: Me | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThoughtList({
|
||||||
|
thoughts,
|
||||||
|
authorDetails,
|
||||||
|
currentUser,
|
||||||
|
}: ThoughtListProps) {
|
||||||
|
if (thoughts.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
No thoughts to display.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="divide-y p-0">
|
||||||
|
<div className="space-y-6 p-4">
|
||||||
|
{thoughts.map((thought) => {
|
||||||
|
const author = {
|
||||||
|
username: thought.authorUsername,
|
||||||
|
avatarUrl: null,
|
||||||
|
...authorDetails.get(thought.authorUsername),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ThoughtCard
|
||||||
|
key={thought.id}
|
||||||
|
thought={thought}
|
||||||
|
author={author}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,9 +1,8 @@
|
|||||||
import { Me, Thought } from "@/lib/api";
|
import { Me, ThoughtThread as ThoughtThreadType } from "@/lib/api";
|
||||||
import { ThoughtCard } from "./thought-card";
|
import { ThoughtCard } from "./thought-card";
|
||||||
|
|
||||||
interface ThoughtThreadProps {
|
interface ThoughtThreadProps {
|
||||||
thought: Thought;
|
thought: ThoughtThreadType;
|
||||||
repliesByParentId: Map<string, Thought[]>;
|
|
||||||
authorDetails: Map<string, { avatarUrl?: string | null }>;
|
authorDetails: Map<string, { avatarUrl?: string | null }>;
|
||||||
currentUser: Me | null;
|
currentUser: Me | null;
|
||||||
isReply?: boolean;
|
isReply?: boolean;
|
||||||
@@ -11,7 +10,6 @@ interface ThoughtThreadProps {
|
|||||||
|
|
||||||
export function ThoughtThread({
|
export function ThoughtThread({
|
||||||
thought,
|
thought,
|
||||||
repliesByParentId,
|
|
||||||
authorDetails,
|
authorDetails,
|
||||||
currentUser,
|
currentUser,
|
||||||
isReply = false,
|
isReply = false,
|
||||||
@@ -22,10 +20,8 @@ export function ThoughtThread({
|
|||||||
...authorDetails.get(thought.authorUsername),
|
...authorDetails.get(thought.authorUsername),
|
||||||
};
|
};
|
||||||
|
|
||||||
const directReplies = repliesByParentId.get(thought.id) || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0">
|
<div id={`thought-thread-${thought.id}`} className="flex flex-col gap-0">
|
||||||
<ThoughtCard
|
<ThoughtCard
|
||||||
thought={thought}
|
thought={thought}
|
||||||
author={author}
|
author={author}
|
||||||
@@ -33,13 +29,15 @@ export function ThoughtThread({
|
|||||||
isReply={isReply}
|
isReply={isReply}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{directReplies.length > 0 && (
|
{thought.replies.length > 0 && (
|
||||||
<div className="pl-6 border-l-2 border-dashed ml-6 flex flex-col gap-4 pt-4">
|
<div
|
||||||
{directReplies.map((reply) => (
|
id={`thought-thread-${thought.id}__replies`}
|
||||||
|
className="pl-6 border-l-2 border-primary border-dashed ml-6 flex flex-col gap-4 pt-4"
|
||||||
|
>
|
||||||
|
{thought.replies.map((reply) => (
|
||||||
<ThoughtThread // RECURSIVE CALL
|
<ThoughtThread // RECURSIVE CALL
|
||||||
key={reply.id}
|
key={reply.id}
|
||||||
thought={reply}
|
thought={reply}
|
||||||
repliesByParentId={repliesByParentId} // Pass the full map down
|
|
||||||
authorDetails={authorDetails}
|
authorDetails={authorDetails}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
isReply={true}
|
isReply={true}
|
||||||
|
@@ -5,21 +5,24 @@ import { getUserProfile, User } from "@/lib/api";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
interface TopFriendsProps {
|
interface TopFriendsProps {
|
||||||
|
mode: "friends" | "top-friends";
|
||||||
usernames: string[];
|
usernames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is an async Server Component
|
export async function TopFriends({
|
||||||
export async function TopFriends({ usernames }: TopFriendsProps) {
|
mode = "top-friends",
|
||||||
|
usernames,
|
||||||
|
}: TopFriendsProps) {
|
||||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
if (usernames.length === 0) {
|
if (usernames.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="p-4">
|
||||||
<CardHeader>
|
<CardHeader className="p-0 pb-2">
|
||||||
<CardTitle>Top Friends</CardTitle>
|
<CardTitle className="text-lg text-shadow-md">Top Friends</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<p className="text-center text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No top friends to display.
|
No top friends to display.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -27,7 +30,6 @@ export async function TopFriends({ usernames }: TopFriendsProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all top friend profiles in parallel
|
|
||||||
const friendsResults = await Promise.allSettled(
|
const friendsResults = await Promise.allSettled(
|
||||||
usernames.map((username) => getUserProfile(username, token))
|
usernames.map((username) => getUserProfile(username, token))
|
||||||
);
|
);
|
||||||
@@ -40,19 +42,25 @@ export async function TopFriends({ usernames }: TopFriendsProps) {
|
|||||||
.map((result) => result.value);
|
.map((result) => result.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card id="top-friends" className="p-4">
|
||||||
<CardHeader>
|
<CardHeader id="top-friends__header" className="p-0 pb-2">
|
||||||
<CardTitle>Top Friends</CardTitle>
|
<CardTitle id="top-friends__title" className="text-lg text-shadow-md">
|
||||||
|
{mode === "top-friends" ? "Top Friends" : "Friends"}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-4 gap-4">
|
<CardContent id="top-friends__content" className="p-0">
|
||||||
{friends.map((friend) => (
|
{friends.map((friend) => (
|
||||||
<Link
|
<Link
|
||||||
|
id={`top-friends__link-${friend.id}`}
|
||||||
href={`/users/${friend.username}`}
|
href={`/users/${friend.username}`}
|
||||||
key={friend.id}
|
key={friend.id}
|
||||||
className="flex flex-col items-center gap-2 text-center group"
|
className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
>
|
>
|
||||||
<UserAvatar src={friend.avatarUrl} alt={friend.username} />
|
<UserAvatar src={friend.avatarUrl} alt={friend.username} />
|
||||||
<span className="text-xs font-medium truncate w-full group-hover:underline">
|
<span
|
||||||
|
id={`top-friends__name-${friend.id}`}
|
||||||
|
className="text-xs truncate w-full group-hover:underline font-medium text-shadow-sm"
|
||||||
|
>
|
||||||
{friend.displayName || friend.username}
|
{friend.displayName || friend.username}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
@@ -1,29 +1,28 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 glossy-effect bottom text-shadow-sm", // Use green for secondary
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 glossy-effect bottom text-shadow-sm",
|
||||||
outline:
|
outline: "text-foreground glossy-effect bottom text-shadow-sm",
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
@@ -32,7 +31,7 @@ function Badge({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span"> &
|
}: React.ComponentProps<"span"> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "span"
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -40,7 +39,7 @@ function Badge({
|
|||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
@@ -1,31 +1,35 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
// Default button gets blue gradient, gloss, and shadows
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
"glass-effect fa-gradient-blue text-primary-foreground shadow-fa-md hover:bg-primary/90 active:shadow-fa-inner transition-transform active:scale-[0.98] glossy-effect",
|
||||||
destructive:
|
// Secondary gets green gradient
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
||||||
outline:
|
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"glass-effect fa-gradient-green text-secondary-foreground shadow-fa-md hover:bg-secondary/90 active:shadow-fa-inner transition-transform active:scale-[0.98] glossy-effect",
|
||||||
|
// Ghost and Link should be more subtle
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"glass-effect hover:bg-accent hover:text-accent-foreground rounded-lg", // Keep them simple, maybe a slight blur/gloss on hover
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
// Outline button for a transparent-ish, glassy feel
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background/80 hover:bg-accent/80 hover:text-accent-foreground backdrop-blur-sm shadow-fa-sm glossy-effect",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-10 px-4 py-2",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-9 rounded-md px-3",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-11 rounded-md px-8",
|
||||||
icon: "size-9",
|
icon: "h-10 w-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -33,7 +37,7 @@ const buttonVariants = cva(
|
|||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@@ -43,9 +47,9 @@ function Button({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -53,7 +57,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
@@ -1,18 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
"relative rounded-xl border bg-card/70 backdrop-blur-lg shadow-fa-lg overflow-hidden glass-effect glossy-effect bottom text-card-foreground flex flex-col gap-6 py-6",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
></div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -25,17 +25,20 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn("leading-none font-semibold", className)}
|
className={cn(
|
||||||
|
"leading-none font-semibold tracking-tight text-shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -45,7 +48,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -58,7 +61,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -68,7 +71,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("px-6", className)}
|
className={cn("px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -78,7 +81,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -89,4 +92,4 @@ export {
|
|||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
}
|
};
|
||||||
|
@@ -1,15 +1,15 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
function DropdownMenuTrigger({
|
||||||
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
|
|||||||
data-slot="dropdown-menu-trigger"
|
data-slot="dropdown-menu-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
@@ -42,13 +42,14 @@ function DropdownMenuContent({
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
"bg-popover/80 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1",
|
||||||
|
"shadow-fa-lg backdrop-blur-md",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
@@ -56,7 +57,7 @@ function DropdownMenuGroup({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
@@ -65,8 +66,8 @@ function DropdownMenuItem({
|
|||||||
variant = "default",
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
variant?: "default" | "destructive"
|
variant?: "default" | "destructive";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
@@ -79,7 +80,7 @@ function DropdownMenuItem({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
@@ -105,7 +106,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({
|
||||||
@@ -116,7 +117,7 @@ function DropdownMenuRadioGroup({
|
|||||||
data-slot="dropdown-menu-radio-group"
|
data-slot="dropdown-menu-radio-group"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
@@ -140,7 +141,7 @@ function DropdownMenuRadioItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
@@ -148,7 +149,7 @@ function DropdownMenuLabel({
|
|||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
@@ -160,7 +161,7 @@ function DropdownMenuLabel({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({
|
||||||
@@ -173,7 +174,7 @@ function DropdownMenuSeparator({
|
|||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({
|
||||||
@@ -189,13 +190,13 @@ function DropdownMenuShortcut({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({
|
function DropdownMenuSub({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
function DropdownMenuSubTrigger({
|
||||||
@@ -204,7 +205,7 @@ function DropdownMenuSubTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
@@ -219,7 +220,7 @@ function DropdownMenuSubTrigger({
|
|||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
@@ -235,7 +236,7 @@ function DropdownMenuSubContent({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -254,4 +255,4 @@ export {
|
|||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
}
|
};
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
@@ -10,12 +10,12 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-fa-inner transition-shadow glass-effect glossy-effect bottom",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
@@ -1,20 +1,20 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Popover({
|
function Popover({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTrigger({
|
function PopoverTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverContent({
|
function PopoverContent({
|
||||||
@@ -30,19 +30,20 @@ function PopoverContent({
|
|||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
"bg-popover/80 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 outline-hidden",
|
||||||
|
"shadow-fa-lg backdrop-blur-md",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverAnchor({
|
function PopoverAnchor({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
|
@@ -1,27 +1,27 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Select({
|
function Select({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
function SelectGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
@@ -30,14 +30,14 @@ function SelectTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"bg-transparent glass-effect glossy-effect bottom shadow-fa-md border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -47,7 +47,7 @@ function SelectTrigger({
|
|||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
@@ -61,7 +61,7 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"bg-popover/90 glass-effect shadow-fa-sm bottom text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className
|
||||||
@@ -82,7 +82,7 @@ function SelectContent({
|
|||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({
|
||||||
@@ -95,7 +95,7 @@ function SelectLabel({
|
|||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
@@ -107,7 +107,7 @@ function SelectItem({
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"bg-transparent focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -119,7 +119,7 @@ function SelectItem({
|
|||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({
|
||||||
@@ -132,7 +132,7 @@ function SelectSeparator({
|
|||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
@@ -150,7 +150,7 @@ function SelectScrollUpButton({
|
|||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
@@ -168,7 +168,7 @@ function SelectScrollDownButton({
|
|||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -182,4 +182,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
};
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
@@ -17,12 +17,12 @@ function Separator({
|
|||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
"bg-border/50 shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
@@ -1,13 +1,13 @@
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="skeleton"
|
data-slot="skeleton"
|
||||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
className={cn("bg-muted/50 animate-pulse rounded-md", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton }
|
export { Skeleton };
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
return (
|
return (
|
||||||
@@ -8,11 +8,12 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"shadow-fa-inner transition-shadow glass-effect glossy-effect bottom",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea };
|
||||||
|
@@ -1,15 +1,23 @@
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { User } from "lucide-react";
|
import { User } from "lucide-react";
|
||||||
|
|
||||||
interface UserAvatarProps {
|
interface UserAvatarProps {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
alt?: string | null;
|
alt?: string | null;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserAvatar({ src, alt }: UserAvatarProps) {
|
export function UserAvatar({ src, alt, className }: UserAvatarProps) {
|
||||||
return (
|
return (
|
||||||
<Avatar>
|
<Avatar className={cn("border-2 border-primary/50 shadow-md", className)}>
|
||||||
{src && <AvatarImage src={src} alt={alt ?? "User avatar"} />}
|
{src && (
|
||||||
|
<AvatarImage
|
||||||
|
className="object-cover object-center"
|
||||||
|
src={src}
|
||||||
|
alt={alt ?? "User avatar"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
@@ -61,7 +61,11 @@ export function UserNav() {
|
|||||||
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
<DropdownMenuContent
|
||||||
|
className="w-56 glossy-effect bottom shadow-fa-md"
|
||||||
|
align="end"
|
||||||
|
forceMount
|
||||||
|
>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">
|
<p className="text-sm font-medium leading-none">
|
||||||
|
@@ -53,18 +53,63 @@ export const CreateThoughtSchema = z.object({
|
|||||||
|
|
||||||
export const UpdateProfileSchema = z.object({
|
export const UpdateProfileSchema = z.object({
|
||||||
displayName: z.string().max(50).optional(),
|
displayName: z.string().max(50).optional(),
|
||||||
bio: z.string().max(160).optional(),
|
bio: z.string().max(4000).optional(),
|
||||||
avatarUrl: z.url().or(z.literal("")).optional(),
|
avatarUrl: z.url().or(z.literal("")).optional(),
|
||||||
headerUrl: z.url().or(z.literal("")).optional(),
|
headerUrl: z.url().or(z.literal("")).optional(),
|
||||||
customCss: z.string().optional(),
|
customCss: z.string().optional(),
|
||||||
topFriends: z.array(z.string()).max(8).optional(),
|
topFriends: z.array(z.string()).max(8).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const SearchResultsSchema = z.object({
|
||||||
|
users: z.object({ users: z.array(UserSchema) }),
|
||||||
|
thoughts: z.object({ thoughts: z.array(ThoughtSchema) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ApiKeySchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
keyPrefix: z.string(),
|
||||||
|
createdAt: z.coerce.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ApiKeyResponseSchema = ApiKeySchema.extend({
|
||||||
|
plaintextKey: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ApiKeyListSchema = z.object({
|
||||||
|
apiKeys: z.array(ApiKeySchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateApiKeySchema = z.object({
|
||||||
|
name: z.string().min(1, "Key name cannot be empty."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThoughtThreadSchema: z.ZodType<{
|
||||||
|
id: string;
|
||||||
|
authorUsername: string;
|
||||||
|
content: string;
|
||||||
|
visibility: "Public" | "FriendsOnly" | "Private";
|
||||||
|
replyToId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
replies: ThoughtThread[];
|
||||||
|
}> = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
authorUsername: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
visibility: z.enum(["Public", "FriendsOnly", "Private"]),
|
||||||
|
replyToId: z.uuid().nullable(),
|
||||||
|
createdAt: z.coerce.date(),
|
||||||
|
replies: z.lazy(() => z.array(ThoughtThreadSchema)),
|
||||||
|
});
|
||||||
|
|
||||||
export type User = z.infer<typeof UserSchema>;
|
export type User = z.infer<typeof UserSchema>;
|
||||||
export type Me = z.infer<typeof MeSchema>;
|
export type Me = z.infer<typeof MeSchema>;
|
||||||
export type Thought = z.infer<typeof ThoughtSchema>;
|
export type Thought = z.infer<typeof ThoughtSchema>;
|
||||||
export type Register = z.infer<typeof RegisterSchema>;
|
export type Register = z.infer<typeof RegisterSchema>;
|
||||||
export type Login = z.infer<typeof LoginSchema>;
|
export type Login = z.infer<typeof LoginSchema>;
|
||||||
|
export type ApiKey = z.infer<typeof ApiKeySchema>;
|
||||||
|
export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>;
|
||||||
|
export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>;
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
@@ -233,3 +278,42 @@ export const getFollowersList = (username: string, token: string | null) =>
|
|||||||
z.object({ users: z.array(UserSchema) }),
|
z.object({ users: z.array(UserSchema) }),
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const search = (query: string, token: string | null) =>
|
||||||
|
apiFetch(
|
||||||
|
`/search?q=${encodeURIComponent(query)}`,
|
||||||
|
{},
|
||||||
|
SearchResultsSchema,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export const getApiKeys = (token: string) =>
|
||||||
|
apiFetch(`/users/me/api-keys`, {}, ApiKeyListSchema, token);
|
||||||
|
|
||||||
|
export const createApiKey = (
|
||||||
|
data: z.infer<typeof CreateApiKeySchema>,
|
||||||
|
token: string
|
||||||
|
) =>
|
||||||
|
apiFetch(
|
||||||
|
`/users/me/api-keys`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
ApiKeyResponseSchema,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteApiKey = (keyId: string, token: string) =>
|
||||||
|
apiFetch(
|
||||||
|
`/users/me/api-keys/${keyId}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
z.null(),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getThoughtThread = (thoughtId: string, token: string | null) =>
|
||||||
|
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
|
@@ -1,35 +1,39 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { Thought } from "./api";
|
import { Thought, ThoughtThread as ThoughtThreadType } from "./api";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildThoughtThreads(allThoughts: Thought[]) {
|
export function buildThoughtThreads(thoughts: Thought[]): ThoughtThreadType[] {
|
||||||
const repliesByParentId = new Map<string, Thought[]>();
|
const thoughtMap = new Map<string, Thought>();
|
||||||
const topLevelThoughts: Thought[] = [];
|
thoughts.forEach((t) => thoughtMap.set(t.id, t));
|
||||||
|
|
||||||
// 1. Group all thoughts into top-level posts or replies
|
const threads: ThoughtThreadType[] = [];
|
||||||
for (const thought of allThoughts) {
|
const repliesMap: Record<string, Thought[]> = {};
|
||||||
|
|
||||||
|
thoughts.forEach((thought) => {
|
||||||
if (thought.replyToId) {
|
if (thought.replyToId) {
|
||||||
// It's a reply, group it with its parent
|
if (!repliesMap[thought.replyToId]) {
|
||||||
const replies = repliesByParentId.get(thought.replyToId) || [];
|
repliesMap[thought.replyToId] = [];
|
||||||
replies.push(thought);
|
|
||||||
repliesByParentId.set(thought.replyToId, replies);
|
|
||||||
} else {
|
|
||||||
// It's a top-level thought
|
|
||||||
topLevelThoughts.push(thought);
|
|
||||||
}
|
}
|
||||||
|
repliesMap[thought.replyToId].push(thought);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildThread(thought: Thought): ThoughtThreadType {
|
||||||
|
return {
|
||||||
|
...thought,
|
||||||
|
replies: (repliesMap[thought.id] || []).map(buildThread),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Sort top-level thoughts by date, newest first
|
thoughts.forEach((thought) => {
|
||||||
topLevelThoughts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
if (!thought.replyToId) {
|
||||||
|
threads.push(buildThread(thought));
|
||||||
// 3. Sort replies within each thread by date, oldest first for conversational flow
|
|
||||||
for (const replies of repliesByParentId.values()) {
|
|
||||||
replies.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { topLevelThoughts, repliesByParentId };
|
return threads;
|
||||||
}
|
}
|
BIN
thoughts-frontend/public/background.jpeg
Normal file
BIN
thoughts-frontend/public/background.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 MiB |
Reference in New Issue
Block a user