Compare commits

...

60 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
08213133be feat: update environment configuration, enhance Dockerfiles, and refactor API handling 2025-09-07 19:55:49 +02:00
5f8cf49ec9 feat: simplify error handling in login and registration pages, add install prompt component, and update favicon and icons 2025-09-07 18:43:56 +02:00
c6f5bab1eb feat: update background image format and remove unused SVG files 2025-09-07 18:11:53 +02:00
72b4cb0851 feat: add confetti animation on thought submission and update dependencies 2025-09-07 17:43:17 +02:00
dd279a1434 feat: add popular tags section to FeedPage and update LandingPage text 2025-09-07 17:36:32 +02:00
6efab333f3 Remove federation functionality and related tests
- Deleted the `federation.rs` module and its associated functionality for federating thoughts to followers.
- Removed the `well_known.rs` module and its WebFinger discovery functionality.
- Eliminated references to federation in the `thought.rs` router and removed the spawning of background tasks for federating thoughts.
- Deleted tests related to WebFinger and user inbox interactions in `activitypub.rs`.
- Updated `Cargo.toml` to remove the `activitypub_federation` dependency.
2025-09-07 17:22:58 +02:00
1a405500ca feat: update top friends display condition to require more than 8 friends 2025-09-07 15:16:18 +02:00
3d25ffca4f feat: add visibility check for tagging in thought creation 2025-09-07 15:15:24 +02:00
5ce6d9f2da feat: refactor thought threads handling to improve structure and efficiency 2025-09-07 15:09:45 +02:00
40695b7ad3 feat: implement thought thread retrieval with replies and visibility filtering 2025-09-07 14:47:30 +02:00
b337184a59 feat: add API keys management page, including API key creation and deletion functionality 2025-09-07 14:06:28 +02:00
862974bb35 feat: update ApiKeySchema and ApiKeyListSchema with proper serde renaming for keyPrefix and createdAt 2025-09-07 13:48:20 +02:00
8b14ab06a2 feat: update bio length validation in UpdateUserParams to allow up to 4000 characters 2025-09-07 13:37:46 +02:00
e1b5a2aaa0 feat: enhance profile and feed pages with friends display logic, update TopFriends component to support mode, and extend bio length in profile schema 2025-09-07 13:37:39 +02:00
c9b8bd7b07 feat: implement search functionality with results display, add search input component, and update API for search results 2025-09-07 12:54:39 +02:00
69eb225c1e feat: implement full-text search functionality with API integration, add search router and persistence logic, and create related schemas and tests 2025-09-07 12:36:03 +02:00
c3539cfc11 feat: add Frutiger font, enhance UI with glass effect and shadows, and improve component styling 2025-09-07 01:12:09 +02:00
f1e891413a feat: enhance user interface with improved styling and responsiveness
- Updated UserAvatar component to accept additional className for better customization.
- Refined ProfilePage layout with responsive avatar styling.
- Enhanced Header component with improved background and text styles.
- Improved PopularTags and TopFriends components with better spacing and text shadows.
- Updated ThoughtCard and ThoughtThread components for better visual hierarchy and responsiveness.
- Enhanced UI components (Button, Badge, Card, DropdownMenu, Input, Popover, Separator, Skeleton, Textarea) with new styles and effects.
- Added a new background image for visual enhancement.
2025-09-07 00:16:51 +02:00
125 changed files with 3129 additions and 1588 deletions

7
.env
View File

@@ -1,3 +1,10 @@
POSTGRES_USER=thoughts_user
POSTGRES_PASSWORD=postgres
POSTGRES_DB=thoughts_db
HOST=0.0.0.0
PORT=8000
DATABASE_URL="postgresql://thoughts_user:postgres@database/thoughts_db"
PREFORK=1
AUTH_SECRET=secret
BASE_URL=http://0.0.0.0

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

View File

@@ -25,6 +25,9 @@ services:
restart: unless-stopped
env_file:
- .env
environment:
- RUST_LOG=info
- RUST_BACKTRACE=1
depends_on:
database:
condition: service_healthy
@@ -34,9 +37,13 @@ services:
build:
context: ./thoughts-frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://localhost/api
restart: unless-stopped
depends_on:
- backend
environment:
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
proxy:
container_name: thoughts-proxy

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

@@ -5,3 +5,4 @@ DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts"
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
PREFORK=0
AUTH_SECRET=your_secret_key_here
BASE_URL=http://0.0.0.0

View File

