Compare commits

...

42 Commits

Author SHA1 Message Date
dffec9b189 fix: enhance top friends display logic in FeedPage
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m37s
2025-09-14 21:40:48 +02:00
e2494135d6 fix: add redirect to login for unauthorized access in FeedPage
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m38s
2025-09-14 21:30:12 +02:00
d6c42afaec fix: integrate js-cookie for install prompt dismissal handling
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m35s
2025-09-09 04:51:29 +02:00
e376f584c7 fix: update frontend API URL to use proxy for server-side requests
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 28s
2025-09-09 04:47:31 +02:00
75c5adf346 fix: reorganize Traefik labels and network configuration in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 29s
2025-09-09 04:45:24 +02:00
878ebf1541 fix: add Traefik network labels for API and web routers in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 17s
2025-09-09 04:43:58 +02:00
c9775293c0 fix: clean up commented-out network and labels configuration in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 19s
2025-09-09 04:39:35 +02:00
93b90b85b6 fix: adjust network configuration for backend and frontend services in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 18s
2025-09-09 04:33:53 +02:00
58e51cb028 fix: enhance Traefik routing for API and web services in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 17s
2025-09-09 04:27:02 +02:00
5282376860 fix: simplify CMD instruction in Dockerfile by removing redundant parameters
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m20s
2025-09-09 04:19:40 +02:00
082f11a3e9 fix: update Docker Compose deployment command and configure server to listen on all interfaces
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 2m0s
2025-09-09 04:13:51 +02:00
ec73a0c373 fix: update healthcheck command for frontend service and install curl in Dockerfile
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 1m50s
2025-09-09 04:09:14 +02:00
29afc2e92e fix: update Dockerfiles to install necessary packages without recommendations
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 3m4s
2025-09-09 04:03:14 +02:00
cbca1058a2 fix: add health checks for backend and frontend services in docker-compose
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 58s
2025-09-09 03:56:06 +02:00
8536e52590 Revert "fix: correct proxy_pass configuration for API requests in nginx"
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 6s
This reverts commit 247c6ad955.
2025-09-09 03:53:41 +02:00
247c6ad955 fix: correct proxy_pass configuration for API requests in nginx
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 6s
2025-09-09 03:51:37 +02:00
c6f7dfe225 feat: add health check endpoint to nginx configuration
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 7s
2025-09-09 03:49:19 +02:00
0ba3b79185 fix: remove default nginx configuration before copying custom config
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 7s
2025-09-09 03:47:24 +02:00
64806f8bd4 feat: implement pagination for feed retrieval and update frontend components
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 2m7s
2025-09-09 03:43:06 +02:00
4ea4f3149f feat: add user count endpoint and integrate it into frontend components
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 19s
2025-09-09 03:07:48 +02:00
d92c9a747e feat: implement pagination for user retrieval and update feed fetching logic
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 2m30s
2025-09-09 02:53:24 +02:00
863bc90c6f feat: add endpoint to retrieve a public list of all users
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m13s
2025-09-09 02:28:00 +02:00
d15339cf4a fix: remove debugging step that dumped POSTGRES_USER secret
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 17s
2025-09-09 02:14:45 +02:00
916dbe0245 feat: add step to dump POSTGRES_USER secret for debugging
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 17s
2025-09-09 02:09:58 +02:00
7889137cd8 fix: remove copying of .env.example to .env in Dockerfile
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m12s
2025-09-09 02:02:58 +02:00
4e38c1133e fix: remove debugging step that dumped environment variables
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 6s
2025-09-09 02:02:16 +02:00
86eb059f3e fix: update debugging step to display specific environment variables
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 5s
2025-09-09 02:00:35 +02:00
84f2423343 feat: add step to dump environment variables for debugging
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 6s
2025-09-09 01:59:50 +02:00
9207572f07 fix: remove redundant volume mapping for proxy service
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 5s
2025-09-09 01:44:01 +02:00
1c52bf3ea4 feat: update Docker setup to use custom proxy image and remove redundant steps
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 7s
2025-09-09 01:43:21 +02:00
327e671571 fix: update Nginx volume path to use GITHUB_WORKSPACE variable
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 5s
2025-09-09 01:40:26 +02:00
36e12d1d96 feat: add step to dump environment variables for debugging
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 6s
2025-09-09 01:39:59 +02:00
452ea5625f fix: update Nginx volume path to use GITEA_WORKSPACE variable
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 7s
2025-09-09 01:38:31 +02:00
bc8941d910 feat: add step to list files in workspace during deployment
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 6s
2025-09-09 01:33:22 +02:00
01d7a837f8 refactor: streamline Docker Compose configuration and remove unnecessary build steps
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 6s
2025-09-09 01:18:28 +02:00
71048f0060 feat: add Docker BuildKit environment variable for improved build performance
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 4s
2025-09-09 01:17:00 +02:00
f278a44d8f feat: add Docker version check step and fix DATABASE_URL formatting in production compose file
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 4s
2025-09-09 01:15:57 +02:00
aa4be7e05b feat: specify build targets for backend and frontend in Docker Compose
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 4s
2025-09-09 01:12:11 +02:00
5bc4337447 feat: update deployment workflow to use master branch and add production Docker Compose configuration
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 10s
2025-09-09 01:10:07 +02:00
b50b7bcc73 feat: add GitHub Actions workflow for building and deploying Thoughts 2025-09-09 01:07:59 +02:00
9b2a1139b5 feat: add author display name to thought schemas and update related components 2025-09-07 22:54:34 +02:00
2083f3bb16 feat: refactor author username assignment in ThoughtSchema 2025-09-07 22:37:12 +02:00
28 changed files with 895 additions and 59 deletions

