Compare commits
50 Commits
5ce6d9f2da
...
master
Author | SHA1 | Date | |
---|---|---|---|
dffec9b189 | |||
e2494135d6 | |||
d6c42afaec | |||
e376f584c7 | |||
75c5adf346 | |||
878ebf1541 | |||
c9775293c0 | |||
93b90b85b6 | |||
58e51cb028 | |||
5282376860 | |||
082f11a3e9 | |||
ec73a0c373 | |||
29afc2e92e | |||
cbca1058a2 | |||
8536e52590 | |||
247c6ad955 | |||
c6f7dfe225 | |||
0ba3b79185 | |||
64806f8bd4 | |||
4ea4f3149f | |||
d92c9a747e | |||
863bc90c6f | |||
d15339cf4a | |||
916dbe0245 | |||
7889137cd8 | |||
4e38c1133e | |||
86eb059f3e | |||
84f2423343 | |||
9207572f07 | |||
1c52bf3ea4 | |||
327e671571 | |||
36e12d1d96 | |||
452ea5625f | |||
bc8941d910 | |||
01d7a837f8 | |||
71048f0060 | |||
f278a44d8f | |||
aa4be7e05b | |||
5bc4337447 | |||
b50b7bcc73 | |||
9b2a1139b5 | |||
2083f3bb16 | |||
08213133be | |||
5f8cf49ec9 | |||
c6f5bab1eb | |||
72b4cb0851 | |||
dd279a1434 | |||
6efab333f3 | |||
1a405500ca | |||
3d25ffca4f |
7
.env
@@ -1,3 +1,10 @@
|
|||||||
POSTGRES_USER=thoughts_user
|
POSTGRES_USER=thoughts_user
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=postgres
|
||||||
POSTGRES_DB=thoughts_db
|
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
|
41
.gitea/workflows/deploy.yml
Normal 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
@@ -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
|
@@ -25,6 +25,9 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
- RUST_LOG=info
|
||||||
|
- RUST_BACKTRACE=1
|
||||||
depends_on:
|
depends_on:
|
||||||
database:
|
database:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -34,9 +37,13 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./thoughts-frontend
|
context: ./thoughts-frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_URL: http://localhost/api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
container_name: thoughts-proxy
|
container_name: thoughts-proxy
|
||||||
|
5
nginx/Dockerfile
Normal 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
|
@@ -10,6 +10,11 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
return 200 "OK";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
proxy_connect_timeout 300s;
|
proxy_connect_timeout 300s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
|
@@ -5,3 +5,4 @@ DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts"
|
|||||||
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
|
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
|
||||||
PREFORK=0
|
PREFORK=0
|
||||||
AUTH_SECRET=your_secret_key_here
|
AUTH_SECRET=your_secret_key_here
|
||||||
|
BASE_URL=http://0.0.0.0
|
@@ -1,7 +1,6 @@
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=3000
|
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
|
PREFORK=1
|
||||||
AUTH_SECRET=your_secret_key_here
|
AUTH_SECRET=your_secret_key_here
|
||||||
BASE_URL=http://localhost:3000
|
BASE_URL=http://localhost:3000
|
698
thoughts-backend/Cargo.lock
generated
@@ -1,10 +1,14 @@
|
|||||||
FROM rust:1.89-slim AS builder
|
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
|
RUN cargo install cargo-chef --locked
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY api/Cargo.toml ./api/
|
COPY api/Cargo.toml ./api/
|
||||||
COPY app/Cargo.toml ./app/
|
COPY app/Cargo.toml ./app/
|
||||||
|
COPY common/Cargo.toml ./common/
|
||||||
COPY doc/Cargo.toml ./doc/
|
COPY doc/Cargo.toml ./doc/
|
||||||
COPY migration/Cargo.toml ./migration/
|
COPY migration/Cargo.toml ./migration/
|
||||||
COPY models/Cargo.toml ./models/
|
COPY models/Cargo.toml ./models/
|
||||||
@@ -22,6 +26,8 @@ RUN cargo build --release --bin thoughts-backend
|
|||||||
|
|
||||||
FROM debian:13-slim AS runtime
|
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 && \
|
RUN groupadd --system --gid 1001 appgroup && \
|
||||||
useradd --system --uid 1001 --gid appgroup appuser
|
useradd --system --uid 1001 --gid appgroup appuser
|
||||||
|
|
||||||
@@ -29,8 +35,6 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=builder /app/target/release/thoughts-backend .
|
COPY --from=builder /app/target/release/thoughts-backend .
|
||||||
|
|
||||||
COPY .env.example .env
|
|
||||||
|
|
||||||
RUN chown -R appuser:appgroup /app
|
RUN chown -R appuser:appgroup /app
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
@@ -38,6 +38,5 @@ tower-http = { version = "0.6.6", features = ["fs", "cors"] }
|
|||||||
tower-cookies = "0.11.0"
|
tower-cookies = "0.11.0"
|
||||||
anyhow = "1.0.98"
|
anyhow = "1.0.98"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
activitypub_federation = "0.6.5"
|
|
||||||
url = "2.5.7"
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,6 +1,5 @@
|
|||||||
mod error;
|
mod error;
|
||||||
mod extractor;
|
mod extractor;
|
||||||
mod federation;
|
|
||||||
mod init;
|
mod init;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
|
@@ -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::{
|
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,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
use models::{
|
||||||
|
queries::pagination::PaginationQuery,
|
||||||
|
schemas::{pagination::PaginatedResponse, thought::ThoughtSchema},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{error::ApiError, extractor::AuthUser};
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "",
|
path = "",
|
||||||
|
params(PaginationQuery),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
|
(status = 200, description = "Authenticated user's feed", body = PaginatedResponse<ThoughtSchema>)
|
||||||
),
|
),
|
||||||
security(
|
security(
|
||||||
("api_key" = []),
|
("api_key" = []),
|
||||||
@@ -22,21 +31,35 @@ use crate::{error::ApiError, extractor::AuthUser};
|
|||||||
async fn feed_get(
|
async fn feed_get(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
Query(pagination): Query<PaginationQuery>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
|
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
|
||||||
let mut thoughts_with_authors =
|
let (thoughts_with_authors, total_items) = get_feed_for_users_and_self_paginated(
|
||||||
get_feed_for_user(&state.conn, following_ids, Some(auth_user.id)).await?;
|
&state.conn,
|
||||||
|
auth_user.id,
|
||||||
let own_thoughts =
|
following_ids,
|
||||||
get_feed_for_user(&state.conn, vec![auth_user.id], Some(auth_user.id)).await?;
|
&pagination,
|
||||||
thoughts_with_authors.extend(own_thoughts);
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(ThoughtSchema::from)
|
.map(ThoughtSchema::from)
|
||||||
.collect();
|
.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> {
|
pub fn create_feed_router() -> Router<AppState> {
|
||||||
|
@@ -9,9 +9,8 @@ pub mod search;
|
|||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
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 app::state::AppState;
|
||||||
use root::create_root_router;
|
use root::create_root_router;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
@@ -24,7 +23,6 @@ pub fn create_router(state: AppState) -> Router {
|
|||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(create_root_router())
|
.merge(create_root_router())
|
||||||
.nest("/.well-known", create_well_known_router())
|
|
||||||
.nest("/auth", create_auth_router())
|
.nest("/auth", create_auth_router())
|
||||||
.nest("/users", create_user_router())
|
.nest("/users", create_user_router())
|
||||||
.nest("/thoughts", create_thought_router())
|
.nest("/thoughts", create_thought_router())
|
||||||
|
@@ -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 sea_orm::{ConnectionTrait, Statement};
|
||||||
|
|
||||||
use app::state::AppState;
|
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())
|
result.unwrap().try_get_by(0).map_err(|e| e.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_root_router() -> Router<AppState> {
|
async fn health_check() -> StatusCode {
|
||||||
Router::new().route("/", get(root_get))
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_root_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(root_get))
|
||||||
|
.route("/health", get(health_check))
|
||||||
}
|
}
|
||||||
|
@@ -20,7 +20,6 @@ use sea_orm::prelude::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
extractor::{AuthUser, Json, OptionalAuthUser, Valid},
|
extractor::{AuthUser, Json, OptionalAuthUser, Valid},
|
||||||
federation,
|
|
||||||
models::{ApiErrorResponse, ParamsErrorResponse},
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,13 +76,6 @@ async fn thoughts_post(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid
|
.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);
|
let schema = ThoughtSchema::from_models(&thought, &author);
|
||||||
Ok((StatusCode::CREATED, Json(schema)))
|
Ok((StatusCode::CREATED, Json(schema)))
|
||||||
}
|
}
|
||||||
|
@@ -11,12 +11,20 @@ use serde_json::{json, Value};
|
|||||||
use app::persistence::{
|
use app::persistence::{
|
||||||
follow,
|
follow,
|
||||||
thought::get_thoughts_by_user,
|
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::state::AppState;
|
||||||
use app::{error::UserError, persistence::user::get_user_by_username};
|
use app::{error::UserError, persistence::user::get_user_by_username};
|
||||||
use models::schemas::user::{MeSchema, UserListSchema, UserSchema};
|
use models::{
|
||||||
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
|
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 models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||||
|
|
||||||
use crate::{error::ApiError, extractor::AuthUser};
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
@@ -413,9 +421,46 @@ async fn get_user_followers(
|
|||||||
Ok(Json(UserListSchema::from(followers_list)))
|
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> {
|
pub fn create_user_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(users_get))
|
.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))
|
.route("/me", get(get_me).put(update_me))
|
||||||
.nest("/me/api-keys", create_api_key_router())
|
.nest("/me/api-keys", create_api_key_router())
|
||||||
.route("/{param}", get(get_user_by_param))
|
.route("/{param}", get(get_user_by_param))
|
||||||
|
@@ -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))
|
|
||||||
}
|
|
@@ -38,6 +38,7 @@ pub async fn search_thoughts(
|
|||||||
// We must join with the user table to get the author's username
|
// We must join with the user table to get the author's username
|
||||||
let thoughts_with_authors = thought::Entity::find()
|
let thoughts_with_authors = thought::Entity::find()
|
||||||
.column_as(user::Column::Username, "author_username")
|
.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::User.def())
|
||||||
.filter(Expr::cust_with_values(
|
.filter(Expr::cust_with_values(
|
||||||
"thought.search_document @@ websearch_to_tsquery('english', $1)",
|
"thought.search_document @@ websearch_to_tsquery('english', $1)",
|
||||||
|
@@ -106,6 +106,7 @@ where
|
|||||||
thought_tag::Relation::Thought.def(),
|
thought_tag::Relation::Thought.def(),
|
||||||
)
|
)
|
||||||
.filter(thought::Column::CreatedAt.gte(seven_days_ago))
|
.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::Name)
|
||||||
.group_by(tag::Column::Id)
|
.group_by(tag::Column::Id)
|
||||||
.order_by_desc(Expr::col(Alias::new("count")))
|
.order_by_desc(Expr::col(Alias::new("count")))
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr,
|
prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr,
|
||||||
EntityTrait, JoinType, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
|
EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
|
||||||
TransactionTrait,
|
Set, TransactionTrait,
|
||||||
};
|
};
|
||||||
|
|
||||||
use models::{
|
use models::{
|
||||||
domains::{tag, thought, thought_tag, user},
|
domains::{tag, thought, thought_tag, user},
|
||||||
params::thought::CreateThoughtParams,
|
params::thought::CreateThoughtParams,
|
||||||
|
queries::pagination::PaginationQuery,
|
||||||
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,10 +36,12 @@ pub async fn create_thought(
|
|||||||
.insert(&txn)
|
.insert(&txn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let tag_names = parse_hashtags(¶ms.content);
|
if new_thought.visibility == thought::Visibility::Public {
|
||||||
if !tag_names.is_empty() {
|
let tag_names = parse_hashtags(¶ms.content);
|
||||||
let tags = find_or_create_tags(&txn, tag_names).await?;
|
if !tag_names.is_empty() {
|
||||||
link_tags_to_thought(&txn, new_thought.id, tags).await?;
|
let tags = find_or_create_tags(&txn, tag_names).await?;
|
||||||
|
link_tags_to_thought(&txn, new_thought.id, tags).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
@@ -100,6 +103,7 @@ pub async fn get_thoughts_by_user(
|
|||||||
.column(thought::Column::CreatedAt)
|
.column(thought::Column::CreatedAt)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
.column(thought::Column::Visibility)
|
.column(thought::Column::Visibility)
|
||||||
|
.column_as(user::Column::DisplayName, "author_display_name")
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
.filter(apply_visibility_filter(user_id, viewer_id, &friend_ids))
|
.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::Visibility)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.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::User.def())
|
||||||
.filter(
|
.filter(
|
||||||
Condition::any().add(following_ids.iter().fold(
|
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()))
|
.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(
|
pub async fn get_thoughts_by_tag_name(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
tag_name: &str,
|
tag_name: &str,
|
||||||
@@ -171,6 +243,7 @@ pub async fn get_thoughts_by_tag_name(
|
|||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
.column(thought::Column::Visibility)
|
.column(thought::Column::Visibility)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.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::User.def())
|
||||||
.join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def())
|
.join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def())
|
||||||
.join(JoinType::InnerJoin, thought_tag::Relation::Tag.def())
|
.join(JoinType::InnerJoin, thought_tag::Relation::Tag.def())
|
||||||
@@ -286,6 +359,7 @@ pub async fn get_thought_with_replies(
|
|||||||
ThoughtThreadSchema {
|
ThoughtThreadSchema {
|
||||||
id: thought_schema.id,
|
id: thought_schema.id,
|
||||||
author_username: thought_schema.author_username.clone(),
|
author_username: thought_schema.author_username.clone(),
|
||||||
|
author_display_name: thought_schema.author_display_name.clone(),
|
||||||
content: thought_schema.content.clone(),
|
content: thought_schema.content.clone(),
|
||||||
visibility: thought_schema.visibility.clone(),
|
visibility: thought_schema.visibility.clone(),
|
||||||
reply_to_id: thought_schema.reply_to_id,
|
reply_to_id: thought_schema.reply_to_id,
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
|
use models::queries::pagination::PaginationQuery;
|
||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
|
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait,
|
||||||
QuerySelect, RelationTrait, Set, TransactionTrait,
|
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
|
||||||
};
|
};
|
||||||
|
|
||||||
use models::domains::{top_friends, user};
|
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
|
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
|
||||||
|
}
|
||||||
|
@@ -3,6 +3,10 @@ name = "common"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "common"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
utoipa = { workspace = true }
|
utoipa = { workspace = true }
|
||||||
|
@@ -12,7 +12,4 @@ path = "src/lib.rs"
|
|||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
|
|
||||||
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
|
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
|
||||||
sea-orm-migration = { version = "1.1.12", features = [
|
sea-orm-migration = { version = "1.1.12", features = ["sqlx-postgres"] }
|
||||||
"sqlx-sqlite",
|
|
||||||
"sqlx-postgres",
|
|
||||||
] }
|
|
||||||
|
@@ -13,7 +13,6 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sea-orm = { workspace = true, features = [
|
sea-orm = { workspace = true, features = [
|
||||||
"sqlx-postgres",
|
"sqlx-postgres",
|
||||||
"sqlx-sqlite",
|
|
||||||
"runtime-tokio-rustls",
|
"runtime-tokio-rustls",
|
||||||
"macros",
|
"macros",
|
||||||
] }
|
] }
|
||||||
|
@@ -1 +1,2 @@
|
|||||||
|
pub mod pagination;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
27
thoughts-backend/models/src/queries/pagination.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
|
pub mod pagination;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
12
thoughts-backend/models/src/schemas/pagination.rs
Normal 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,
|
||||||
|
}
|
@@ -14,6 +14,7 @@ pub struct ThoughtSchema {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
#[schema(example = "frutiger")]
|
#[schema(example = "frutiger")]
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
|
pub author_display_name: Option<String>,
|
||||||
#[schema(example = "This is my first thought! #welcome")]
|
#[schema(example = "This is my first thought! #welcome")]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
@@ -26,6 +27,7 @@ impl ThoughtSchema {
|
|||||||
Self {
|
Self {
|
||||||
id: thought.id,
|
id: thought.id,
|
||||||
author_username: author.username.clone(),
|
author_username: author.username.clone(),
|
||||||
|
author_display_name: author.display_name.clone(),
|
||||||
content: thought.content.clone(),
|
content: thought.content.clone(),
|
||||||
visibility: thought.visibility.clone(),
|
visibility: thought.visibility.clone(),
|
||||||
reply_to_id: thought.reply_to_id,
|
reply_to_id: thought.reply_to_id,
|
||||||
@@ -54,6 +56,7 @@ pub struct ThoughtWithAuthor {
|
|||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
pub author_id: Uuid,
|
pub author_id: Uuid,
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
|
pub author_display_name: Option<String>,
|
||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
|
|||||||
Self {
|
Self {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
author_username: model.author_username,
|
author_username: model.author_username,
|
||||||
|
author_display_name: model.author_display_name,
|
||||||
content: model.content,
|
content: model.content,
|
||||||
created_at: model.created_at.into(),
|
created_at: model.created_at.into(),
|
||||||
reply_to_id: model.reply_to_id,
|
reply_to_id: model.reply_to_id,
|
||||||
@@ -75,6 +79,7 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
|
|||||||
pub struct ThoughtThreadSchema {
|
pub struct ThoughtThreadSchema {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
|
pub author_display_name: Option<String>,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
use api::{setup_config, setup_db, setup_router};
|
use api::{setup_config, setup_db, setup_router};
|
||||||
use utils::{create_dev_db, migrate};
|
use utils::migrate;
|
||||||
|
|
||||||
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
||||||
let conn = setup_db(db_url, prefork).await;
|
let conn = setup_db(db_url, prefork).await;
|
||||||
@@ -19,7 +19,6 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
|
|||||||
#[cfg(feature = "prefork")]
|
#[cfg(feature = "prefork")]
|
||||||
fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
|
fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
|
||||||
let db_url: &'static str = Box::leak(db_url.to_owned().into_boxed_str());
|
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 num_of_cores = std::thread::available_parallelism().unwrap().get() as u32;
|
||||||
let is_parent = prefork::Prefork::from_resource(listener)
|
let is_parent = prefork::Prefork::from_resource(listener)
|
||||||
@@ -34,8 +33,6 @@ fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_non_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();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(worker(0, db_url, false, listener));
|
rt.block_on(worker(0, db_url, false, listener));
|
||||||
}
|
}
|
||||||
|
@@ -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!"
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,7 +1,10 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::main::{create_user_with_password, setup};
|
use super::main::{create_user_with_password, setup};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
|
use tokio::time::sleep;
|
||||||
use utils::testing::make_jwt_request;
|
use utils::testing::make_jwt_request;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -59,9 +62,9 @@ async fn test_feed_and_user_thoughts() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
|
assert_eq!(v["items"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(v["thoughts"][0]["authorUsername"], "user1");
|
assert_eq!(v["items"][0]["authorUsername"], "user1");
|
||||||
assert_eq!(v["thoughts"][0]["content"], "A thought from user1");
|
assert_eq!(v["items"][0]["content"], "A thought from user1");
|
||||||
|
|
||||||
// 3. user1 follows user2
|
// 3. user1 follows user2
|
||||||
make_jwt_request(
|
make_jwt_request(
|
||||||
@@ -78,9 +81,193 @@ async fn test_feed_and_user_thoughts() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["thoughts"].as_array().unwrap().len(), 2);
|
assert_eq!(v["items"].as_array().unwrap().len(), 2);
|
||||||
assert_eq!(v["thoughts"][0]["authorUsername"], "user2");
|
assert_eq!(v["items"][0]["authorUsername"], "user2");
|
||||||
assert_eq!(v["thoughts"][0]["content"], "user2 was here");
|
assert_eq!(v["items"][0]["content"], "user2 was here");
|
||||||
assert_eq!(v["thoughts"][1]["authorUsername"], "user1");
|
assert_eq!(v["items"][1]["authorUsername"], "user1");
|
||||||
assert_eq!(v["thoughts"][1]["content"], "A thought from 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);
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
mod activitypub;
|
|
||||||
mod api_key;
|
mod api_key;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod feed;
|
mod feed;
|
||||||
|
@@ -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["headerUrl"], "https://example.com/new-header.jpg");
|
||||||
assert_eq!(v["customCss"], "body { color: blue; }");
|
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);
|
||||||
|
}
|
||||||
|
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,6 +1,4 @@
|
|||||||
mod db;
|
mod db;
|
||||||
mod file;
|
|
||||||
pub mod testing;
|
pub mod testing;
|
||||||
|
|
||||||
pub use db::migrate;
|
pub use db::migrate;
|
||||||
pub use file::create_dev_db;
|
|
||||||
|
@@ -1,30 +1,29 @@
|
|||||||
FROM oven/bun:1 AS base
|
FROM node:22-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS install
|
ARG NEXT_PUBLIC_API_URL
|
||||||
RUN mkdir -p /temp/dev
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
COPY package.json bun.lock /temp/dev/
|
|
||||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
RUN mkdir -p /temp/prod
|
# Install dependencies with Bun for speed
|
||||||
COPY package.json bun.lock /temp/prod/
|
COPY --chown=node:node package.json bun.lock ./
|
||||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
RUN npm install -g bun
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS prerelease
|
# Copy the rest of the app and build with Node's Next.js runtime
|
||||||
COPY --from=install /temp/dev/node_modules node_modules
|
COPY --chown=node:node . .
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
FROM base AS release
|
FROM node:22-slim AS release
|
||||||
|
|
||||||
COPY --from=prerelease /app/public ./public
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=prerelease /app/.next/standalone ./
|
|
||||||
COPY --from=prerelease /app/.next/static ./.next/static
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
USER bun
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
CMD ["bun", "run", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
|
@@ -42,7 +42,7 @@ export default function LoginPage() {
|
|||||||
const { token } = await loginUser(values);
|
const { token } = await loginUser(values);
|
||||||
setToken(token);
|
setToken(token);
|
||||||
router.push("/"); // Redirect to homepage on successful login
|
router.push("/"); // Redirect to homepage on successful login
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError("Invalid username or password.");
|
setError("Invalid username or password.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -40,7 +40,7 @@ export default function RegisterPage() {
|
|||||||
await registerUser(values);
|
await registerUser(values);
|
||||||
// You can automatically log the user in here or just redirect them
|
// You can automatically log the user in here or just redirect them
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError("Username or email may already be taken.");
|
setError("Username or email may already be taken.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -183,7 +183,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
background-image: url("/background.jpeg");
|
background-image: url("/background.avif");
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthProvider } from "@/hooks/use-auth";
|
import { AuthProvider } from "@/hooks/use-auth";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
import InstallPrompt from "@/components/install-prompt";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Thoughts",
|
title: "Thoughts",
|
||||||
@@ -38,6 +38,7 @@ export default function RootLayout({
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
<InstallPrompt />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
|
25
thoughts-frontend/app/manifest.ts
Normal 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
@@ -14,25 +14,51 @@ import { PopularTags } from "@/components/popular-tags";
|
|||||||
import { ThoughtThread } from "@/components/thought-thread";
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
import { buildThoughtThreads } from "@/lib/utils";
|
import { buildThoughtThreads } from "@/lib/utils";
|
||||||
import { TopFriends } from "@/components/top-friends";
|
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;
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
return <FeedPage token={token} />;
|
return <FeedPage token={token} searchParams={searchParams} />;
|
||||||
} else {
|
} else {
|
||||||
return <LandingPage />;
|
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([
|
const [feedData, me] = await Promise.all([
|
||||||
getFeed(token),
|
getFeed(token, page).catch(() => null),
|
||||||
getMe(token).catch(() => null) as Promise<Me | null>,
|
getMe(token).catch(() => null) as Promise<Me | null>,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allThoughts = feedData.thoughts;
|
if (!feedData || !me) {
|
||||||
const thoughtThreads = buildThoughtThreads(feedData.thoughts);
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items: allThoughts, totalPages } = feedData!;
|
||||||
|
const thoughtThreads = buildThoughtThreads(allThoughts);
|
||||||
|
|
||||||
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
||||||
const userProfiles = await Promise.all(
|
const userProfiles = await Promise.all(
|
||||||
@@ -46,7 +72,10 @@ async function FeedPage({ token }: { token: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const friends = (await getFriends(token)).users.map((user) => user.username);
|
const friends = (await getFriends(token)).users.map((user) => user.username);
|
||||||
const shouldDisplayTopFriends = me?.topFriends && me.topFriends.length > 0;
|
const shouldDisplayTopFriends =
|
||||||
|
token && me?.topFriends && me.topFriends.length > 8;
|
||||||
|
|
||||||
|
console.log("Should display top friends:", shouldDisplayTopFriends);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
||||||
@@ -63,6 +92,18 @@ async function FeedPage({ token }: { token: string }) {
|
|||||||
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
|
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
|
||||||
</header>
|
</header>
|
||||||
<PostThoughtForm />
|
<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">
|
<div className="space-y-6">
|
||||||
{thoughtThreads.map((thought) => (
|
{thoughtThreads.map((thought) => (
|
||||||
<ThoughtThread
|
<ThoughtThread
|
||||||
@@ -78,15 +119,34 @@ async function FeedPage({ token }: { token: string }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</main>
|
||||||
|
|
||||||
<aside className="hidden lg:block lg:col-span-1">
|
<aside className="hidden lg:block lg:col-span-1">
|
||||||
<div className="sticky top-20 space-y-6">
|
<div className="sticky top-20 space-y-6">
|
||||||
|
<PopularTags />
|
||||||
{shouldDisplayTopFriends && (
|
{shouldDisplayTopFriends && (
|
||||||
<TopFriends mode="top-friends" usernames={me.topFriends} />
|
<TopFriends mode="top-friends" usernames={me.topFriends} />
|
||||||
)}
|
)}
|
||||||
<PopularTags />
|
{!shouldDisplayTopFriends && token && friends.length > 0 && (
|
||||||
{token && <TopFriends mode="friends" usernames={friends || []} />}
|
<TopFriends mode="friends" usernames={friends || []} />
|
||||||
|
)}
|
||||||
|
<UsersCount />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,26 +156,28 @@ async function FeedPage({ token }: { token: string }) {
|
|||||||
|
|
||||||
function LandingPage() {
|
function LandingPage() {
|
||||||
return (
|
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="font-sans min-h-screen text-gray-800 flex items-center justify-center">
|
||||||
<h1
|
<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">
|
||||||
className="text-5xl font-bold"
|
<h1
|
||||||
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
|
className="text-5xl font-bold"
|
||||||
>
|
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
|
||||||
Welcome to Thoughts
|
>
|
||||||
</h1>
|
Welcome to Thoughts
|
||||||
<p className="text-muted-foreground mt-2">
|
</h1>
|
||||||
Your space on the decentralized web.
|
<p className="text-muted-foreground mt-2">
|
||||||
</p>
|
Throwback to the golden age of microblogging.
|
||||||
<div className="mt-8 flex justify-center gap-4">
|
</p>
|
||||||
<Button asChild>
|
<div className="mt-8 flex justify-center gap-4">
|
||||||
<Link href="/login">Login</Link>
|
<Button asChild>
|
||||||
</Button>
|
<Link href="/login">Login</Link>
|
||||||
<Button variant="secondary" asChild>
|
</Button>
|
||||||
<Link href="/register">Register</Link>
|
<Button variant="secondary" asChild>
|
||||||
</Button>
|
<Link href="/register">Register</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
63
thoughts-frontend/app/users/all/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -50,6 +50,7 @@
|
|||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tone": "^15.1.22",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.5",
|
"zod": "^4.1.5",
|
||||||
},
|
},
|
||||||
@@ -474,6 +475,8 @@
|
|||||||
|
|
||||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
127
thoughts-frontend/components/confetti.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@@ -36,7 +36,7 @@ export function FollowButton({
|
|||||||
setIsFollowing(!isFollowing);
|
setIsFollowing(!isFollowing);
|
||||||
await action(username, token);
|
await action(username, token);
|
||||||
router.refresh(); // Re-fetch server component data to get the latest follower count etc.
|
router.refresh(); // Re-fetch server component data to get the latest follower count etc.
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Revert on error
|
// Revert on error
|
||||||
setIsFollowing(isFollowing);
|
setIsFollowing(isFollowing);
|
||||||
toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} user.`);
|
toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} user.`);
|
||||||
|
119
thoughts-frontend/components/install-prompt.tsx
Normal 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}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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 "Add to Home Screen"
|
||||||
|
<span className="mx-1 text-lg">➕</span>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -8,15 +8,15 @@ import { SearchInput } from "./search-input";
|
|||||||
export function MainNav() {
|
export function MainNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
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
|
<Link
|
||||||
href="/"
|
href="/users/all"
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors hover:text-foreground/80",
|
"transition-colors hover:text-foreground/80",
|
||||||
pathname === "/" ? "text-foreground" : "text-foreground/60"
|
pathname === "/users/all" ? "text-foreground" : "text-foreground/60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Feed
|
Discover
|
||||||
</Link>
|
</Link>
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</nav>
|
</nav>
|
||||||
|
@@ -25,10 +25,13 @@ import { CreateThoughtSchema, createThought } from "@/lib/api";
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Globe, Lock, Users } from "lucide-react";
|
import { Globe, Lock, Users } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Confetti } from "./confetti";
|
||||||
|
|
||||||
export function PostThoughtForm() {
|
export function PostThoughtForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||||||
resolver: zodResolver(CreateThoughtSchema),
|
resolver: zodResolver(CreateThoughtSchema),
|
||||||
@@ -44,6 +47,7 @@ export function PostThoughtForm() {
|
|||||||
try {
|
try {
|
||||||
await createThought(values, token);
|
await createThought(values, token);
|
||||||
toast.success("Your thought has been posted!");
|
toast.success("Your thought has been posted!");
|
||||||
|
setShowConfetti(true);
|
||||||
form.reset();
|
form.reset();
|
||||||
router.refresh(); // This is the key to updating the feed
|
router.refresh(); // This is the key to updating the feed
|
||||||
} catch {
|
} catch {
|
||||||
@@ -52,67 +56,70 @@ export function PostThoughtForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<>
|
||||||
<CardContent className="p-4">
|
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
|
||||||
<Form {...form}>
|
<Card>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<CardContent className="p-4">
|
||||||
<FormField
|
<Form {...form}>
|
||||||
control={form.control}
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
name="content"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="What's on your mind?"
|
|
||||||
className="resize-none"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="visibility"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<FormItem>
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="w-[150px]">
|
<Textarea
|
||||||
<SelectValue placeholder="Visibility" />
|
placeholder="What's on your mind?"
|
||||||
</SelectTrigger>
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<FormMessage />
|
||||||
<SelectItem value="Public">
|
</FormItem>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Globe className="h-4 w-4" /> Public
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="FriendsOnly">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="h-4 w-4" /> Friends Only
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="Private">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Lock className="h-4 w-4" /> Private
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
<div className="flex justify-between items-center">
|
||||||
{form.formState.isSubmitting ? "Posting..." : "Post Thought"}
|
<FormField
|
||||||
</Button>
|
control={form.control}
|
||||||
</div>
|
name="visibility"
|
||||||
</form>
|
render={({ field }) => (
|
||||||
</Form>
|
<Select
|
||||||
</CardContent>
|
onValueChange={field.onChange}
|
||||||
</Card>
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue placeholder="Visibility" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Public">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4" /> Public
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="FriendsOnly">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4" /> Friends Only
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Private">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4" /> Private
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? "Posting..." : "Post Thought"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
// components/reply-form.tsx
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -17,6 +16,8 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { CreateThoughtSchema, createThought } from "@/lib/api";
|
import { CreateThoughtSchema, createThought } from "@/lib/api";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Confetti } from "./confetti";
|
||||||
|
|
||||||
interface ReplyFormProps {
|
interface ReplyFormProps {
|
||||||
parentThoughtId: string;
|
parentThoughtId: string;
|
||||||
@@ -26,6 +27,7 @@ interface ReplyFormProps {
|
|||||||
export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
|
export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||||||
resolver: zodResolver(CreateThoughtSchema),
|
resolver: zodResolver(CreateThoughtSchema),
|
||||||
@@ -46,45 +48,50 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
|
|||||||
await createThought(values, token);
|
await createThought(values, token);
|
||||||
toast.success("Your reply has been posted!");
|
toast.success("Your reply has been posted!");
|
||||||
form.reset();
|
form.reset();
|
||||||
onReplySuccess(); // Call the callback
|
setShowConfetti(true);
|
||||||
router.refresh(); // Refresh the page to show the new reply
|
console.log("Showing confetti");
|
||||||
} catch (err) {
|
onReplySuccess();
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
toast.error("Failed to post reply. Please try again.");
|
toast.error("Failed to post reply. Please try again.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 p-4">
|
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
|
||||||
<FormField
|
<Form {...form}>
|
||||||
control={form.control}
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 p-4">
|
||||||
name="content"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="content"
|
||||||
<FormControl>
|
render={({ field }) => (
|
||||||
<Textarea
|
<FormItem>
|
||||||
placeholder="Post your reply..."
|
<FormControl>
|
||||||
className="resize-none bg-white glass-effect glossy-efect bottom shadow-fa-sm"
|
<Textarea
|
||||||
{...field}
|
placeholder="Post your reply..."
|
||||||
/>
|
className="resize-none bg-white glass-effect glossy-efect bottom shadow-fa-sm"
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
<div className="flex justify-end gap-2">
|
)}
|
||||||
<Button
|
/>
|
||||||
type="button"
|
<div className="flex justify-end gap-2">
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={onReplySuccess} // Close button
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
Cancel
|
onClick={onReplySuccess} // Close button
|
||||||
</Button>
|
>
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
Cancel
|
||||||
{form.formState.isSubmitting ? "Replying..." : "Reply"}
|
</Button>
|
||||||
</Button>
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
</div>
|
{form.formState.isSubmitting ? "Replying..." : "Reply"}
|
||||||
</form>
|
</Button>
|
||||||
</Form>
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -44,6 +44,7 @@ interface ThoughtCardProps {
|
|||||||
thought: Thought;
|
thought: Thought;
|
||||||
author: {
|
author: {
|
||||||
username: string;
|
username: string;
|
||||||
|
displayName?: string | null;
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
};
|
};
|
||||||
currentUser: Me | null;
|
currentUser: Me | null;
|
||||||
@@ -112,9 +113,14 @@ export function ThoughtCard({
|
|||||||
href={`/users/${author.username}`}
|
href={`/users/${author.username}`}
|
||||||
className="flex items-center gap-4 text-shadow-md"
|
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">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold">{author.username}</span>
|
<span className="font-bold">
|
||||||
|
{author.displayName || author.username}
|
||||||
|
</span>
|
||||||
<span className="text-sm text-muted-foreground text-shadow-sm">
|
<span className="text-sm text-muted-foreground text-shadow-sm">
|
||||||
{timeAgo}
|
{timeAgo}
|
||||||
</span>
|
</span>
|
||||||
|
@@ -28,7 +28,7 @@ export function ThoughtList({
|
|||||||
{thoughts.map((thought) => {
|
{thoughts.map((thought) => {
|
||||||
const author = {
|
const author = {
|
||||||
username: thought.authorUsername,
|
username: thought.authorUsername,
|
||||||
avatarUrl: null,
|
displayName: thought.authorDisplayName,
|
||||||
...authorDetails.get(thought.authorUsername),
|
...authorDetails.get(thought.authorUsername),
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
@@ -16,7 +16,7 @@ export function ThoughtThread({
|
|||||||
}: ThoughtThreadProps) {
|
}: ThoughtThreadProps) {
|
||||||
const author = {
|
const author = {
|
||||||
username: thought.authorUsername,
|
username: thought.authorUsername,
|
||||||
avatarUrl: null,
|
displayName: thought.authorDisplayName,
|
||||||
...authorDetails.get(thought.authorUsername),
|
...authorDetails.get(thought.authorUsername),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export function ThoughtThread({
|
|||||||
className="pl-6 border-l-2 border-primary border-dashed ml-6 flex flex-col gap-4 pt-4"
|
className="pl-6 border-l-2 border-primary border-dashed ml-6 flex flex-col gap-4 pt-4"
|
||||||
>
|
>
|
||||||
{thought.replies.map((reply) => (
|
{thought.replies.map((reply) => (
|
||||||
<ThoughtThread // RECURSIVE CALL
|
<ThoughtThread
|
||||||
key={reply.id}
|
key={reply.id}
|
||||||
thought={reply}
|
thought={reply}
|
||||||
authorDetails={authorDetails}
|
authorDetails={authorDetails}
|
||||||
|
@@ -17,7 +17,7 @@ const buttonVariants = cva(
|
|||||||
"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",
|
"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 and Link should be more subtle
|
||||||
ghost:
|
ghost:
|
||||||
"glass-effect hover:bg-accent hover:text-accent-foreground rounded-lg", // Keep them simple, maybe a slight blur/gloss on hover
|
"glass-effect hover:bg-accent hover:text-accent-foreground rounded-lg",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
// Outline button for a transparent-ish, glassy feel
|
// Outline button for a transparent-ish, glassy feel
|
||||||
outline:
|
outline:
|
||||||
|
69
thoughts-frontend/components/users-count.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Link } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { getAllUsersCount } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function UsersCount() {
|
||||||
|
const usersCount = await getAllUsersCount().catch(() => null);
|
||||||
|
|
||||||
|
if (usersCount === null) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<CardHeader className="p-0 pb-2">
|
||||||
|
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Total number of registered users on Thoughts.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-muted-foreground text-sm text-center py-4">
|
||||||
|
Could not load users count.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usersCount.count === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<CardHeader className="p-0 pb-2">
|
||||||
|
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Total number of registered users on Thoughts.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-muted-foreground text-sm text-center py-4">
|
||||||
|
No registered users yet. Be the first to{" "}
|
||||||
|
<Link href="/signup" className="text-primary hover:underline">
|
||||||
|
sign up
|
||||||
|
</Link>
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<CardHeader className="p-0 pb-2">
|
||||||
|
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Total number of registered users on Thoughts.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-muted-foreground text-sm text-center py-4">
|
||||||
|
{usersCount.count} registered users.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@@ -28,6 +28,7 @@ export const MeSchema = z.object({
|
|||||||
export const ThoughtSchema = z.object({
|
export const ThoughtSchema = z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
authorUsername: z.string(),
|
authorUsername: z.string(),
|
||||||
|
authorDisplayName: z.string().nullable(),
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
visibility: z.enum(["Public", "FriendsOnly", "Private"]),
|
visibility: z.enum(["Public", "FriendsOnly", "Private"]),
|
||||||
replyToId: z.uuid().nullable(),
|
replyToId: z.uuid().nullable(),
|
||||||
@@ -87,6 +88,7 @@ export const CreateApiKeySchema = z.object({
|
|||||||
export const ThoughtThreadSchema: z.ZodType<{
|
export const ThoughtThreadSchema: z.ZodType<{
|
||||||
id: string;
|
id: string;
|
||||||
authorUsername: string;
|
authorUsername: string;
|
||||||
|
authorDisplayName: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
visibility: "Public" | "FriendsOnly" | "Private";
|
visibility: "Public" | "FriendsOnly" | "Private";
|
||||||
replyToId: string | null;
|
replyToId: string | null;
|
||||||
@@ -95,6 +97,7 @@ export const ThoughtThreadSchema: z.ZodType<{
|
|||||||
}> = z.object({
|
}> = z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
authorUsername: z.string(),
|
authorUsername: z.string(),
|
||||||
|
authorDisplayName: z.string().nullable(),
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
visibility: z.enum(["Public", "FriendsOnly", "Private"]),
|
visibility: z.enum(["Public", "FriendsOnly", "Private"]),
|
||||||
replyToId: z.uuid().nullable(),
|
replyToId: z.uuid().nullable(),
|
||||||
@@ -111,7 +114,10 @@ export type ApiKey = z.infer<typeof ApiKeySchema>;
|
|||||||
export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>;
|
export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>;
|
||||||
export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>;
|
export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>;
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
const API_BASE_URL =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? process.env.NEXT_PUBLIC_SERVER_SIDE_API_URL // Server-side
|
||||||
|
: process.env.NEXT_PUBLIC_API_URL; // Client-side
|
||||||
|
|
||||||
async function apiFetch<T>(
|
async function apiFetch<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
@@ -119,6 +125,10 @@ async function apiFetch<T>(
|
|||||||
schema: z.ZodType<T>,
|
schema: z.ZodType<T>,
|
||||||
token?: string | null
|
token?: string | null
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
if (!API_BASE_URL) {
|
||||||
|
throw new Error("API_BASE_URL is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(options.headers as Record<string, string>),
|
...(options.headers as Record<string, string>),
|
||||||
@@ -128,7 +138,8 @@ async function apiFetch<T>(
|
|||||||
headers["Authorization"] = `Bearer ${token}`;
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const fullUrl = `${API_BASE_URL}${endpoint}`;
|
||||||
|
const response = await fetch(fullUrl, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
@@ -157,15 +168,14 @@ export const loginUser = (data: z.infer<typeof LoginSchema>) =>
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}, z.object({ token: z.string() }));
|
}, z.object({ token: z.string() }));
|
||||||
|
|
||||||
export const getFeed = (token: string) =>
|
export const getFeed = (token: string, page: number = 1, pageSize: number = 20) =>
|
||||||
apiFetch(
|
apiFetch(
|
||||||
"/feed",
|
`/feed?page=${page}&page_size=${pageSize}`,
|
||||||
{},
|
{},
|
||||||
z.object({ thoughts: z.array(ThoughtSchema) }),
|
z.object({ items: z.array(ThoughtSchema), totalPages: z.number() }),
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- User API Functions ---
|
|
||||||
export const getUserProfile = (username: string, token: string | null) =>
|
export const getUserProfile = (username: string, token: string | null) =>
|
||||||
apiFetch(`/users/${username}`, {}, UserSchema, token);
|
apiFetch(`/users/${username}`, {}, UserSchema, token);
|
||||||
|
|
||||||
@@ -317,3 +327,26 @@ export const deleteApiKey = (keyId: string, token: string) =>
|
|||||||
|
|
||||||
export const getThoughtThread = (thoughtId: string, token: string | null) =>
|
export const getThoughtThread = (thoughtId: string, token: string | null) =>
|
||||||
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
|
apiFetch(`/thoughts/${thoughtId}/thread`, {}, ThoughtThreadSchema, token);
|
||||||
|
|
||||||
|
|
||||||
|
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
|
||||||
|
apiFetch(
|
||||||
|
`/users/all?page=${page}&page_size=${pageSize}`,
|
||||||
|
{},
|
||||||
|
z.object({
|
||||||
|
items: z.array(UserSchema),
|
||||||
|
page: z.number(),
|
||||||
|
pageSize: z.number(),
|
||||||
|
totalPages: z.number(),
|
||||||
|
totalItems: z.number(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getAllUsersCount = () =>
|
||||||
|
apiFetch(
|
||||||
|
`/users/count`,
|
||||||
|
{},
|
||||||
|
z.object({
|
||||||
|
count: z.number(),
|
||||||
|
})
|
||||||
|
);
|
@@ -55,6 +55,7 @@
|
|||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tone": "^15.1.22",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
|
BIN
thoughts-frontend/public/background.avif
Normal file
After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 1.3 MiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
Before Width: | Height: | Size: 1.0 KiB |
BIN
thoughts-frontend/public/icon-128x128.webp
Normal file
After Width: | Height: | Size: 4.1 KiB |
BIN
thoughts-frontend/public/icon-144x144.webp
Normal file
After Width: | Height: | Size: 4.8 KiB |
BIN
thoughts-frontend/public/icon-152x152.webp
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
thoughts-frontend/public/icon-192x192.webp
Normal file
After Width: | Height: | Size: 7.0 KiB |
BIN
thoughts-frontend/public/icon-256x256.webp
Normal file
After Width: | Height: | Size: 9.7 KiB |
BIN
thoughts-frontend/public/icon-384x384.webp
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
thoughts-frontend/public/icon-48x48.webp
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
thoughts-frontend/public/icon-512x512.webp
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
thoughts-frontend/public/icon-72x72.webp
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
thoughts-frontend/public/icon-96x96.webp
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
thoughts-frontend/public/icon.avif
Normal file
After Width: | Height: | Size: 9.2 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
Before Width: | Height: | Size: 385 B |