@@ -1,7 +1,6 @@
HOST=0.0.0.0
PORT=3000
DATABASE_URL="sqlite://dev.db"
# DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
PREFORK=1
AUTH_SECRET=your_secret_key_here
BASE_URL=http://localhost:3000

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,14 @@
FROM rust:1.89-slim AS builder
RUN apt-get update && apt-get install -y libssl-dev pkg-config && rm -rf /var/lib/apt/lists/*
RUN cargo install cargo-chef --locked
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY api/Cargo.toml ./api/
COPY app/Cargo.toml ./app/
COPY common/Cargo.toml ./common/
COPY doc/Cargo.toml ./doc/
COPY migration/Cargo.toml ./migration/
COPY models/Cargo.toml ./models/
@@ -22,6 +26,8 @@ RUN cargo build --release --bin thoughts-backend
FROM debian:13-slim AS runtime
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
@@ -29,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

@@ -38,6 +38,5 @@ tower-http = { version = "0.6.6", features = ["fs", "cors"] }
tower-cookies = "0.11.0"
anyhow = "1.0.98"
dotenvy = "0.15.7"
activitypub_federation = "0.6.5"
url = "2.5.7"
[dev-dependencies]

View File

@@ -1,70 +0,0 @@
use app::{
persistence::{follow, user},
state::AppState,
};
use models::domains::thought;
use serde_json::json;
// This function handles pushing a new thought to all followers.
pub async fn federate_thought(
state: AppState,
thought: thought::Model,
author: models::domains::user::Model,
) {
// Find all followers of the author
let follower_ids = match follow::get_follower_ids(&state.conn, author.id).await {
Ok(ids) => ids,
Err(e) => {
tracing::error!("Failed to get followers for federation: {}", e);
return;
}
};
if follower_ids.is_empty() {
println!("No followers to federate to for user {}", author.username);
return;
}
let thought_url = format!("{}/thoughts/{}", &state.base_url, thought.id);
let author_url = format!("{}/users/{}", &state.base_url, author.username);
// Construct the "Create" activity containing the "Note" object
let activity = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activity", thought_url),
"type": "Create",
"actor": author_url,
"object": {
"id": thought_url,
"type": "Note",
"attributedTo": author_url,
"content": thought.content,
"published": thought.created_at.to_rfc3339(),
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": [format!("{}/followers", author_url)]
}
});
// Get the inbox URLs for all followers
// In a real federated app, you would store remote users' full inbox URLs.
// For now, we assume followers are local and construct their inbox URLs.
let followers = match user::get_users_by_ids(&state.conn, follower_ids).await {
Ok(users) => users,
Err(e) => {
tracing::error!("Failed to get follower user objects: {}", e);
return;
}
};
let client = reqwest::Client::new();
for follower in followers {
let inbox_url = format!("{}/users/{}/inbox", &state.base_url, follower.username);
tracing::info!("Federating post {} to {}", thought.id, inbox_url);
let res = client.post(&inbox_url).json(&activity).send().await;
if let Err(e) = res {
tracing::error!("Failed to federate to {}: {}", inbox_url, e);
}
}
}

View File

@@ -8,7 +8,6 @@ use app::state::AppState;
use crate::routers::create_router;
// TODO: middleware, logging, authentication
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
create_router(AppState {
conn,

View File

@@ -1,6 +1,5 @@
mod error;
mod extractor;
mod federation;
mod init;
mod validation;

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

@@ -5,12 +5,12 @@ pub mod auth;
pub mod feed;
pub mod friends;
pub mod root;
pub mod search;
pub mod tag;
pub mod thought;
pub mod user;
pub mod well_known;
use crate::routers::{auth::create_auth_router, well_known::create_well_known_router};
use crate::routers::auth::create_auth_router;
use app::state::AppState;
use root::create_root_router;
use tower_http::cors::CorsLayer;
@@ -23,13 +23,13 @@ pub fn create_router(state: AppState) -> Router {
Router::new()
.merge(create_root_router())
.nest("/.well-known", create_well_known_router())
.nest("/auth", create_auth_router())
.nest("/users", create_user_router())
.nest("/thoughts", create_thought_router())
.nest("/feed", create_feed_router())
.nest("/tags", tag::create_tag_router())
.nest("/friends", friends::create_friends_router())
.nest("/search", search::create_search_router())
.with_state(state)
.layer(cors)
}

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

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

View File

@@ -11,13 +11,15 @@ use app::{
persistence::thought::{create_thought, delete_thought, get_thought},
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 crate::{
error::ApiError,
extractor::{AuthUser, Json, OptionalAuthUser, Valid},
federation,
models::{ApiErrorResponse, ParamsErrorResponse},
};
@@ -74,13 +76,6 @@ async fn thoughts_post(
.await?
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid
// Spawn a background task to handle federation without blocking the response
tokio::spawn(federation::federate_thought(
state.clone(),
thought.clone(),
author.clone(),
));
let schema = ThoughtSchema::from_models(&thought, &author);
Ok((StatusCode::CREATED, Json(schema)))
}
@@ -118,8 +113,33 @@ async fn thoughts_delete(
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> {
Router::new()
.route("/", post(thoughts_post))
.route("/{id}/thread", get(get_thought_thread))
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
}

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};
@@ -345,10 +353,17 @@ async fn get_me(
let following = get_following(&state.conn, auth_user.id).await?;
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(),
};
Ok(axum::Json(response))
}
@@ -406,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

@@ -1,70 +0,0 @@
use app::state::AppState;
use axum::{
extract::{Query, State},
response::{IntoResponse, Json},
};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Deserialize)]
pub struct WebFingerQuery {
resource: String,
}
#[derive(Serialize)]
pub struct WebFingerLink {
rel: String,
#[serde(rename = "type")]
type_: String,
href: Url,
}
#[derive(Serialize)]
pub struct WebFingerResponse {
subject: String,
links: Vec<WebFingerLink>,
}
pub async fn webfinger(
State(state): State<AppState>,
Query(query): Query<WebFingerQuery>,
) -> Result<impl IntoResponse, impl IntoResponse> {
if let Some((scheme, account_info)) = query.resource.split_once(':') {
if scheme != "acct" {
return Err((
axum::http::StatusCode::BAD_REQUEST,
"Invalid resource scheme",
));
}
let account_parts: Vec<&str> = account_info.split('@').collect();
let username = account_parts[0];
let user = match app::persistence::user::get_user_by_username(&state.conn, username).await {
Ok(Some(user)) => user,
_ => return Err((axum::http::StatusCode::NOT_FOUND, "User not found")),
};
let user_url = Url::parse(&format!("{}/users/{}", &state.base_url, user.username)).unwrap();
let response = WebFingerResponse {
subject: query.resource,
links: vec![WebFingerLink {
rel: "self".to_string(),
type_: "application/activity+json".to_string(),
href: user_url,
}],
};
Ok(Json(response))
} else {
Err((
axum::http::StatusCode::BAD_REQUEST,
"Invalid resource format",
))
}
}
pub fn create_well_known_router() -> axum::Router<AppState> {
axum::Router::new().route("/webfinger", axum::routing::get(webfinger))
}

View File

@@ -1,6 +1,7 @@
pub mod api_key;
pub mod auth;
pub mod follow;
pub mod search;
pub mod tag;
pub mod thought;
pub mod user;

View File

@@ -0,0 +1,66 @@
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")
.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)",
[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
}

View File

@@ -106,6 +106,7 @@ where
thought_tag::Relation::Thought.def(),
)
.filter(thought::Column::CreatedAt.gte(seven_days_ago))
.filter(thought::Column::Visibility.eq(thought::Visibility::Public))
.group_by(tag::Column::Name)
.group_by(tag::Column::Id)
.order_by_desc(Expr::col(Alias::new("count")))

View File

@@ -1,13 +1,14 @@
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,
schemas::thought::ThoughtWithAuthor,
queries::pagination::PaginationQuery,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
};
use crate::{
@@ -35,11 +36,13 @@ pub async fn create_thought(
.insert(&txn)
.await?;
if new_thought.visibility == thought::Visibility::Public {
let tag_names = parse_hashtags(&params.content);
if !tag_names.is_empty() {
let tags = find_or_create_tags(&txn, tag_names).await?;
link_tags_to_thought(&txn, new_thought.id, tags).await?;
}
}
txn.commit().await?;
Ok(new_thought)
@@ -100,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))
@@ -135,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(
@@ -152,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,
@@ -171,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())
@@ -201,7 +274,7 @@ pub async fn get_thoughts_by_tag_name(
Ok(visible_thoughts)
}
fn apply_visibility_filter(
pub fn apply_visibility_filter(
user_id: Uuid,
viewer_id: Option<Uuid>,
friend_ids: &[Uuid],
@@ -210,17 +283,104 @@ fn apply_visibility_filter(
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
if let Some(viewer) = viewer_id {
// Viewers can see their own thoughts of any visibility
if user_id == viewer {
condition = condition
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
}
// 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) {
} else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
condition =
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
}
}
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(),
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,
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))
}

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

@@ -3,6 +3,10 @@ name = "common"
version = "0.1.0"
edition = "2021"
[lib]
name = "common"
path = "src/lib.rs"
[dependencies]
serde = { workspace = true }
utoipa = { workspace = true }

View File

@@ -5,7 +5,7 @@ use sea_query::ValueTypeErr;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema, Debug)]
#[derive(Serialize, ToSchema, Debug, Clone)]
#[schema(example = "2025-09-05T12:34:56Z")]
pub struct DateTimeWithTimeZoneWrapper(String);

View File

@@ -10,6 +10,7 @@ path = "src/lib.rs"
[dependencies]
axum = { workspace = true }
tracing = { workspace = true }
utoipa = { workspace = true, features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = [
"axum",
@@ -19,5 +20,5 @@ utoipa-scalar = { version = "0.3.0", features = [
"axum",
], default-features = false }
api = { path = "../api" }
# api = { path = "../api" }
models = { path = "../models" }

View File

@@ -6,27 +6,8 @@ use utoipa::{
use utoipa_scalar::{Scalar, Servable as ScalarServable};
use utoipa_swagger_ui::SwaggerUi;
mod api_key;
mod auth;
mod feed;
mod friends;
mod root;
mod tag;
mod thought;
mod user;
#[derive(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(
(name = "root", description = "Root API"),
(name = "auth", description = "Authentication API"),
@@ -35,6 +16,7 @@ mod user;
(name = "feed", description = "Feed API"),
(name = "tag", description = "Tag Discovery API"),
(name = "friends", description = "Friends API"),
(name = "search", description = "Search API"),
),
modifiers(&SecurityAddon),
)]
@@ -55,12 +37,14 @@ impl Modify for SecurityAddon {
}
}
pub trait ApiDoc {
pub trait ApiDocExt {
fn attach_doc(self) -> Self;
}
impl ApiDoc for Router {
impl ApiDocExt for Router {
fn attach_doc(self) -> Self {
tracing::info!("Attaching API documentation");
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
}

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

View File

@@ -2,15 +2,19 @@ use api::{
models::{ApiErrorResponse, ParamsErrorResponse},
routers::thought::*,
};
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
use models::{
params::thought::CreateThoughtParams,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
};
use utoipa::OpenApi;
#[derive(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(
CreateThoughtParams,
ThoughtSchema,
ThoughtThreadSchema,
ApiErrorResponse,
ParamsErrorResponse
))

View File

@@ -12,7 +12,4 @@ path = "src/lib.rs"
models = { path = "../models" }
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
sea-orm-migration = { version = "1.1.12", features = [
"sqlx-sqlite",
"sqlx-postgres",
] }
sea-orm-migration = { version = "1.1.12", features = ["sqlx-postgres"] }

View File

@@ -7,6 +7,7 @@ mod m20250906_130237_add_tags;
mod m20250906_134056_add_api_keys;
mod m20250906_145148_add_reply_to_thoughts;
mod m20250906_145755_add_visibility_to_thoughts;
mod m20250906_231359_add_full_text_search;
pub struct Migrator;
@@ -21,6 +22,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250906_134056_add_api_keys::Migration),
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
Box::new(m20250906_231359_add_full_text_search::Migration),
]
}
}

View File

@@ -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(())
}
}

View File

@@ -13,7 +13,6 @@ serde = { workspace = true }
serde_json = { workspace = true }
sea-orm = { workspace = true, features = [
"sqlx-postgres",
"sqlx-sqlite",
"runtime-tokio-rustls",
"macros",
] }

View File

@@ -25,6 +25,8 @@ pub struct Model {
pub reply_to_id: Option<Uuid>,
pub visibility: Visibility,
pub created_at: DateTimeWithTimeZone,
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
pub search_document: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -19,6 +19,8 @@ pub struct Model {
pub custom_css: Option<String>,
pub created_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)]

View File

@@ -17,7 +17,7 @@ pub struct UpdateUserParams {
#[serde(rename = "displayName")]
pub display_name: Option<String>,
#[validate(length(max = 160))]
#[validate(length(max = 4000))]
#[schema(example = "Est. 2004")]
pub bio: Option<String>,

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

@@ -8,7 +8,9 @@ use uuid::Uuid;
pub struct ApiKeySchema {
pub id: Uuid,
pub name: String,
#[serde(rename = "keyPrefix")]
pub key_prefix: String,
#[serde(rename = "createdAt")]
pub created_at: DateTimeWithTimeZoneWrapper,
}
@@ -16,7 +18,6 @@ pub struct ApiKeySchema {
pub struct ApiKeyResponse {
#[serde(flatten)]
pub key: ApiKeySchema,
/// The full plaintext API key. This is only returned on creation.
#[serde(skip_serializing_if = "Option::is_none", rename = "plaintextKey")]
pub plaintext_key: Option<String>,
}
@@ -37,6 +38,7 @@ impl ApiKeyResponse {
#[derive(Serialize, ToSchema)]
pub struct ApiKeyListSchema {
#[serde(rename = "apiKeys")]
pub api_keys: Vec<ApiKeySchema>,
}

View File

@@ -1,3 +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

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

View File

@@ -8,18 +8,17 @@ use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Serialize, ToSchema, FromQueryResult, Debug)]
#[derive(Serialize, ToSchema, FromQueryResult, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtSchema {
pub id: Uuid,
#[schema(example = "frutiger")]
#[serde(rename = "authorUsername")]
pub author_username: String,
pub author_display_name: Option<String>,
#[schema(example = "This is my first thought! #welcome")]
pub content: String,
pub visibility: Visibility,
#[serde(rename = "replyToId")]
pub reply_to_id: Option<Uuid>,
#[serde(rename = "createdAt")]
pub created_at: DateTimeWithTimeZoneWrapper,
}
@@ -28,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,
@@ -37,6 +37,7 @@ impl ThoughtSchema {
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtListSchema {
pub thoughts: Vec<ThoughtSchema>,
}
@@ -55,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>,
}
@@ -63,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,
@@ -70,3 +73,16 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
}
}
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
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>,
pub created_at: DateTimeWithTimeZoneWrapper,
pub replies: Vec<ThoughtThreadSchema>,
}

View File

@@ -6,21 +6,16 @@ use uuid::Uuid;
use crate::domains::user;
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UserSchema {
pub id: Uuid,
pub username: String,
#[serde(rename = "displayName")]
pub display_name: Option<String>,
pub bio: Option<String>,
#[serde(rename = "avatarUrl")]
pub avatar_url: Option<String>,
#[serde(rename = "headerUrl")]
pub header_url: Option<String>,
#[serde(rename = "customCss")]
pub custom_css: Option<String>,
#[serde(rename = "topFriends")]
pub top_friends: Vec<String>,
#[serde(rename = "joinedAt")]
pub joined_at: DateTimeWithTimeZoneWrapper,
}
@@ -50,7 +45,7 @@ impl From<user::Model> for UserSchema {
avatar_url: user.avatar_url,
header_url: user.header_url,
custom_css: user.custom_css,
top_friends: vec![], // Defaults to an empty list
top_friends: vec![],
joined_at: user.created_at.into(),
}
}
@@ -70,8 +65,16 @@ impl From<Vec<user::Model>> for UserListSchema {
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MeSchema {
#[serde(flatten)]
pub user: UserSchema,
pub id: Uuid,
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>,
}

View File

@@ -1,5 +1,5 @@
use api::{setup_db, setup_router};
use doc::ApiDoc;
use doc::ApiDocExt;
use utils::migrate;
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {

View File

@@ -1,10 +1,7 @@
use api::{setup_config, setup_db, setup_router};
use doc::ApiDoc;
use utils::{create_dev_db, migrate};
use utils::migrate;
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;
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 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");
axum::serve(listener, router).await.expect("start server");
@@ -22,7 +19,6 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
#[cfg(feature = "prefork")]
fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
let db_url: &'static str = Box::leak(db_url.to_owned().into_boxed_str());
create_dev_db(db_url);
let num_of_cores = std::thread::available_parallelism().unwrap().get() as u32;
let is_parent = prefork::Prefork::from_resource(listener)
@@ -37,18 +33,17 @@ fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
}
fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) {
create_dev_db(db_url);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(worker(0, db_url, false, listener));
}
pub fn run() {
tracing::info!("Starting server...");
let config = setup_config();
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
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")]
if config.prefork {

View File

@@ -1,151 +0,0 @@
use crate::api::main::{create_user_with_password, setup};
use axum::http::{header, StatusCode};
use http_body_util::BodyExt;
use serde_json::{json, Value};
use utils::testing::{
make_get_request, make_jwt_request, make_post_request, make_request_with_headers,
};
#[tokio::test]
async fn test_webfinger_discovery() {
let app = setup().await;
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
// 1. Valid WebFinger lookup for existing user
let url = "/.well-known/webfinger?resource=acct:testuser@localhost:3000";
let response = make_get_request(app.router.clone(), url, 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["subject"], "acct:testuser@localhost:3000");
assert_eq!(
v["links"][0]["href"],
"http://localhost:3000/users/testuser"
);
// 2. WebFinger lookup for a non-existent user
let response = make_get_request(
app.router.clone(),
"/.well-known/webfinger?resource=acct:nobody@localhost:3000",
None,
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_user_actor_endpoint() {
let app = setup().await;
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
let response = make_request_with_headers(
app.router.clone(),
"/users/testuser",
"GET",
None,
vec![(
header::ACCEPT,
"application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
)],
).await;
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get(header::CONTENT_TYPE).unwrap();
assert_eq!(content_type, "application/activity+json");
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["type"], "Person");
assert_eq!(v["preferredUsername"], "testuser");
assert_eq!(v["id"], "http://localhost:3000/users/testuser");
}
#[tokio::test]
async fn test_user_inbox_follow() {
let app = setup().await;
// user1 will be followed
let user1 =
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
// user2 will be the follower
let user2 =
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
// Construct a follow activity from user2, targeting user1
let follow_activity = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://localhost:3000/some-unique-id",
"type": "Follow",
"actor": "http://localhost:3000/users/user2", // The actor is user2
"object": "http://localhost:3000/users/user1"
})
.to_string();
// POST the activity to user1's inbox
let response = make_post_request(
app.router.clone(),
"/users/user1/inbox",
follow_activity,
None,
)
.await;
assert_eq!(response.status(), StatusCode::ACCEPTED);
// Verify that user2 is now following user1 in the database
let followers = app::persistence::follow::get_following_ids(&app.db, user2.id)
.await
.unwrap();
assert!(
followers.contains(&user1.id),
"User2 should be following user1"
);
let following = app::persistence::follow::get_following_ids(&app.db, user1.id)
.await
.unwrap();
assert!(
!following.contains(&user2.id),
"User1 should now be followed by user2"
);
assert!(following.is_empty(), "User1 should not be following anyone");
}
#[tokio::test]
async fn test_user_outbox_get() {
let app = setup().await;
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
let token = super::main::login_user(app.router.clone(), "testuser", "password123").await;
// Create a thought first
let thought_body = json!({ "content": "This is a federated thought!" }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(thought_body),
&token,
)
.await;
// Now, fetch the outbox
let response = make_request_with_headers(
app.router.clone(),
"/users/testuser/outbox",
"GET",
None,
vec![(header::ACCEPT, "application/activity+json")],
)
.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["type"], "OrderedCollection");
assert_eq!(v["totalItems"], 1);
assert_eq!(v["orderedItems"][0]["type"], "Create");
assert_eq!(
v["orderedItems"][0]["object"]["content"],
"This is a federated thought!"
);
}

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

@@ -1,9 +1,9 @@
mod activitypub;
mod api_key;
mod auth;
mod feed;
mod follow;
mod main;
mod search;
mod tag;
mod thought;
mod user;

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

View File

@@ -249,3 +249,73 @@ async fn test_get_thought_by_id_visibility() {
"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());
}

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

@@ -1,22 +0,0 @@
use std::process::Command;
fn touch(file_name: &str) {
if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", &format!("type nul >> {}", file_name)])
.output()
.expect("failed to execute touch");
} else {
Command::new("touch")
.arg(file_name)
.output()
.expect("failed to execute touch");
}
}
pub fn create_dev_db(db_url: &str) {
let prefix = "sqlite://";
if let Some(file_name) = db_url.strip_prefix(prefix) {
touch(file_name);
}
}

View File

@@ -1,6 +1,4 @@
mod db;
mod file;
pub mod testing;
pub use db::migrate;
pub use file::create_dev_db;

View File

@@ -1,30 +1,29 @@
FROM oven/bun:1 AS base
FROM node:22-slim AS builder
WORKDIR /app
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN mkdir -p /temp/prod
COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
# Install dependencies with Bun for speed
COPY --chown=node:node package.json bun.lock ./
RUN npm install -g bun
RUN bun install --frozen-lockfile
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# Copy the rest of the app and build with Node's Next.js runtime
COPY --chown=node:node . .
ENV NODE_ENV=production
RUN bun run build
FROM base AS release
FROM node:22-slim AS release
COPY --from=prerelease /app/public ./public
COPY --from=prerelease /app/.next/standalone ./
COPY --from=prerelease /app/.next/static ./.next/static
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
USER bun
EXPOSE 3000
CMD ["bun", "run", "server.js"]
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -5,7 +5,7 @@ export default function AuthLayout({
children: React.ReactNode;
}) {
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}
</div>
);

View File

@@ -42,7 +42,7 @@ export default function LoginPage() {
const { token } = await loginUser(values);
setToken(token);
router.push("/"); // Redirect to homepage on successful login
} catch (err) {
} catch {
setError("Invalid username or password.");
}
}

View File

@@ -40,7 +40,7 @@ export default function RegisterPage() {
await registerUser(values);
// You can automatically log the user in here or just redirect them
router.push("/login");
} catch (err) {
} catch {
setError("Username or email may already be taken.");
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -41,28 +41,33 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--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 {
--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);
--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-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -76,27 +81,40 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 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 {
--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);
--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-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@@ -110,13 +128,187 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--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 {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
background-image: url("/background.avif");
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;
}
}

View File

@@ -1,25 +1,32 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/hooks/use-auth";
import { Toaster } from "@/components/ui/sonner";
import { Header } from "@/components/header";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import localFont from "next/font/local";
import InstallPrompt from "@/components/install-prompt";
export const metadata: Metadata = {
title: "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({
children,
}: Readonly<{
@@ -27,12 +34,11 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className={`${frutiger.className} antialiased`}>
<AuthProvider>
<Header />
<main className="flex-1">{children}</main>
<InstallPrompt />
<Toaster />
</AuthProvider>
</body>

View File

@@ -0,0 +1,25 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Thoughts',
short_name: 'Thoughts',
description: 'A social network for sharing thoughts',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icon-192x192.webp',
sizes: '192x192',
type: 'image/webp',
},
{
src: '/icon.avif',
sizes: '512x512',
type: 'image/avif',
},
],
}
}

View File

@@ -1,5 +1,12 @@
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 { Button } from "@/components/ui/button";
import Link from "next/link";
@@ -7,24 +14,53 @@ 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 authors = [...new Set(feedData.thoughts.map((t) => t.authorUsername))];
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(
authors.map((username) => getUserProfile(username, token).catch(() => null))
);
@@ -35,15 +71,17 @@ async function FeedPage({ token }: { token: string }) {
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
);
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(
feedData.thoughts
);
const friends = (await getFriends(token)).users.map((user) => user.username);
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">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<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>
<p className="text-sm text-muted-foreground">Coming soon...</p>
</div>
@@ -51,31 +89,64 @@ async function FeedPage({ token }: { token: string }) {
<main className="col-span-1 lg:col-span-2 space-y-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>
<PostThoughtForm />
<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">
{topLevelThoughts.map((thought) => (
{thoughtThreads.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
repliesByParentId={repliesByParentId}
authorDetails={authorDetails}
currentUser={me}
/>
))}
{topLevelThoughts.length === 0 && (
{thoughtThreads.length === 0 && (
<p className="text-center text-muted-foreground pt-8">
Your feed is empty. Follow some users to see their thoughts!
</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">
{me?.topFriends && <TopFriends usernames={me.topFriends} />}
<PopularTags />
{shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={me.topFriends} />
)}
{!shouldDisplayTopFriends && token && friends.length > 0 && (
<TopFriends mode="friends" usernames={friends || []} />
)}
<UsersCount />
</div>
</aside>
</div>
@@ -85,8 +156,9 @@ async function FeedPage({ token }: { token: string }) {
function LandingPage() {
return (
<>
<div className="font-sans min-h-screen text-gray-800 flex items-center justify-center">
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center glass-effect glossy-effect bottom rounded-md shadow-fa-lg">
<h1
className="text-5xl font-bold"
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
@@ -94,7 +166,7 @@ function LandingPage() {
Welcome to Thoughts
</h1>
<p className="text-muted-foreground mt-2">
Your space on the decentralized web.
Throwback to the golden age of microblogging.
</p>
<div className="mt-8 flex justify-center gap-4">
<Button asChild>
@@ -106,5 +178,6 @@ function LandingPage() {
</div>
</div>
</div>
</>
);
}

View 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: &quot;{query}&quot;
</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>
);
}

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

View File

@@ -7,7 +7,10 @@ const sidebarNavItems = [
title: "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({
@@ -17,7 +20,7 @@ export default function SettingsLayout({
}) {
return (
<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>
<p className="text-muted-foreground">
Manage your account settings and profile.

View File

@@ -18,8 +18,8 @@ export default async function EditProfilePage() {
}
return (
<div className="space-y-6">
<div>
<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">Profile</h3>
<p className="text-sm text-muted-foreground">
This is how others will see you on the site.

View File

@@ -24,6 +24,7 @@ export default async function TagPage({ params }: TagPageProps) {
}
const allThoughts = thoughtsResult.value.thoughts;
const thoughtThreads = buildThoughtThreads(allThoughts);
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
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 }])
);
const { topLevelThoughts, repliesByParentId } =
buildThoughtThreads(allThoughts);
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6">
@@ -48,16 +46,15 @@ export default async function TagPage({ params }: TagPageProps) {
</h1>
</header>
<main className="space-y-6">
{topLevelThoughts.map((thought) => (
{thoughtThreads.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
repliesByParentId={repliesByParentId}
authorDetails={authorDetails}
currentUser={me}
/>
))}
{topLevelThoughts.length === 0 && (
{thoughtThreads.length === 0 && (
<p className="text-center text-muted-foreground pt-8">
No thoughts found for this tag.
</p>

View File

@@ -1,13 +1,12 @@
import { cookies } from "next/headers";
import {
getThoughtById,
getUserThoughts,
getThoughtThread,
getUserProfile,
getMe,
Me,
Thought,
User,
ThoughtThread as ThoughtThreadType,
} from "@/lib/api";
import { buildThoughtThreads } from "@/lib/utils";
import { ThoughtThread } from "@/components/thought-thread";
import { notFound } from "next/navigation";
@@ -15,57 +14,43 @@ interface ThoughtPageProps {
params: { thoughtId: string };
}
async function findConversationRoot(
startThought: Thought,
token: string | null
): Promise<Thought> {
let currentThought = startThought;
while (currentThought.replyToId) {
const parentThought = await getThoughtById(
currentThought.replyToId,
token
).catch(() => null);
if (!parentThought) break;
currentThought = parentThought;
function collectAuthors(thread: ThoughtThreadType): string[] {
const authors = new Set<string>([thread.authorUsername]);
for (const reply of thread.replies) {
collectAuthors(reply).forEach((author) => authors.add(author));
}
return currentThought;
return Array.from(authors);
}
export default async function ThoughtPage({ params }: ThoughtPageProps) {
const { thoughtId } = params;
const token = (await cookies()).get("auth_token")?.value ?? null;
const initialThought = await getThoughtById(thoughtId, token).catch(
() => null
);
if (!initialThought) {
notFound();
}
const rootThought = await findConversationRoot(initialThought, token);
const [thoughtsResult, meResult] = await Promise.allSettled([
getUserThoughts(rootThought.authorUsername, token),
const [threadResult, meResult] = await Promise.allSettled([
getThoughtThread(thoughtId, token),
token ? getMe(token) : Promise.resolve(null),
]);
if (thoughtsResult.status === "rejected") {
if (threadResult.status === "rejected") {
notFound();
}
const allThoughts = thoughtsResult.value.thoughts;
const thread = threadResult.value;
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
const author = await getUserProfile(rootThought.authorUsername, token).catch(
() => null
// Fetch details for all authors in the thread efficiently
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 (
<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>
<main>
<ThoughtThread
thought={rootThought}
repliesByParentId={repliesByParentId}
thought={thread}
authorDetails={authorDetails}
currentUser={me}
/>

View File

@@ -1,6 +1,7 @@
import {
getFollowersList,
getFollowingList,
getFriends,
getMe,
getUserProfile,
getUserThoughts,
@@ -55,7 +56,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const thoughts =
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : [];
const { topLevelThoughts, repliesByParentId } = buildThoughtThreads(thoughts);
const thoughtThreads = buildThoughtThreads(thoughts);
const followersCount =
followersResult.status === "fulfilled"
@@ -75,32 +76,53 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
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 (
<div>
<div id={`profile-page-${user.username}`}>
{user.customCss && (
<style dangerouslySetInnerHTML={{ __html: user.customCss }} />
)}
<div
id="profile-header"
className="h-48 bg-gray-200 bg-cover bg-center profile-header"
style={{
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) */}
<aside className="col-span-1 lg:col-span-1 space-y-6">
<div className="sticky top-20 space-y-6">
<Card className="p-6 bg-card/80 backdrop-blur-lg">
<div className="flex justify-between items-start">
<div className="flex items-end gap-4">
<div className="w-24 h-24 rounded-full border-4 border-background shrink-0">
<UserAvatar src={user.avatarUrl} alt={user.displayName} />
<aside id="left-sidebar" className="col-span-1 lg:col-span-1 space-y-6">
<div id="left-sidebar__inner" className="sticky top-20 space-y-6">
<Card id="profile-card" className="p-6 bg-card/80 backdrop-blur-lg">
<div
id="profile-card__inner"
className="flex justify-between items-start"
>
<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>
{/* Action Button */}
<div>
<div id="profile-card__action">
{isOwnProfile ? (
<Button asChild variant="outline" size="sm">
<Link href="/settings/profile">
@@ -116,19 +138,30 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</div>
</div>
<div className="mt-4">
<h1 className="text-2xl font-bold">
<div id="profile-card__info" className="mt-4">
<h1 id="profile-card__name" className="text-2xl font-bold">
{user.displayName || user.username}
</h1>
<p className="text-sm text-muted-foreground">
<p
id="profile-card__username"
className="text-sm text-muted-foreground"
>
@{user.username}
</p>
</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 && (
<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
href={`/users/${user.username}/following`}
className="hover:underline"
@@ -150,7 +183,10 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</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" />
<span>
Joined {new Date(user.joinedAt).toLocaleDateString()}
@@ -158,22 +194,30 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</div>
</Card>
<TopFriends usernames={user.topFriends} />
{shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={user.topFriends} />
)}
{token && <TopFriends mode="friends" usernames={friends || []} />}
</div>
</aside>
<div className="col-span-1 lg:col-span-3 space-y-4">
{topLevelThoughts.map((thought) => (
<div
id="profile-card__thoughts"
className="col-span-1 lg:col-span-3 space-y-4"
>
{thoughtThreads.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
repliesByParentId={repliesByParentId}
authorDetails={authorDetails}
currentUser={me}
/>
))}
{topLevelThoughts.length === 0 && (
<Card className="flex items-center justify-center h-48">
{thoughtThreads.length === 0 && (
<Card
id="profile-card__no-thoughts"
className="flex items-center justify-center h-48"
>
<p className="text-center text-muted-foreground">
This user hasn&apos;t posted any public thoughts yet.
</p>

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

@@ -50,6 +50,7 @@
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tone": "^15.1.22",
"vaul": "^1.1.2",
"zod": "^4.1.5",
},
@@ -474,6 +475,8 @@
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"automation-events": ["automation-events@7.1.12", "", { "dependencies": { "@babel/runtime": "^7.28.3", "tslib": "^2.8.1" } }, "sha512-JDdPQoV58WPm15/L3ABtIEiqyxLoW+yTYIEqYtrKZ7VizLSRXhMKRZbQ8CYc2mFq/lMRKUvqOj0OcT3zANFiXA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="],
@@ -992,6 +995,8 @@
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"standardized-audio-context": ["standardized-audio-context@25.3.77", "", { "dependencies": { "@babel/runtime": "^7.25.6", "automation-events": "^7.0.9", "tslib": "^2.7.0" } }, "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
@@ -1030,6 +1035,8 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tone": ["tone@15.1.22", "", { "dependencies": { "standardized-audio-context": "^25.3.70", "tslib": "^2.3.1" } }, "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],

View 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 &quot;{key.name}
&quot;. 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>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import React, { useEffect, useRef } from "react";
import * as Tone from "tone";
interface ConfettiProps {
fire: boolean;
onComplete: () => void;
}
const colors = ["#26ccff", "#a25afd", "#ff5e7e", "#88ff5a", "#fcff42"];
export function Confetti({ fire, onComplete }: ConfettiProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (fire) {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const synth = new Tone.PolySynth(Tone.Synth, {
oscillator: { type: "sine" },
envelope: { attack: 0.005, decay: 0.1, sustain: 0.3, release: 1 },
}).toDestination();
const notes = ["C4", "E4", "G4", "A4"];
let animationFrameId: number;
const confetti: {
x: number;
y: number;
r: number;
d: number;
color: string;
tilt: number;
}[] = [];
const numConfetti = 100;
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
for (let i = 0; i < numConfetti; i++) {
confetti.push({
x: Math.random() * canvas.width,
y: -20,
r: Math.random() * 6 + 1,
d: Math.random() * numConfetti,
color: colors[Math.floor(Math.random() * colors.length)],
tilt: Math.floor(Math.random() * 10) - 10,
});
}
let animationFinished = false;
const draw = () => {
if (animationFinished) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
let allOffScreen = true;
for (let i = 0; i < numConfetti; i++) {
const c = confetti[i];
ctx.beginPath();
ctx.lineWidth = c.r / 2;
ctx.strokeStyle = c.color;
ctx.moveTo(c.x + c.tilt, c.y);
ctx.lineTo(c.x, c.y + c.tilt + c.r);
ctx.stroke();
c.y += Math.cos(c.d + i + 1.2) + 1.5 + c.r / 2;
c.x += Math.sin(i) * 1.5;
if (c.y <= canvas.height) {
allOffScreen = false;
}
}
if (allOffScreen) {
animationFinished = true;
onComplete();
} else {
animationFrameId = requestAnimationFrame(draw);
}
};
try {
Tone.start();
const now = Tone.now();
notes.forEach((note, i) => {
synth.triggerAttackRelease(note, "8n", now + i * 0.1);
});
draw();
} catch (error) {
console.error("Audio could not be started", error);
draw();
}
return () => {
window.removeEventListener("resize", resizeCanvas);
cancelAnimationFrame(animationFrameId);
};
}
}, [fire, onComplete]);
if (!fire) return null;
return (
<canvas
ref={canvasRef}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 9999,
}}
/>
);
}

View File

@@ -36,7 +36,7 @@ export function FollowButton({
setIsFollowing(!isFollowing);
await action(username, token);
router.refresh(); // Re-fetch server component data to get the latest follower count etc.
} catch (err) {
} catch {
// Revert on error
setIsFollowing(isFollowing);
toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} user.`);
@@ -50,6 +50,7 @@ export function FollowButton({
onClick={handleClick}
disabled={isLoading}
variant={isFollowing ? "secondary" : "default"}
data-following={isFollowing}
>
{isFollowing ? (
<>

View File

@@ -5,27 +5,27 @@ import Link from "next/link";
import { Button } from "./ui/button";
import { UserNav } from "./user-nav";
import { MainNav } from "./main-nav";
import { ThemeToggle } from "./theme-toggle";
export function Header() {
const { token } = useAuth();
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="w-full flex h-14 items-center px-2">
<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="container flex h-14 items-center px-2">
<div className="flex gap-2">
<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>
<MainNav />
</div>
<div className="flex flex-1 items-center justify-end space-x-2">
<ThemeToggle />
{token ? (
<UserNav />
) : (
<>
<Button asChild variant="ghost" size="sm">
<Button asChild size="sm">
<Link href="/login">Login</Link>
</Button>
<Button asChild size="sm">

View File

@@ -0,0 +1,119 @@
"use client";
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Download } from "lucide-react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardAction,
} from "@/components/ui/card";
import Cookies from "js-cookie";
interface CustomWindow extends Window {
MSStream?: unknown;
}
interface BeforeInstallPromptEvent extends Event {
prompt: () => void;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
}
export default function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
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);
};
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
return () => {
window.removeEventListener(
"beforeinstallprompt",
handleBeforeInstallPrompt
);
};
}, []);
const handleInstallClick = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
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);
};
const handleCloseClick = () => {
setIsStandalone(true);
Cookies.set("install_prompt_dismissed", "true", { expires: 7 });
};
if (isStandalone || (!isIOS && !deferredPrompt) || isDismissed) {
return null;
}
return (
<div className="fixed bottom-0 z-50">
<Card className="w-full max-w-sm glass-effect glossy-effect bottom shadow-fa-lg">
<CardHeader>
<CardTitle>Install Thoughts</CardTitle>
<CardDescription>
Get the full app experience on your device.
</CardDescription>
<CardAction>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2"
onClick={handleCloseClick}
>
&times;
</Button>
</CardAction>
</CardHeader>
<CardContent>
{!isIOS && deferredPrompt && (
<Button className="w-full" onClick={handleInstallClick}>
<Download className="mr-2 h-4 w-4" />
Add to Home Screen
</Button>
)}
{isIOS && (
<p className="text-sm text-muted-foreground">
To install, tap the Share icon
<span className="mx-1 text-lg"></span>
and then &quot;Add to Home Screen&quot;
<span className="mx-1 text-lg"></span>.
</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -3,20 +3,22 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { SearchInput } from "./search-input";
export function MainNav() {
const pathname = usePathname();
return (
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
<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

@@ -23,22 +23,25 @@ export async function PopularTags() {
}
return (
<Card>
<CardHeader>
<CardTitle>Popular Tags</CardTitle>
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg">Popular Tags</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<CardContent className="flex flex-wrap gap-2 p-0">
{tags.map((tag) => (
<Link href={`/tags/${tag}`} key={tag}>
<Badge
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" />
{tag}
</Badge>
</Link>
))}
{tags.length === 0 && (
<p className="text-sm text-muted-foreground">No popular tags yet.</p>
)}
</CardContent>
</Card>
);

View File

@@ -25,10 +25,13 @@ import { CreateThoughtSchema, createThought } from "@/lib/api";
import { useAuth } from "@/hooks/use-auth";
import { toast } from "sonner";
import { Globe, Lock, Users } from "lucide-react";
import { useState } from "react";
import { Confetti } from "./confetti";
export function PostThoughtForm() {
const router = useRouter();
const { token } = useAuth();
const [showConfetti, setShowConfetti] = useState(false);
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
resolver: zodResolver(CreateThoughtSchema),
@@ -44,14 +47,17 @@ export function PostThoughtForm() {
try {
await createThought(values, token);
toast.success("Your thought has been posted!");
setShowConfetti(true);
form.reset();
router.refresh(); // This is the key to updating the feed
} catch (err) {
} catch {
toast.error("Failed to post thought. Please try again.");
}
}
return (
<>
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
<Card>
<CardContent className="p-4">
<Form {...form}>
@@ -114,5 +120,6 @@ export function PostThoughtForm() {
</Form>
</CardContent>
</Card>
</>
);
}

View File

@@ -1,4 +1,3 @@
// components/reply-form.tsx
"use client";
import { useForm } from "react-hook-form";
@@ -17,6 +16,8 @@ import { Textarea } from "@/components/ui/textarea";
import { CreateThoughtSchema, createThought } from "@/lib/api";
import { useAuth } from "@/hooks/use-auth";
import { toast } from "sonner";
import { useState } from "react";
import { Confetti } from "./confetti";
interface ReplyFormProps {
parentThoughtId: string;
@@ -26,6 +27,7 @@ interface ReplyFormProps {
export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
const router = useRouter();
const { token } = useAuth();
const [showConfetti, setShowConfetti] = useState(false);
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
resolver: zodResolver(CreateThoughtSchema),
@@ -46,16 +48,20 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
await createThought(values, token);
toast.success("Your reply has been posted!");
form.reset();
onReplySuccess(); // Call the callback
router.refresh(); // Refresh the page to show the new reply
} catch (err) {
setShowConfetti(true);
console.log("Showing confetti");
onReplySuccess();
router.refresh();
} catch {
toast.error("Failed to post reply. Please try again.");
}
}
return (
<>
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
<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
control={form.control}
name="content"
@@ -64,7 +70,7 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
<FormControl>
<Textarea
placeholder="Post your reply..."
className="resize-none"
className="resize-none bg-white glass-effect glossy-efect bottom shadow-fa-sm"
{...field}
/>
</FormControl>
@@ -86,5 +92,6 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
</div>
</form>
</Form>
</>
);
}

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

View File

@@ -29,10 +29,8 @@ export function SettingsNav({ className, items, ...props }: SettingsNavProps) {
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === item.href
? "bg-muted hover:bg-muted"
: "hover:bg-transparent hover:underline",
"justify-start"
pathname === item.href ? "bg-muted" : "hover:underline",
"justify-start glass-effect glossy-effect bottom shadow-fa-md"
)}
>
{item.title}

View File

@@ -38,11 +38,13 @@ import {
} from "lucide-react";
import { ReplyForm } from "@/components/reply-form";
import Link from "next/link";
import { cn } from "@/lib/utils";
interface ThoughtCardProps {
thought: Thought;
author: {
username: string;
displayName?: string | null;
avatarUrl?: string | null;
};
currentUser: Me | null;
@@ -83,16 +85,21 @@ export function ThoughtCard({
<>
<div
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 && (
<div className="px-4 pt-2 text-sm text-muted-foreground flex items-center gap-2">
<CornerUpLeft className="h-4 w-4" />
<div className="text-sm text-muted-foreground flex items-center gap-2">
<CornerUpLeft className="h-4 w-4 text-primary/70" />
<span>
Replying to{" "}
<Link
href={`#${thought.replyToId}`}
className="hover:underline text-primary"
className="hover:underline text-primary text-shadow-sm"
>
parent thought
</Link>
@@ -100,16 +107,23 @@ export function ThoughtCard({
</div>
)}
</div>
<Card>
<Card className="mt-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Link
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.displayName || author.username}
/>
<div className="flex flex-col">
<span className="font-bold">{author.username}</span>
<span className="text-sm text-muted-foreground">{timeAgo}</span>
<span className="font-bold">
{author.displayName || author.username}
</span>
<span className="text-sm text-muted-foreground text-shadow-sm">
{timeAgo}
</span>
</div>
</Link>
<DropdownMenu>
@@ -138,11 +152,13 @@ export function ThoughtCard({
</DropdownMenu>
</CardHeader>
<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>
{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
variant="ghost"
size="sm"
@@ -155,7 +171,7 @@ export function ThoughtCard({
)}
{isReplyOpen && (
<div className="border-t p-4">
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20 ">
<ReplyForm
parentThoughtId={thought.id}
onReplySuccess={() => setIsReplyOpen(false)}

View 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,
displayName: thought.authorDisplayName,
...authorDetails.get(thought.authorUsername),
};
return (
<ThoughtCard
key={thought.id}
thought={thought}
author={author}
currentUser={currentUser}
/>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,9 +1,8 @@
import { Me, Thought } from "@/lib/api";
import { Me, ThoughtThread as ThoughtThreadType } from "@/lib/api";
import { ThoughtCard } from "./thought-card";
interface ThoughtThreadProps {
thought: Thought;
repliesByParentId: Map<string, Thought[]>;
thought: ThoughtThreadType;
authorDetails: Map<string, { avatarUrl?: string | null }>;
currentUser: Me | null;
isReply?: boolean;
@@ -11,21 +10,18 @@ interface ThoughtThreadProps {
export function ThoughtThread({
thought,
repliesByParentId,
authorDetails,
currentUser,
isReply = false,
}: ThoughtThreadProps) {
const author = {
username: thought.authorUsername,
avatarUrl: null,
displayName: thought.authorDisplayName,
...authorDetails.get(thought.authorUsername),
};
const directReplies = repliesByParentId.get(thought.id) || [];
return (
<div className="flex flex-col gap-0">
<div id={`thought-thread-${thought.id}`} className="flex flex-col gap-0">
<ThoughtCard
thought={thought}
author={author}
@@ -33,13 +29,15 @@ export function ThoughtThread({
isReply={isReply}
/>
{directReplies.length > 0 && (
<div className="pl-6 border-l-2 border-dashed ml-6 flex flex-col gap-4 pt-4">
{directReplies.map((reply) => (
<ThoughtThread // RECURSIVE CALL
{thought.replies.length > 0 && (
<div
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
key={reply.id}
thought={reply}
repliesByParentId={repliesByParentId} // Pass the full map down
authorDetails={authorDetails}
currentUser={currentUser}
isReply={true}

View File

@@ -5,21 +5,24 @@ import { getUserProfile, User } from "@/lib/api";
import { cookies } from "next/headers";
interface TopFriendsProps {
mode: "friends" | "top-friends";
usernames: string[];
}
// This is an async Server Component
export async function TopFriends({ usernames }: TopFriendsProps) {
export async function TopFriends({
mode = "top-friends",
usernames,
}: TopFriendsProps) {
const token = (await cookies()).get("auth_token")?.value ?? null;
if (usernames.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Top Friends</CardTitle>
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg text-shadow-md">Top Friends</CardTitle>
</CardHeader>
<CardContent>
<p className="text-center text-muted-foreground">
<CardContent className="p-0">
<p className="text-sm text-muted-foreground">
No top friends to display.
</p>
</CardContent>
@@ -27,7 +30,6 @@ export async function TopFriends({ usernames }: TopFriendsProps) {
);
}
// Fetch all top friend profiles in parallel
const friendsResults = await Promise.allSettled(
usernames.map((username) => getUserProfile(username, token))
);
@@ -40,19 +42,25 @@ export async function TopFriends({ usernames }: TopFriendsProps) {
.map((result) => result.value);
return (
<Card>
<CardHeader>
<CardTitle>Top Friends</CardTitle>
<Card id="top-friends" className="p-4">
<CardHeader id="top-friends__header" className="p-0 pb-2">
<CardTitle id="top-friends__title" className="text-lg text-shadow-md">
{mode === "top-friends" ? "Top Friends" : "Friends"}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-4 gap-4">
<CardContent id="top-friends__content" className="p-0">
{friends.map((friend) => (
<Link
id={`top-friends__link-${friend.id}`}
href={`/users/${friend.username}`}
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} />
<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}
</span>
</Link>

View File

@@ -1,29 +1,28 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
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: {
variant: {
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:
"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:
"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",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 glossy-effect bottom text-shadow-sm",
outline: "text-foreground glossy-effect bottom text-shadow-sm",
},
},
defaultVariants: {
variant: "default",
},
}
)
);
function Badge({
className,
@@ -32,7 +31,7 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : "span";
return (
<Comp
@@ -40,7 +39,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@@ -1,31 +1,35 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
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",
{
variants: {
variant: {
// Default button gets blue gradient, gloss, and shadows
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"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",
"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",
// Secondary gets green gradient
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:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"glass-effect hover:bg-accent hover:text-accent-foreground rounded-lg",
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: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
@@ -33,7 +37,7 @@ const buttonVariants = cva(
size: "default",
},
}
)
);
function Button({
className,
@@ -43,9 +47,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -53,7 +57,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@@ -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">) {
return (
<div
data-slot="card"
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
)}
{...props}
/>
)
></div>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -25,17 +25,20 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn(
"leading-none font-semibold tracking-tight text-shadow-md",
className
)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -58,7 +61,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +71,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
export {
@@ -89,4 +92,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};

View File

@@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
);
}
function DropdownMenuTrigger({
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -42,13 +42,14 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
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
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@@ -56,7 +57,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
@@ -65,8 +66,8 @@ function DropdownMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
@@ -79,7 +80,7 @@ function DropdownMenuItem({
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -105,7 +106,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -116,7 +117,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -140,7 +141,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -148,7 +149,7 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
@@ -160,7 +161,7 @@ function DropdownMenuLabel({
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -173,7 +174,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
@@ -189,13 +190,13 @@ function DropdownMenuShortcut({
)}
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: 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({
@@ -204,7 +205,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -219,7 +220,7 @@ function DropdownMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -235,7 +236,7 @@ function DropdownMenuSubContent({
)}
{...props}
/>
)
);
}
export {
@@ -254,4 +255,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};

View File

@@ -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">) {
return (
@@ -10,12 +10,12 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
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",
"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
)}
{...props}
/>
)
);
}
export { Input }
export { Input };

View File

@@ -1,20 +1,20 @@
"use client"
"use client";
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
@@ -30,19 +30,20 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
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
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
);
}
function PopoverAnchor({
...props
}: 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 };

View File

@@ -1,27 +1,27 @@
"use client"
"use client";
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
@@ -30,14 +30,14 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
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
)}
{...props}
@@ -47,7 +47,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
@@ -61,7 +61,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
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" &&
"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
@@ -82,7 +82,7 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
@@ -95,7 +95,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
);
}
function SelectItem({
@@ -107,7 +107,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
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
)}
{...props}
@@ -119,7 +119,7 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@@ -132,7 +132,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@@ -150,7 +150,7 @@ function SelectScrollUpButton({
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@@ -168,7 +168,7 @@ function SelectScrollDownButton({
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@@ -182,4 +182,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Separator({
className,
@@ -17,12 +17,12 @@ function Separator({
decorative={decorative}
orientation={orientation}
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
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };

Some files were not shown because too many files have changed in this diff Show More