View File

@@ -0,0 +1,41 @@
name: Build and Deploy Thoughts
on:
push:
branches:
- master
workflow_dispatch:
jobs:
build-and-deploy-local:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Create .env file
run: |
echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env
echo "AUTH_SECRET=${{ secrets.AUTH_SECRET }}" >> .env
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" >> .env
- name: Build Docker Images Manually
run: |
docker build --target runtime -t thoughts-backend:latest ./thoughts-backend
docker build --target release -t thoughts-frontend:latest --build-arg NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} ./thoughts-frontend
docker build -t custom-proxy:latest ./nginx
- name: Deploy with Docker Compose
run: |
docker compose -f compose.prod.yml down
POSTGRES_USER=${{ secrets.POSTGRES_USER }} \
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \
POSTGRES_DB=${{ secrets.POSTGRES_DB }} \
AUTH_SECRET=${{ secrets.AUTH_SECRET }} \
docker compose -f compose.prod.yml up -d
docker image prune -f

91
compose.prod.yml Normal file
View File

@@ -0,0 +1,91 @@
services:
database:
image: postgres:15-alpine
container_name: thoughts-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
backend:
container_name: thoughts-backend
image: thoughts-backend:latest
restart: unless-stopped
environment:
- RUST_LOG=info
- RUST_BACKTRACE=1
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB}
- HOST=0.0.0.0
- PORT=8000
- PREFORK=1
- AUTH_SECRET=${AUTH_SECRET}
- BASE_URL=https://thoughts.gabrielkaszewski.dev
depends_on:
database:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
frontend:
container_name: thoughts-frontend
image: thoughts-frontend:latest
restart: unless-stopped
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 10s
timeout: 5s
retries: 5
environment:
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
- PORT=3000
- HOSTNAME=0.0.0.0
networks:
- internal
proxy:
container_name: thoughts-proxy
image: custom-proxy:latest
restart: unless-stopped
depends_on:
frontend:
condition: service_healthy
backend:
condition: service_healthy
networks:
- internal
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.thoughts.rule=Host(`thoughts.gabrielkaszewski.dev`)"
- "traefik.http.routers.thoughts.entrypoints=web,websecure"
- "traefik.http.routers.thoughts.tls.certresolver=letsencrypt"
- "traefik.http.routers.thoughts.service=thoughts"
- "traefik.http.services.thoughts.loadbalancer.server.port=80"
volumes:
postgres_data:
driver: local
networks:
traefik:
name: traefik
external: true
internal:
driver: bridge

5
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM nginx:stable-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -10,6 +10,11 @@ server {
listen 80;
server_name localhost;
location /health {
return 200 "OK";
access_log off;
}
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;

View File

@@ -26,7 +26,7 @@ RUN cargo build --release --bin thoughts-backend
FROM debian:13-slim AS runtime
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 appgroup && \
useradd --system --uid 1001 --gid appgroup appuser
@@ -35,8 +35,6 @@ WORKDIR /app
COPY --from=builder /app/target/release/thoughts-backend .
COPY .env.example .env
RUN chown -R appuser:appgroup /app
USER appuser

View File

@@ -1,18 +1,27 @@
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
use axum::{
extract::{Query, State},
response::IntoResponse,
routing::get,
Json, Router,
};
use app::{
persistence::{follow::get_following_ids, thought::get_feed_for_user},
persistence::{follow::get_following_ids, thought::get_feed_for_users_and_self_paginated},
state::AppState,
};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
use models::{
queries::pagination::PaginationQuery,
schemas::{pagination::PaginatedResponse, thought::ThoughtSchema},
};
use crate::{error::ApiError, extractor::AuthUser};
#[utoipa::path(
get,
path = "",
params(PaginationQuery),
responses(
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
(status = 200, description = "Authenticated user's feed", body = PaginatedResponse<ThoughtSchema>)
),
security(
("api_key" = []),
@@ -22,21 +31,35 @@ use crate::{error::ApiError, extractor::AuthUser};
async fn feed_get(
State(state): State<AppState>,
auth_user: AuthUser,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> {
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
let mut thoughts_with_authors =
get_feed_for_user(&state.conn, following_ids, Some(auth_user.id)).await?;
let own_thoughts =
get_feed_for_user(&state.conn, vec![auth_user.id], Some(auth_user.id)).await?;
thoughts_with_authors.extend(own_thoughts);
let (thoughts_with_authors, total_items) = get_feed_for_users_and_self_paginated(
&state.conn,
auth_user.id,
following_ids,
&pagination,
)
.await?;
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter()
.map(ThoughtSchema::from)
.collect();
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
let page = pagination.page();
let page_size = pagination.page_size();
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
let response = PaginatedResponse {
items: thoughts_schema,
total_items,
total_pages,
page,
page_size,
};
Ok(Json(response))
}
pub fn create_feed_router() -> Router<AppState> {

View File

@@ -1,4 +1,4 @@
use axum::{extract::State, routing::get, Router};
use axum::{extract::State, http::StatusCode, routing::get, Router};
use sea_orm::{ConnectionTrait, Statement};
use app::state::AppState;
@@ -25,6 +25,12 @@ async fn root_get(state: State<AppState>) -> Result<String, ApiError> {
result.unwrap().try_get_by(0).map_err(|e| e.into())
}
pub fn create_root_router() -> Router<AppState> {
Router::new().route("/", get(root_get))
async fn health_check() -> StatusCode {
StatusCode::OK
}
pub fn create_root_router() -> Router<AppState> {
Router::new()
.route("/", get(root_get))
.route("/health", get(health_check))
}

View File

@@ -11,12 +11,20 @@ use serde_json::{json, Value};
use app::persistence::{
follow,
thought::get_thoughts_by_user,
user::{get_followers, get_following, get_user, search_users, update_user_profile},
user::{
get_all_users, get_followers, get_following, get_user, search_users, update_user_profile,
},
};
use app::state::AppState;
use app::{error::UserError, persistence::user::get_user_by_username};
use models::schemas::user::{MeSchema, UserListSchema, UserSchema};
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
use models::{
params::user::UpdateUserParams,
schemas::{pagination::PaginatedResponse, thought::ThoughtListSchema},
};
use models::{
queries::pagination::PaginationQuery,
schemas::user::{MeSchema, UserListSchema, UserSchema},
};
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
use crate::{error::ApiError, extractor::AuthUser};
@@ -413,9 +421,46 @@ async fn get_user_followers(
Ok(Json(UserListSchema::from(followers_list)))
}
#[utoipa::path(
get,
path = "/all",
params(PaginationQuery),
responses(
(status = 200, description = "A public, paginated list of all users", body = PaginatedResponse<UserSchema>)
),
tag = "user"
)]
async fn get_all_users_public(
State(state): State<AppState>,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> {
let (users, total_items) = get_all_users(&state.conn, &pagination).await?;
let page = pagination.page();
let page_size = pagination.page_size();
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
let response = PaginatedResponse {
items: users.into_iter().map(UserSchema::from).collect(),
page,
page_size,
total_pages,
total_items,
};
Ok(Json(response))
}
async fn get_all_users_count(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let count = app::persistence::user::get_all_users_count(&state.conn).await?;
Ok(Json(json!({ "count": count })))
}
pub fn create_user_router() -> Router<AppState> {
Router::new()
.route("/", get(users_get))
.route("/all", get(get_all_users_public))
.route("/count", get(get_all_users_count))
.route("/me", get(get_me).put(update_me))
.nest("/me/api-keys", create_api_key_router())
.route("/{param}", get(get_user_by_param))

View File

@@ -38,6 +38,7 @@ pub async fn search_thoughts(
// 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")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(Expr::cust_with_values(
"thought.search_document @@ websearch_to_tsquery('english', $1)",

View File

@@ -1,12 +1,13 @@
use sea_orm::{
prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr,
EntityTrait, JoinType, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
TransactionTrait,
EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
Set, TransactionTrait,
};
use models::{
domains::{tag, thought, thought_tag, user},
params::thought::CreateThoughtParams,
queries::pagination::PaginationQuery,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
};
@@ -102,6 +103,7 @@ pub async fn get_thoughts_by_user(
.column(thought::Column::CreatedAt)
.column(thought::Column::AuthorId)
.column(thought::Column::Visibility)
.column_as(user::Column::DisplayName, "author_display_name")
.column_as(user::Column::Username, "author_username")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(apply_visibility_filter(user_id, viewer_id, &friend_ids))
@@ -137,6 +139,7 @@ pub async fn get_feed_for_user(
.column(thought::Column::Visibility)
.column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(
Condition::any().add(following_ids.iter().fold(
@@ -154,6 +157,73 @@ pub async fn get_feed_for_user(
.map_err(|e| UserError::Internal(e.to_string()))
}
pub async fn get_feed_for_users_and_self(
db: &DbConn,
user_id: Uuid,
following_ids: Vec<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut authors_to_include = following_ids;
authors_to_include.push(user_id);
thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt)
.column(thought::Column::Visibility)
.column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(thought::Column::AuthorId.is_in(authors_to_include))
.filter(
Condition::any()
.add(thought::Column::Visibility.eq(thought::Visibility::Public))
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)),
)
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await
}
pub async fn get_feed_for_users_and_self_paginated(
db: &DbConn,
user_id: Uuid,
following_ids: Vec<Uuid>,
pagination: &PaginationQuery,
) -> Result<(Vec<ThoughtWithAuthor>, u64), DbErr> {
let mut authors_to_include = following_ids;
authors_to_include.push(user_id);
let paginator = thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt)
.column(thought::Column::Visibility)
.column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(thought::Column::AuthorId.is_in(authors_to_include))
.filter(
Condition::any()
.add(thought::Column::Visibility.eq(thought::Visibility::Public))
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)),
)
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.paginate(db, pagination.page_size());
let total_items = paginator.num_items().await?;
let thoughts = paginator.fetch_page(pagination.page() - 1).await?;
Ok((thoughts, total_items))
}
pub async fn get_thoughts_by_tag_name(
db: &DbConn,
tag_name: &str,
@@ -173,6 +243,7 @@ pub async fn get_thoughts_by_tag_name(
.column(thought::Column::AuthorId)
.column(thought::Column::Visibility)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def())
.join(JoinType::InnerJoin, thought_tag::Relation::Tag.def())
@@ -288,6 +359,7 @@ pub async fn get_thought_with_replies(
ThoughtThreadSchema {
id: thought_schema.id,
author_username: thought_schema.author_username.clone(),
author_display_name: thought_schema.author_display_name.clone(),
content: thought_schema.content.clone(),
visibility: thought_schema.visibility.clone(),
reply_to_id: thought_schema.reply_to_id,

View File

@@ -1,7 +1,8 @@
use models::queries::pagination::PaginationQuery;
use sea_orm::prelude::Uuid;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
QuerySelect, RelationTrait, Set, TransactionTrait,
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
};
use models::domains::{top_friends, user};
@@ -165,3 +166,21 @@ pub async fn get_followers(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model
}
get_users_by_ids(db, follower_ids).await
}
pub async fn get_all_users(
db: &DbConn,
pagination: &PaginationQuery,
) -> Result<(Vec<user::Model>, u64), DbErr> {
let paginator = user::Entity::find()
.order_by_desc(user::Column::CreatedAt)
.paginate(db, pagination.page_size());
let total_items = paginator.num_items().await?;
let users = paginator.fetch_page(pagination.page() - 1).await?;
Ok((users, total_items))
}
pub async fn get_all_users_count(db: &DbConn) -> Result<u64, DbErr> {
user::Entity::find().count(db).await
}

View File

@@ -1 +1,2 @@
pub mod pagination;
pub mod user;

View File

@@ -0,0 +1,27 @@
use serde::Deserialize;
use utoipa::IntoParams;
const DEFAULT_PAGE: u64 = 1;
const DEFAULT_PAGE_SIZE: u64 = 20;
#[derive(Deserialize, IntoParams)]
pub struct PaginationQuery {
#[param(nullable = true, example = 1)]
page: Option<u64>,
#[param(nullable = true, example = 20)]
page_size: Option<u64>,
}
impl PaginationQuery {
pub fn page(&self) -> u64 {
self.page.unwrap_or(DEFAULT_PAGE).max(1)
}
pub fn page_size(&self) -> u64 {
self.page_size.unwrap_or(DEFAULT_PAGE_SIZE).max(1)
}
pub fn offset(&self) -> u64 {
(self.page() - 1) * self.page_size()
}
}

View File

@@ -1,4 +1,5 @@
pub mod api_key;
pub mod pagination;
pub mod search;
pub mod thought;
pub mod user;

View File

@@ -0,0 +1,12 @@
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
pub total_items: u64,
}

View File

@@ -14,6 +14,7 @@ pub struct ThoughtSchema {
pub id: Uuid,
#[schema(example = "frutiger")]
pub author_username: String,
pub author_display_name: Option<String>,
#[schema(example = "This is my first thought! #welcome")]
pub content: String,
pub visibility: Visibility,
@@ -26,6 +27,7 @@ impl ThoughtSchema {
Self {
id: thought.id,
author_username: author.username.clone(),
author_display_name: author.display_name.clone(),
content: thought.content.clone(),
visibility: thought.visibility.clone(),
reply_to_id: thought.reply_to_id,
@@ -54,6 +56,7 @@ pub struct ThoughtWithAuthor {
pub visibility: Visibility,
pub author_id: Uuid,
pub author_username: String,
pub author_display_name: Option<String>,
pub reply_to_id: Option<Uuid>,
}
@@ -62,6 +65,7 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
Self {
id: model.id,
author_username: model.author_username,
author_display_name: model.author_display_name,
content: model.content,
created_at: model.created_at.into(),
reply_to_id: model.reply_to_id,
@@ -75,6 +79,7 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
pub struct ThoughtThreadSchema {
pub id: Uuid,
pub author_username: String,
pub author_display_name: Option<String>,
pub content: String,
pub visibility: Visibility,
pub reply_to_id: Option<Uuid>,

View File

@@ -1,7 +1,10 @@
use std::time::Duration;
use super::main::{create_user_with_password, setup};
use axum::http::StatusCode;
use http_body_util::BodyExt;
use serde_json::json;
use serde_json::{json, Value};
use tokio::time::sleep;
use utils::testing::make_jwt_request;
#[tokio::test]
@@ -59,9 +62,9 @@ async fn test_feed_and_user_thoughts() {
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
assert_eq!(v["thoughts"][0]["authorUsername"], "user1");
assert_eq!(v["thoughts"][0]["content"], "A thought from user1");
assert_eq!(v["items"].as_array().unwrap().len(), 1);
assert_eq!(v["items"][0]["authorUsername"], "user1");
assert_eq!(v["items"][0]["content"], "A thought from user1");
// 3. user1 follows user2
make_jwt_request(
@@ -78,9 +81,193 @@ async fn test_feed_and_user_thoughts() {
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["thoughts"].as_array().unwrap().len(), 2);
assert_eq!(v["thoughts"][0]["authorUsername"], "user2");
assert_eq!(v["thoughts"][0]["content"], "user2 was here");
assert_eq!(v["thoughts"][1]["authorUsername"], "user1");
assert_eq!(v["thoughts"][1]["content"], "A thought from user1");
assert_eq!(v["items"].as_array().unwrap().len(), 2);
assert_eq!(v["items"][0]["authorUsername"], "user2");
assert_eq!(v["items"][0]["content"], "user2 was here");
assert_eq!(v["items"][1]["authorUsername"], "user1");
assert_eq!(v["items"][1]["content"], "A thought from user1");
}
#[tokio::test]
async fn test_feed_strict_chronological_order() {
let app = setup().await;
create_user_with_password(&app.db, "user1", "password123", "u1@e.com").await;
create_user_with_password(&app.db, "user2", "password123", "u2@e.com").await;
create_user_with_password(&app.db, "user3", "password123", "u3@e.com").await;
let token1 = super::main::login_user(app.router.clone(), "user1", "password123").await;
let token2 = super::main::login_user(app.router.clone(), "user2", "password123").await;
let token3 = super::main::login_user(app.router.clone(), "user3", "password123").await;
make_jwt_request(
app.router.clone(),
"/users/user2/follow",
"POST",
None,
&token1,
)
.await;
make_jwt_request(
app.router.clone(),
"/users/user3/follow",
"POST",
None,
&token1,
)
.await;
let body_t1 = json!({ "content": "Thought 1 from user2" }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(body_t1),
&token2,
)
.await;
sleep(Duration::from_millis(10)).await;
let body_t2 = json!({ "content": "Thought 2 from user3" }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(body_t2),
&token3,
)
.await;
sleep(Duration::from_millis(10)).await;
let body_t3 = json!({ "content": "Thought 3 from user2" }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(body_t3),
&token2,
)
.await;
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token1).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let thoughts = v["items"].as_array().unwrap();
assert_eq!(
thoughts.len(),
3,
"Feed should contain 3 thoughts from followed users and self"
);
assert_eq!(thoughts[0]["content"], "Thought 3 from user2");
assert_eq!(thoughts[1]["content"], "Thought 2 from user3");
assert_eq!(thoughts[2]["content"], "Thought 1 from user2");
}
#[tokio::test]
async fn test_feed_pagination() {
let app = setup().await;
// 1. Setup users
create_user_with_password(&app.db, "user1", "password123", "u1@e.com").await;
create_user_with_password(&app.db, "user2", "password123", "u2@e.com").await;
create_user_with_password(&app.db, "user3", "password123", "u3@e.com").await;
let token1 = super::main::login_user(app.router.clone(), "user1", "password123").await;
let token2 = super::main::login_user(app.router.clone(), "user2", "password123").await;
let token3 = super::main::login_user(app.router.clone(), "user3", "password123").await;
// 2. user1 follows user2 and user3
make_jwt_request(
app.router.clone(),
"/users/user2/follow",
"POST",
None,
&token1,
)
.await;
make_jwt_request(
app.router.clone(),
"/users/user3/follow",
"POST",
None,
&token1,
)
.await;
// 3. Create 25 thoughts from the followed users to test pagination
// user1's feed also includes their own thoughts.
let mut last_thought_content = String::new();
for i in 0..25 {
let content = format!("Thought number {}", i);
// Alternate who posts to mix up the feed
let token_to_use = match i % 3 {
0 => &token2,
1 => &token3,
_ => &token1,
};
let body = json!({ "content": &content }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(body),
token_to_use,
)
.await;
if i == 24 {
last_thought_content = content;
}
// Small delay to ensure created_at timestamps are distinct
sleep(Duration::from_millis(5)).await;
}
// 4. Request the first page (default size 20)
let response_p1 = make_jwt_request(
app.router.clone(),
"/feed?page=1&page_size=20",
"GET",
None,
&token1,
)
.await;
assert_eq!(response_p1.status(), StatusCode::OK);
let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes();
let v_p1: Value = serde_json::from_slice(&body_p1).unwrap();
assert_eq!(
v_p1["items"].as_array().unwrap().len(),
20,
"First page should have 20 items"
);
assert_eq!(v_p1["page"], 1);
assert_eq!(v_p1["pageSize"], 20);
assert_eq!(v_p1["totalPages"], 2);
assert_eq!(v_p1["totalItems"], 25);
// Verify the newest thought is first on the first page
assert_eq!(v_p1["items"][0]["content"], last_thought_content);
// 5. Request the second page
let response_p2 = make_jwt_request(
app.router.clone(),
"/feed?page=2&page_size=20",
"GET",
None,
&token1,
)
.await;
assert_eq!(response_p2.status(), StatusCode::OK);
let body_p2 = response_p2.into_body().collect().await.unwrap().to_bytes();
let v_p2: Value = serde_json::from_slice(&body_p2).unwrap();
assert_eq!(
v_p2["items"].as_array().unwrap().len(),
5,
"Second page should have the remaining 5 items"
);
assert_eq!(v_p2["page"], 2);
}

View File

@@ -245,3 +245,68 @@ async fn test_update_me_css_and_images() {
assert_eq!(v["headerUrl"], "https://example.com/new-header.jpg");
assert_eq!(v["customCss"], "body { color: blue; }");
}
#[tokio::test]
async fn test_get_all_users_paginated() {
let app = setup().await;
for i in 0..25 {
create_user_with_password(
&app.db,
&format!("user{}", i),
"password123",
&format!("u{}@e.com", i),
)
.await;
}
let response_p1 = make_get_request(app.router.clone(), "/users/all", None).await;
assert_eq!(response_p1.status(), StatusCode::OK);
let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes();
let v_p1: Value = serde_json::from_slice(&body_p1).unwrap();
assert_eq!(
v_p1["items"].as_array().unwrap().len(),
20,
"First page should have 20 items"
);
assert_eq!(v_p1["page"], 1);
assert_eq!(v_p1["pageSize"], 20);
assert_eq!(v_p1["totalPages"], 2);
assert_eq!(v_p1["totalItems"], 25);
let response_p2 = make_get_request(app.router.clone(), "/users/all?page=2", None).await;
assert_eq!(response_p2.status(), StatusCode::OK);
let body_p2 = response_p2.into_body().collect().await.unwrap().to_bytes();
let v_p2: Value = serde_json::from_slice(&body_p2).unwrap();
assert_eq!(
v_p2["items"].as_array().unwrap().len(),
5,
"Second page should have 5 items"
);
assert_eq!(v_p2["page"], 2);
assert_eq!(v_p2["totalPages"], 2);
}
#[tokio::test]
async fn test_get_all_users_count() {
let app = setup().await;
for i in 0..25 {
create_user_with_password(
&app.db,
&format!("user{}", i),
"password123",
&format!("u{}@e.com", i),
)
.await;
}
let response = make_get_request(app.router.clone(), "/users/count", 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["count"], 25);
}

View File

@@ -17,11 +17,13 @@ RUN bun run build
FROM node:22-slim AS release
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -14,25 +14,51 @@ import { PopularTags } from "@/components/popular-tags";
import { ThoughtThread } from "@/components/thought-thread";
import { buildThoughtThreads } from "@/lib/utils";
import { TopFriends } from "@/components/top-friends";
import { UsersCount } from "@/components/users-count";
export default async function Home() {
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { redirect } from "next/navigation";
export default async function Home({
searchParams,
}: {
searchParams: { page?: string };
}) {
const token = (await cookies()).get("auth_token")?.value ?? null;
if (token) {
return <FeedPage token={token} />;
return <FeedPage token={token} searchParams={searchParams} />;
} else {
return <LandingPage />;
}
}
async function FeedPage({ token }: { token: string }) {
async function FeedPage({
token,
searchParams,
}: {
token: string;
searchParams: { page?: string };
}) {
const page = parseInt(searchParams.page ?? "1", 10);
const [feedData, me] = await Promise.all([
getFeed(token),
getFeed(token, page).catch(() => null),
getMe(token).catch(() => null) as Promise<Me | null>,
]);
const allThoughts = feedData.thoughts;
const thoughtThreads = buildThoughtThreads(feedData.thoughts);
if (!feedData || !me) {
redirect("/login");
}
const { items: allThoughts, totalPages } = feedData!;
const thoughtThreads = buildThoughtThreads(allThoughts);
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
const userProfiles = await Promise.all(
@@ -46,7 +72,10 @@ async function FeedPage({ token }: { token: string }) {
);
const friends = (await getFriends(token)).users.map((user) => user.username);
const shouldDisplayTopFriends = me?.topFriends && me.topFriends.length > 8;
const shouldDisplayTopFriends =
token && me?.topFriends && me.topFriends.length > 8;
console.log("Should display top friends:", shouldDisplayTopFriends);
return (
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
@@ -66,6 +95,13 @@ async function FeedPage({ token }: { token: string }) {
<div className="block lg:hidden space-y-6">
<PopularTags />
{shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={me.topFriends} />
)}
{!shouldDisplayTopFriends && token && friends.length > 0 && (
<TopFriends mode="friends" usernames={friends || []} />
)}
<UsersCount />
</div>
<div className="space-y-6">
@@ -83,15 +119,34 @@ async function FeedPage({ token }: { token: string }) {
</p>
)}
</div>
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={page > 1 ? `/?page=${page - 1}` : "#"}
aria-disabled={page <= 1}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
href={page < totalPages ? `/?page=${page + 1}` : "#"}
aria-disabled={page >= totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</main>
<aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6">
<PopularTags />
{shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={me.topFriends} />
)}
<PopularTags />
{token && <TopFriends mode="friends" usernames={friends || []} />}
{!shouldDisplayTopFriends && token && friends.length > 0 && (
<TopFriends mode="friends" usernames={friends || []} />
)}
<UsersCount />
</div>
</aside>
</div>

View File

@@ -0,0 +1,63 @@
import { getAllUsers } from "@/lib/api";
import { UserListCard } from "@/components/user-list-card";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
export default async function AllUsersPage({
searchParams,
}: {
searchParams: { page?: string };
}) {
const page = parseInt(searchParams.page ?? "1", 10);
const usersData = await getAllUsers(page).catch(() => null);
if (!usersData) {
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
<h1 className="text-3xl font-bold my-6">All Users</h1>
<p className="text-muted-foreground">
Could not load users. Please try again later.
</p>
</div>
);
}
const { items, totalPages } = usersData;
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6 glass-effect glossy-effect bottom gloss-highlight rounded-md p-4 text-shadow-md">
<h1 className="text-3xl font-bold">All Users</h1>
<p className="text-muted-foreground">
Discover other users on Thoughts.
</p>
</header>
<main>
<UserListCard users={items} />
{totalPages > 1 && (
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={page > 1 ? `/users/all?page=${page - 1}` : "#"}
aria-disabled={page <= 1}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
href={page < totalPages ? `/users/all?page=${page + 1}` : "#"}
aria-disabled={page >= totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</main>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
CardContent,
CardAction,
} from "@/components/ui/card";
import Cookies from "js-cookie";
interface CustomWindow extends Window {
MSStream?: unknown;
@@ -26,15 +27,20 @@ export default function InstallPrompt() {
const [isStandalone, setIsStandalone] = useState(false);
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
// Cast window to our custom type instead of 'any'
const customWindow = window as CustomWindow;
setIsIOS(
/iPad|iPhone|iPod/.test(navigator.userAgent) && !customWindow.MSStream
);
setIsStandalone(window.matchMedia("(display-mode: standalone)").matches);
const dismissed = Cookies.get("install_prompt_dismissed");
if (dismissed) {
setIsDismissed(true);
}
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
@@ -58,11 +64,17 @@ export default function InstallPrompt() {
console.log("User accepted the install prompt");
} else {
console.log("User dismissed the install prompt");
Cookies.set("install_prompt_dismissed", "true", { expires: 7 });
}
setDeferredPrompt(null);
};
if (isStandalone || (!isIOS && !deferredPrompt)) {
const handleCloseClick = () => {
setIsStandalone(true);
Cookies.set("install_prompt_dismissed", "true", { expires: 7 });
};
if (isStandalone || (!isIOS && !deferredPrompt) || isDismissed) {
return null;
}
@@ -79,7 +91,7 @@ export default function InstallPrompt() {
size="sm"
variant="ghost"
className="absolute top-2 right-2"
onClick={() => setIsStandalone(true)}
onClick={handleCloseClick}
>
&times;
</Button>

View File

@@ -10,13 +10,13 @@ export function MainNav() {
return (
<nav className="inline-flex md:flex items-center space-x-6 text-sm font-medium">
<Link
href="/"
href="/users/all"
className={cn(
"transition-colors hover:text-foreground/80",
pathname === "/" ? "text-foreground" : "text-foreground/60"
pathname === "/users/all" ? "text-foreground" : "text-foreground/60"
)}
>
Feed
Discover
</Link>
<SearchInput />
</nav>

View File

@@ -44,6 +44,7 @@ interface ThoughtCardProps {
thought: Thought;
author: {
username: string;
displayName?: string | null;
avatarUrl?: string | null;
};
currentUser: Me | null;
@@ -112,9 +113,14 @@ export function ThoughtCard({
href={`/users/${author.username}`}
className="flex items-center gap-4 text-shadow-md"
>
<UserAvatar src={author.avatarUrl} alt={author.username} />
<UserAvatar
src={author.avatarUrl}
alt={author.displayName || author.username}
/>
<div className="flex flex-col">
<span className="font-bold">{author.username}</span>
<span className="font-bold">
{author.displayName || author.username}
</span>
<span className="text-sm text-muted-foreground text-shadow-sm">
{timeAgo}
</span>

View File

@@ -28,7 +28,7 @@ export function ThoughtList({
{thoughts.map((thought) => {
const author = {
username: thought.authorUsername,
avatarUrl: null,
displayName: thought.authorDisplayName,
...authorDetails.get(thought.authorUsername),
};
return (

View File

@@ -16,7 +16,7 @@ export function ThoughtThread({
}: ThoughtThreadProps) {
const author = {
username: thought.authorUsername,
avatarUrl: null,
displayName: thought.authorDisplayName,
...authorDetails.get(thought.authorUsername),
};
@@ -35,7 +35,7 @@ export function ThoughtThread({
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
key={reply.id}
thought={reply}
authorDetails={authorDetails}

View File

@@ -0,0 +1,69 @@
import { Link } from "lucide-react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardDescription,
} from "@/components/ui/card";
import { getAllUsersCount } from "@/lib/api";
export async function UsersCount() {
const usersCount = await getAllUsersCount().catch(() => null);
if (usersCount === null) {
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
<CardDescription>
Total number of registered users on Thoughts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm text-center py-4">
Could not load users count.
</div>
</CardContent>
</Card>
);
}
if (usersCount.count === 0) {
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
<CardDescription>
Total number of registered users on Thoughts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm text-center py-4">
No registered users yet. Be the first to{" "}
<Link href="/signup" className="text-primary hover:underline">
sign up
</Link>
!
</div>
</CardContent>
</Card>
);
}
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
<CardDescription>
Total number of registered users on Thoughts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm text-center py-4">
{usersCount.count} registered users.
</div>
</CardContent>
</Card>
);
}

View File

@@ -28,6 +28,7 @@ export const MeSchema = z.object({
export const ThoughtSchema = z.object({
id: z.uuid(),
authorUsername: z.string(),
authorDisplayName: z.string().nullable(),
content: z.string(),
visibility: z.enum(["Public", "FriendsOnly", "Private"]),
replyToId: z.uuid().nullable(),
@@ -87,6 +88,7 @@ export const CreateApiKeySchema = z.object({
export const ThoughtThreadSchema: z.ZodType<{
id: string;
authorUsername: string;
authorDisplayName: string | null;
content: string;
visibility: "Public" | "FriendsOnly" | "Private";
replyToId: string | null;
@@ -95,6 +97,7 @@ export const ThoughtThreadSchema: z.ZodType<{
}> = z.object({
id: z.uuid(),
authorUsername: z.string(),
authorDisplayName: z.string().nullable(),
content: z.string(),
visibility: z.enum(["Public", "FriendsOnly", "Private"]),
replyToId: z.uuid().nullable(),
@@ -165,15 +168,14 @@ export const loginUser = (data: z.infer<typeof LoginSchema>) =>
body: JSON.stringify(data),
}, z.object({ token: z.string() }));
export const getFeed = (token: string) =>
export const getFeed = (token: string, page: number = 1, pageSize: number = 20) =>
apiFetch(
"/feed",
`/feed?page=${page}&page_size=${pageSize}`,
{},
z.object({ thoughts: z.array(ThoughtSchema) }),
z.object({ items: z.array(ThoughtSchema), totalPages: z.number() }),
token
);
// --- User API Functions ---
export const getUserProfile = (username: string, token: string | null) =>
apiFetch(`/users/${username}`, {}, UserSchema, token);
@@ -324,4 +326,27 @@ export const deleteApiKey = (keyId: string, token: string) =>
);
export const getThoughtThread = (thoughtId: string, token: string | null) =>
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
apiFetch(
`/users/all?page=${page}&page_size=${pageSize}`,
{},
z.object({
items: z.array(UserSchema),
page: z.number(),
pageSize: z.number(),
totalPages: z.number(),
totalItems: z.number(),
})
);
export const getAllUsersCount = () =>
apiFetch(
`/users/count`,
{},
z.object({
count: z.number(),
})
);