Compare commits
76 Commits
6aef739438
...
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 | |||
5ce6d9f2da | |||
40695b7ad3 | |||
b337184a59 | |||
862974bb35 | |||
8b14ab06a2 | |||
e1b5a2aaa0 | |||
c9b8bd7b07 | |||
69eb225c1e | |||
c3539cfc11 | |||
f1e891413a | |||
c520690f1e | |||
8ddbf45a09 | |||
dc92945962 | |||
bf7c6501c6 | |||
85e3425d4b | |||
5344e0d6a8 | |||
8b82a5e48e | |||
bf2e280cdd | |||
8a4c07b3f6 | |||
19520c832f | |||
fc7dacc6fb | |||
7348433b9c | |||
8552858c8c | |||
c7cb3f537d | |||
e7cf76a0d8 | |||
38e107ad59 |
7
.env
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
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
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
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
|
700
thoughts-backend/Cargo.lock
generated
700
thoughts-backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -8,7 +8,6 @@ use app::state::AppState;
|
|||||||
|
|
||||||
use crate::routers::create_router;
|
use crate::routers::create_router;
|
||||||
|
|
||||||
// TODO: middleware, logging, authentication
|
|
||||||
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
|
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
|
||||||
create_router(AppState {
|
create_router(AppState {
|
||||||
conn,
|
conn,
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
mod error;
|
mod error;
|
||||||
mod extractor;
|
mod extractor;
|
||||||
mod federation;
|
|
||||||
mod init;
|
mod init;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
|
@@ -16,7 +16,7 @@ use sea_orm::prelude::Uuid;
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/me/api-keys",
|
path = "",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
|
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
|
||||||
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
@@ -36,7 +36,7 @@ async fn get_keys(
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/me/api-keys",
|
path = "",
|
||||||
request_body = ApiKeyRequest,
|
request_body = ApiKeyRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 201, description = "API key created", body = ApiKeyResponse),
|
(status = 201, description = "API key created", body = ApiKeyResponse),
|
||||||
@@ -63,7 +63,7 @@ async fn create_key(
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
delete,
|
||||||
path = "/me/api-keys/{key_id}",
|
path = "/{key_id}",
|
||||||
responses(
|
responses(
|
||||||
(status = 204, description = "API key deleted"),
|
(status = 204, description = "API key deleted"),
|
||||||
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
|
@@ -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> {
|
||||||
|
24
thoughts-backend/api/src/routers/friends.rs
Normal file
24
thoughts-backend/api/src/routers/friends.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
use app::{persistence::user, state::AppState};
|
||||||
|
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
||||||
|
use models::schemas::user::UserListSchema;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List of authenticated user's friends", body = UserListSchema)
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = []))
|
||||||
|
)]
|
||||||
|
async fn get_friends_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let friends = user::get_friends(&state.conn, auth_user.id).await?;
|
||||||
|
Ok(Json(UserListSchema::from(friends)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_friends_router() -> Router<AppState> {
|
||||||
|
Router::new().route("/", get(get_friends_list))
|
||||||
|
}
|
@@ -3,13 +3,14 @@ use axum::Router;
|
|||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
|
pub mod friends;
|
||||||
pub mod root;
|
pub mod root;
|
||||||
|
pub mod search;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
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;
|
||||||
@@ -22,12 +23,13 @@ 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())
|
||||||
.nest("/feed", create_feed_router())
|
.nest("/feed", create_feed_router())
|
||||||
.nest("/tags", tag::create_tag_router())
|
.nest("/tags", tag::create_tag_router())
|
||||||
|
.nest("/friends", friends::create_friends_router())
|
||||||
|
.nest("/search", search::create_search_router())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
}
|
}
|
||||||
|
@@ -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))
|
||||||
}
|
}
|
||||||
|
53
thoughts-backend/api/src/routers/search.rs
Normal file
53
thoughts-backend/api/src/routers/search.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use crate::{error::ApiError, extractor::OptionalAuthUser};
|
||||||
|
use app::{persistence::search, state::AppState};
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use models::schemas::{
|
||||||
|
search::SearchResultsSchema,
|
||||||
|
thought::{ThoughtListSchema, ThoughtSchema},
|
||||||
|
user::UserListSchema,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::IntoParams;
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
q: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "",
|
||||||
|
params(SearchQuery),
|
||||||
|
responses((status = 200, body = SearchResultsSchema))
|
||||||
|
)]
|
||||||
|
async fn search_all(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
Query(query): Query<SearchQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
|
||||||
|
let (users, thoughts) = tokio::try_join!(
|
||||||
|
search::search_users(&state.conn, &query.q),
|
||||||
|
search::search_thoughts(&state.conn, &query.q, viewer_id)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let thought_schemas: Vec<ThoughtSchema> =
|
||||||
|
thoughts.into_iter().map(ThoughtSchema::from).collect();
|
||||||
|
|
||||||
|
let response = SearchResultsSchema {
|
||||||
|
users: UserListSchema::from(users),
|
||||||
|
thoughts: ThoughtListSchema::from(thought_schemas),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_search_router() -> Router<AppState> {
|
||||||
|
Router::new().route("/", get(search_all))
|
||||||
|
}
|
@@ -2,7 +2,7 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{delete, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,16 +11,47 @@ use app::{
|
|||||||
persistence::thought::{create_thought, delete_thought, get_thought},
|
persistence::thought::{create_thought, delete_thought, get_thought},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
use models::{
|
||||||
|
params::thought::CreateThoughtParams,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
|
||||||
|
};
|
||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
extractor::{AuthUser, Json, Valid},
|
extractor::{AuthUser, Json, OptionalAuthUser, Valid},
|
||||||
federation,
|
|
||||||
models::{ApiErrorResponse, ParamsErrorResponse},
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{id}",
|
||||||
|
params(
|
||||||
|
("id" = Uuid, Path, description = "Thought ID")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Thought found", body = ThoughtSchema),
|
||||||
|
(status = 404, description = "Not Found", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_thought_by_id(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
let thought = get_thought(&state.conn, id, viewer_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let author = app::persistence::user::get_user(&state.conn, thought.author_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let schema = ThoughtSchema::from_models(&thought, &author);
|
||||||
|
Ok(Json(schema))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "",
|
path = "",
|
||||||
@@ -45,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)))
|
||||||
}
|
}
|
||||||
@@ -77,7 +101,7 @@ async fn thoughts_delete(
|
|||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let thought = get_thought(&state.conn, id)
|
let thought = get_thought(&state.conn, id, Some(auth_user.id))
|
||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
@@ -89,8 +113,33 @@ async fn thoughts_delete(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{id}/thread",
|
||||||
|
params(
|
||||||
|
("id" = Uuid, Path, description = "Thought ID")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Thought thread found", body = ThoughtThreadSchema),
|
||||||
|
(status = 404, description = "Not Found", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_thought_thread(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
let thread = app::persistence::thought::get_thought_with_replies(&state.conn, id, viewer_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
Ok(Json(thread))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_thought_router() -> Router<AppState> {
|
pub fn create_thought_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", post(thoughts_post))
|
.route("/", post(thoughts_post))
|
||||||
.route("/{id}", delete(thoughts_delete))
|
.route("/{id}/thread", get(get_thought_thread))
|
||||||
|
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
|
||||||
}
|
}
|
||||||
|
@@ -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_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::{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};
|
||||||
@@ -327,7 +335,7 @@ async fn user_outbox_get(
|
|||||||
get,
|
get,
|
||||||
path = "/me",
|
path = "/me",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Authenticated user's profile", body = UserSchema)
|
(status = 200, description = "Authenticated user's full profile", body = MeSchema)
|
||||||
),
|
),
|
||||||
security(
|
security(
|
||||||
("bearer_auth" = [])
|
("bearer_auth" = [])
|
||||||
@@ -342,7 +350,21 @@ async fn get_me(
|
|||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
let top_friends = app::persistence::user::get_top_friends(&state.conn, auth_user.id).await?;
|
let top_friends = app::persistence::user::get_top_friends(&state.conn, auth_user.id).await?;
|
||||||
|
|
||||||
Ok(axum::Json(UserSchema::from((user, top_friends))))
|
let following = get_following(&state.conn, auth_user.id).await?;
|
||||||
|
|
||||||
|
let response = MeSchema {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
bio: user.bio,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
header_url: user.header_url,
|
||||||
|
custom_css: user.custom_css,
|
||||||
|
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
|
||||||
|
joined_at: user.created_at.into(),
|
||||||
|
following: following.into_iter().map(UserSchema::from).collect(),
|
||||||
|
};
|
||||||
|
Ok(axum::Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -367,13 +389,84 @@ async fn update_me(
|
|||||||
Ok(axum::Json(UserSchema::from(updated_user)))
|
Ok(axum::Json(UserSchema::from(updated_user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{username}/following",
|
||||||
|
responses((status = 200, body = UserListSchema))
|
||||||
|
)]
|
||||||
|
async fn get_user_following(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
let following_list = get_following(&state.conn, user.id).await?;
|
||||||
|
Ok(Json(UserListSchema::from(following_list)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{username}/followers",
|
||||||
|
responses((status = 200, body = UserListSchema))
|
||||||
|
)]
|
||||||
|
async fn get_user_followers(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
let followers_list = get_followers(&state.conn, user.id).await?;
|
||||||
|
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))
|
||||||
.route("/{username}/thoughts", get(user_thoughts_get))
|
.route("/{username}/thoughts", get(user_thoughts_get))
|
||||||
|
.route("/{username}/followers", get(get_user_followers))
|
||||||
|
.route("/{username}/following", get(get_user_following))
|
||||||
.route(
|
.route(
|
||||||
"/{username}/follow",
|
"/{username}/follow",
|
||||||
post(user_follow_post).delete(user_follow_delete),
|
post(user_follow_post).delete(user_follow_delete),
|
||||||
|
@@ -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))
|
|
||||||
}
|
|
@@ -1,6 +1,7 @@
|
|||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
|
pub mod search;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
66
thoughts-backend/app/src/persistence/search.rs
Normal file
66
thoughts-backend/app/src/persistence/search.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use models::{
|
||||||
|
domains::{thought, user},
|
||||||
|
schemas::thought::ThoughtWithAuthor,
|
||||||
|
};
|
||||||
|
use sea_orm::{
|
||||||
|
prelude::{Expr, Uuid},
|
||||||
|
DatabaseConnection, DbErr, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait,
|
||||||
|
Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::persistence::follow;
|
||||||
|
|
||||||
|
fn is_visible(
|
||||||
|
author_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
friend_ids: &[Uuid],
|
||||||
|
visibility: &thought::Visibility,
|
||||||
|
) -> bool {
|
||||||
|
match visibility {
|
||||||
|
thought::Visibility::Public => true,
|
||||||
|
thought::Visibility::Private => viewer_id.map_or(false, |v| v == author_id),
|
||||||
|
thought::Visibility::FriendsOnly => {
|
||||||
|
viewer_id.map_or(false, |v| v == author_id || friend_ids.contains(&author_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_thoughts(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
query: &str,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
|
let mut friend_ids = Vec::new();
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We must join with the user table to get the author's username
|
||||||
|
let thoughts_with_authors = thought::Entity::find()
|
||||||
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.column_as(user::Column::DisplayName, "author_display_name")
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(Expr::cust_with_values(
|
||||||
|
"thought.search_document @@ websearch_to_tsquery('english', $1)",
|
||||||
|
[Value::from(query)],
|
||||||
|
))
|
||||||
|
.into_model::<ThoughtWithAuthor>() // Convert directly in the query
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Apply visibility filtering in Rust after the search
|
||||||
|
Ok(thoughts_with_authors
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| is_visible(t.author_id, viewer_id, &friend_ids, &t.visibility))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_users(db: &DatabaseConnection, query: &str) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
user::Entity::find()
|
||||||
|
.filter(Expr::cust_with_values(
|
||||||
|
"\"user\".search_document @@ websearch_to_tsquery('english', $1)",
|
||||||
|
[Value::from(query)],
|
||||||
|
))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
@@ -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,13 +1,14 @@
|
|||||||
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,
|
||||||
schemas::thought::ThoughtWithAuthor,
|
queries::pagination::PaginationQuery,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -35,18 +36,48 @@ pub async fn create_thought(
|
|||||||
.insert(&txn)
|
.insert(&txn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if new_thought.visibility == thought::Visibility::Public {
|
||||||
let tag_names = parse_hashtags(¶ms.content);
|
let tag_names = parse_hashtags(¶ms.content);
|
||||||
if !tag_names.is_empty() {
|
if !tag_names.is_empty() {
|
||||||
let tags = find_or_create_tags(&txn, tag_names).await?;
|
let tags = find_or_create_tags(&txn, tag_names).await?;
|
||||||
link_tags_to_thought(&txn, new_thought.id, tags).await?;
|
link_tags_to_thought(&txn, new_thought.id, tags).await?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
Ok(new_thought)
|
Ok(new_thought)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_thought(db: &DbConn, thought_id: Uuid) -> Result<Option<thought::Model>, DbErr> {
|
pub async fn get_thought(
|
||||||
thought::Entity::find_by_id(thought_id).one(db).await
|
db: &DbConn,
|
||||||
|
thought_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Option<thought::Model>, DbErr> {
|
||||||
|
let thought = thought::Entity::find_by_id(thought_id).one(db).await?;
|
||||||
|
|
||||||
|
match thought {
|
||||||
|
Some(t) => {
|
||||||
|
if t.visibility == thought::Visibility::Public {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
if t.author_id == viewer {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.visibility == thought::Visibility::FriendsOnly {
|
||||||
|
let author_friends = follow::get_friend_ids(db, t.author_id).await?;
|
||||||
|
if author_friends.contains(&viewer) {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> {
|
pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> {
|
||||||
@@ -72,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))
|
||||||
@@ -107,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(
|
||||||
@@ -124,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,
|
||||||
@@ -143,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())
|
||||||
@@ -173,7 +274,7 @@ pub async fn get_thoughts_by_tag_name(
|
|||||||
Ok(visible_thoughts)
|
Ok(visible_thoughts)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_visibility_filter(
|
pub fn apply_visibility_filter(
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
viewer_id: Option<Uuid>,
|
viewer_id: Option<Uuid>,
|
||||||
friend_ids: &[Uuid],
|
friend_ids: &[Uuid],
|
||||||
@@ -182,17 +283,104 @@ fn apply_visibility_filter(
|
|||||||
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
|
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
|
||||||
|
|
||||||
if let Some(viewer) = viewer_id {
|
if let Some(viewer) = viewer_id {
|
||||||
// Viewers can see their own thoughts of any visibility
|
|
||||||
if user_id == viewer {
|
if user_id == viewer {
|
||||||
condition = condition
|
condition = condition
|
||||||
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
|
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
|
||||||
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
|
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
|
||||||
}
|
} else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
|
||||||
// If the thought's author is a friend of the viewer, they can see it
|
|
||||||
else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
|
|
||||||
condition =
|
condition =
|
||||||
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
|
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
condition.into()
|
condition.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_thought_with_replies(
|
||||||
|
db: &DbConn,
|
||||||
|
thought_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Option<ThoughtThreadSchema>, DbErr> {
|
||||||
|
let root_thought = match get_thought(db, thought_id, viewer_id).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut all_thoughts_in_thread = vec![root_thought.clone()];
|
||||||
|
let mut ids_to_fetch = vec![root_thought.id];
|
||||||
|
let mut friend_ids = vec![];
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
while !ids_to_fetch.is_empty() {
|
||||||
|
let replies = thought::Entity::find()
|
||||||
|
.filter(thought::Column::ReplyToId.is_in(ids_to_fetch))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if replies.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids_to_fetch = replies.iter().map(|r| r.id).collect();
|
||||||
|
all_thoughts_in_thread.extend(replies);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut thought_schemas = vec![];
|
||||||
|
for thought in all_thoughts_in_thread {
|
||||||
|
if let Some(author) = user::Entity::find_by_id(thought.author_id).one(db).await? {
|
||||||
|
let is_visible = match thought.visibility {
|
||||||
|
thought::Visibility::Public => true,
|
||||||
|
thought::Visibility::Private => viewer_id.map_or(false, |v| v == thought.author_id),
|
||||||
|
thought::Visibility::FriendsOnly => viewer_id.map_or(false, |v| {
|
||||||
|
v == thought.author_id || friend_ids.contains(&thought.author_id)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_visible {
|
||||||
|
thought_schemas.push(ThoughtSchema::from_models(&thought, &author));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_thread(
|
||||||
|
thought_id: Uuid,
|
||||||
|
schemas_map: &std::collections::HashMap<Uuid, ThoughtSchema>,
|
||||||
|
replies_map: &std::collections::HashMap<Uuid, Vec<Uuid>>,
|
||||||
|
) -> Option<ThoughtThreadSchema> {
|
||||||
|
schemas_map.get(&thought_id).map(|thought_schema| {
|
||||||
|
let replies = replies_map
|
||||||
|
.get(&thought_id)
|
||||||
|
.unwrap_or(&vec![])
|
||||||
|
.iter()
|
||||||
|
.filter_map(|reply_id| build_thread(*reply_id, schemas_map, replies_map))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ThoughtThreadSchema {
|
||||||
|
id: thought_schema.id,
|
||||||
|
author_username: thought_schema.author_username.clone(),
|
||||||
|
author_display_name: thought_schema.author_display_name.clone(),
|
||||||
|
content: thought_schema.content.clone(),
|
||||||
|
visibility: thought_schema.visibility.clone(),
|
||||||
|
reply_to_id: thought_schema.reply_to_id,
|
||||||
|
created_at: thought_schema.created_at.clone(),
|
||||||
|
replies,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let schemas_map: std::collections::HashMap<Uuid, ThoughtSchema> =
|
||||||
|
thought_schemas.into_iter().map(|s| (s.id, s)).collect();
|
||||||
|
|
||||||
|
let mut replies_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for thought in schemas_map.values() {
|
||||||
|
if let Some(parent_id) = thought.reply_to_id {
|
||||||
|
if schemas_map.contains_key(&parent_id) {
|
||||||
|
replies_map.entry(parent_id).or_default().push(thought.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_thread(root_thought.id, &schemas_map, &replies_map))
|
||||||
|
}
|
||||||
|
@@ -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};
|
||||||
@@ -9,6 +10,7 @@ use models::params::user::{CreateUserParams, UpdateUserParams};
|
|||||||
use models::queries::user::UserQuery;
|
use models::queries::user::UserQuery;
|
||||||
|
|
||||||
use crate::error::UserError;
|
use crate::error::UserError;
|
||||||
|
use crate::persistence::follow::{get_follower_ids, get_following_ids, get_friend_ids};
|
||||||
|
|
||||||
pub async fn create_user(
|
pub async fn create_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
@@ -131,9 +133,54 @@ pub async fn update_user_profile(
|
|||||||
|
|
||||||
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
user::Entity::find()
|
user::Entity::find()
|
||||||
.join(JoinType::InnerJoin, top_friends::Relation::User.def().rev())
|
.join(
|
||||||
|
JoinType::InnerJoin,
|
||||||
|
top_friends::Relation::Friend.def().rev(),
|
||||||
|
)
|
||||||
.filter(top_friends::Column::UserId.eq(user_id))
|
.filter(top_friends::Column::UserId.eq(user_id))
|
||||||
.order_by_asc(top_friends::Column::Position)
|
.order_by_asc(top_friends::Column::Position)
|
||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
let friend_ids = get_friend_ids(db, user_id).await?;
|
||||||
|
if friend_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
get_users_by_ids(db, friend_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_following(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
let following_ids = get_following_ids(db, user_id).await?;
|
||||||
|
if following_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
get_users_by_ids(db, following_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_followers(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
let follower_ids = get_follower_ids(db, user_id).await?;
|
||||||
|
if follower_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
@@ -5,7 +5,7 @@ use sea_query::ValueTypeErr;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, Debug)]
|
#[derive(Serialize, ToSchema, Debug, Clone)]
|
||||||
#[schema(example = "2025-09-05T12:34:56Z")]
|
#[schema(example = "2025-09-05T12:34:56Z")]
|
||||||
pub struct DateTimeWithTimeZoneWrapper(String);
|
pub struct DateTimeWithTimeZoneWrapper(String);
|
||||||
|
|
||||||
|
@@ -10,6 +10,7 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
utoipa = { workspace = true, features = ["axum_extras"] }
|
utoipa = { workspace = true, features = ["axum_extras"] }
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = [
|
utoipa-swagger-ui = { version = "9.0.2", features = [
|
||||||
"axum",
|
"axum",
|
||||||
@@ -19,5 +20,5 @@ utoipa-scalar = { version = "0.3.0", features = [
|
|||||||
"axum",
|
"axum",
|
||||||
], default-features = false }
|
], default-features = false }
|
||||||
|
|
||||||
api = { path = "../api" }
|
# api = { path = "../api" }
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
|
12
thoughts-backend/doc/src/friends.rs
Normal file
12
thoughts-backend/doc/src/friends.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
use api::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||||
|
use api::routers::friends::*;
|
||||||
|
use models::schemas::user::{UserListSchema, UserSchema};
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(get_friends_list,),
|
||||||
|
components(schemas(UserListSchema, ApiErrorResponse, ParamsErrorResponse, UserSchema))
|
||||||
|
)]
|
||||||
|
pub(super) struct FriendsApi;
|
@@ -6,25 +6,8 @@ use utoipa::{
|
|||||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
mod api_key;
|
|
||||||
mod auth;
|
|
||||||
mod feed;
|
|
||||||
mod root;
|
|
||||||
mod tag;
|
|
||||||
mod thought;
|
|
||||||
mod user;
|
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
nest(
|
|
||||||
(path = "/", api = root::RootApi),
|
|
||||||
(path = "/auth", api = auth::AuthApi),
|
|
||||||
(path = "/users", api = user::UserApi),
|
|
||||||
(path = "/users/me/api-keys", api = api_key::ApiKeyApi),
|
|
||||||
(path = "/thoughts", api = thought::ThoughtApi),
|
|
||||||
(path = "/feed", api = feed::FeedApi),
|
|
||||||
(path = "/tags", api = tag::TagApi),
|
|
||||||
),
|
|
||||||
tags(
|
tags(
|
||||||
(name = "root", description = "Root API"),
|
(name = "root", description = "Root API"),
|
||||||
(name = "auth", description = "Authentication API"),
|
(name = "auth", description = "Authentication API"),
|
||||||
@@ -32,6 +15,8 @@ mod user;
|
|||||||
(name = "thought", description = "Thoughts API"),
|
(name = "thought", description = "Thoughts API"),
|
||||||
(name = "feed", description = "Feed API"),
|
(name = "feed", description = "Feed API"),
|
||||||
(name = "tag", description = "Tag Discovery API"),
|
(name = "tag", description = "Tag Discovery API"),
|
||||||
|
(name = "friends", description = "Friends API"),
|
||||||
|
(name = "search", description = "Search API"),
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
)]
|
)]
|
||||||
@@ -52,12 +37,14 @@ impl Modify for SecurityAddon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ApiDoc {
|
pub trait ApiDocExt {
|
||||||
fn attach_doc(self) -> Self;
|
fn attach_doc(self) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiDoc for Router {
|
impl ApiDocExt for Router {
|
||||||
fn attach_doc(self) -> Self {
|
fn attach_doc(self) -> Self {
|
||||||
|
tracing::info!("Attaching API documentation");
|
||||||
|
|
||||||
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
|
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
|
||||||
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
|
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
|
||||||
}
|
}
|
||||||
|
21
thoughts-backend/doc/src/search.rs
Normal file
21
thoughts-backend/doc/src/search.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use api::{models::ApiErrorResponse, routers::search::*};
|
||||||
|
use models::schemas::{
|
||||||
|
search::SearchResultsSchema,
|
||||||
|
thought::{ThoughtListSchema, ThoughtSchema},
|
||||||
|
user::{UserListSchema, UserSchema},
|
||||||
|
};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(search_all),
|
||||||
|
components(schemas(
|
||||||
|
SearchResultsSchema,
|
||||||
|
ApiErrorResponse,
|
||||||
|
ThoughtSchema,
|
||||||
|
ThoughtListSchema,
|
||||||
|
UserSchema,
|
||||||
|
UserListSchema
|
||||||
|
))
|
||||||
|
)]
|
||||||
|
pub(super) struct SearchApi;
|
@@ -2,15 +2,19 @@ use api::{
|
|||||||
models::{ApiErrorResponse, ParamsErrorResponse},
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
routers::thought::*,
|
routers::thought::*,
|
||||||
};
|
};
|
||||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
use models::{
|
||||||
|
params::thought::CreateThoughtParams,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
|
||||||
|
};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(thoughts_post, thoughts_delete),
|
paths(thoughts_post, thoughts_delete, get_thought_by_id, get_thought_thread),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateThoughtParams,
|
CreateThoughtParams,
|
||||||
ThoughtSchema,
|
ThoughtSchema,
|
||||||
|
ThoughtThreadSchema,
|
||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
ParamsErrorResponse
|
ParamsErrorResponse
|
||||||
))
|
))
|
||||||
|
@@ -20,6 +20,8 @@ use models::schemas::{
|
|||||||
user_outbox_get,
|
user_outbox_get,
|
||||||
get_me,
|
get_me,
|
||||||
update_me,
|
update_me,
|
||||||
|
get_user_followers,
|
||||||
|
get_user_following
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateUserParams,
|
CreateUserParams,
|
||||||
|
@@ -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",
|
|
||||||
] }
|
|
||||||
|
@@ -7,6 +7,7 @@ mod m20250906_130237_add_tags;
|
|||||||
mod m20250906_134056_add_api_keys;
|
mod m20250906_134056_add_api_keys;
|
||||||
mod m20250906_145148_add_reply_to_thoughts;
|
mod m20250906_145148_add_reply_to_thoughts;
|
||||||
mod m20250906_145755_add_visibility_to_thoughts;
|
mod m20250906_145755_add_visibility_to_thoughts;
|
||||||
|
mod m20250906_231359_add_full_text_search;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20250906_134056_add_api_keys::Migration),
|
Box::new(m20250906_134056_add_api_keys::Migration),
|
||||||
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
|
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
|
||||||
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
|
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
|
||||||
|
Box::new(m20250906_231359_add_full_text_search::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,48 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// --- Users Table ---
|
||||||
|
// Add the tsvector column for users
|
||||||
|
manager.get_connection().execute_unprepared(
|
||||||
|
"ALTER TABLE \"user\" ADD COLUMN \"search_document\" tsvector \
|
||||||
|
GENERATED ALWAYS AS (to_tsvector('english', username || ' ' || coalesce(display_name, ''))) STORED"
|
||||||
|
).await?;
|
||||||
|
// Add the GIN index for users
|
||||||
|
manager.get_connection().execute_unprepared(
|
||||||
|
"CREATE INDEX \"user_search_document_idx\" ON \"user\" USING GIN(\"search_document\")"
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// --- Thoughts Table ---
|
||||||
|
// Add the tsvector column for thoughts
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
"ALTER TABLE \"thought\" ADD COLUMN \"search_document\" tsvector \
|
||||||
|
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Add the GIN index for thoughts
|
||||||
|
manager.get_connection().execute_unprepared(
|
||||||
|
"CREATE INDEX \"thought_search_document_idx\" ON \"thought\" USING GIN(\"search_document\")"
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("ALTER TABLE \"user\" DROP COLUMN \"search_document\"")
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("ALTER TABLE \"thought\" DROP COLUMN \"search_document\"")
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
@@ -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",
|
||||||
] }
|
] }
|
||||||
|
@@ -25,6 +25,8 @@ pub struct Model {
|
|||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
|
||||||
|
pub search_document: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
@@ -19,6 +19,8 @@ pub struct Model {
|
|||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
pub updated_at: DateTimeWithTimeZone,
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
|
||||||
|
pub search_document: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
@@ -14,5 +14,6 @@ pub struct CreateThoughtParams {
|
|||||||
))]
|
))]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub visibility: Option<Visibility>,
|
pub visibility: Option<Visibility>,
|
||||||
|
#[serde(rename = "replyToId")]
|
||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
@@ -14,21 +14,25 @@ pub struct CreateUserParams {
|
|||||||
pub struct UpdateUserParams {
|
pub struct UpdateUserParams {
|
||||||
#[validate(length(max = 50))]
|
#[validate(length(max = 50))]
|
||||||
#[schema(example = "Frutiger Aero Fan")]
|
#[schema(example = "Frutiger Aero Fan")]
|
||||||
|
#[serde(rename = "displayName")]
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
|
|
||||||
#[validate(length(max = 160))]
|
#[validate(length(max = 4000))]
|
||||||
#[schema(example = "Est. 2004")]
|
#[schema(example = "Est. 2004")]
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
|
|
||||||
#[validate(url)]
|
#[validate(url)]
|
||||||
|
#[serde(rename = "avatarUrl")]
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
|
|
||||||
#[validate(url)]
|
#[validate(url)]
|
||||||
|
#[serde(rename = "headerUrl")]
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
|
#[serde(rename = "customCss")]
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
|
|
||||||
#[validate(length(max = 8))]
|
#[validate(length(max = 8))]
|
||||||
#[schema(example = json!(["username1", "username2"]))]
|
#[schema(example = json!(["username1", "username2"]))]
|
||||||
|
#[serde(rename = "topFriends")]
|
||||||
pub top_friends: Option<Vec<String>>,
|
pub top_friends: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
@@ -1 +1,2 @@
|
|||||||
|
pub mod pagination;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
27
thoughts-backend/models/src/queries/pagination.rs
Normal file
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()
|
||||||
|
}
|
||||||
|
}
|
@@ -8,7 +8,9 @@ use uuid::Uuid;
|
|||||||
pub struct ApiKeySchema {
|
pub struct ApiKeySchema {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(rename = "keyPrefix")]
|
||||||
pub key_prefix: String,
|
pub key_prefix: String,
|
||||||
|
#[serde(rename = "createdAt")]
|
||||||
pub created_at: DateTimeWithTimeZoneWrapper,
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,8 +18,7 @@ pub struct ApiKeySchema {
|
|||||||
pub struct ApiKeyResponse {
|
pub struct ApiKeyResponse {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub key: ApiKeySchema,
|
pub key: ApiKeySchema,
|
||||||
/// The full plaintext API key. This is only returned on creation.
|
#[serde(skip_serializing_if = "Option::is_none", rename = "plaintextKey")]
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub plaintext_key: Option<String>,
|
pub plaintext_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ impl ApiKeyResponse {
|
|||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct ApiKeyListSchema {
|
pub struct ApiKeyListSchema {
|
||||||
|
#[serde(rename = "apiKeys")]
|
||||||
pub api_keys: Vec<ApiKeySchema>,
|
pub api_keys: Vec<ApiKeySchema>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
|
pub mod pagination;
|
||||||
|
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
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,
|
||||||
|
}
|
9
thoughts-backend/models/src/schemas/search.rs
Normal file
9
thoughts-backend/models/src/schemas/search.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use super::{thought::ThoughtListSchema, user::UserListSchema};
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct SearchResultsSchema {
|
||||||
|
pub users: UserListSchema,
|
||||||
|
pub thoughts: ThoughtListSchema,
|
||||||
|
}
|
@@ -8,11 +8,13 @@ use serde::Serialize;
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, FromQueryResult, Debug)]
|
#[derive(Serialize, ToSchema, FromQueryResult, Debug, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ThoughtSchema {
|
pub struct ThoughtSchema {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
#[schema(example = "frutiger")]
|
#[schema(example = "frutiger")]
|
||||||
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,
|
||||||
@@ -25,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,
|
||||||
@@ -34,6 +37,7 @@ impl ThoughtSchema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ThoughtListSchema {
|
pub struct ThoughtListSchema {
|
||||||
pub thoughts: Vec<ThoughtSchema>,
|
pub thoughts: Vec<ThoughtSchema>,
|
||||||
}
|
}
|
||||||
@@ -52,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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,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,
|
||||||
@@ -67,3 +73,16 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ThoughtThreadSchema {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub author_username: String,
|
||||||
|
pub author_display_name: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
pub replies: Vec<ThoughtThreadSchema>,
|
||||||
|
}
|
||||||
|
@@ -6,6 +6,7 @@ use uuid::Uuid;
|
|||||||
use crate::domains::user;
|
use crate::domains::user;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserSchema {
|
pub struct UserSchema {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -44,7 +45,7 @@ impl From<user::Model> for UserSchema {
|
|||||||
avatar_url: user.avatar_url,
|
avatar_url: user.avatar_url,
|
||||||
header_url: user.header_url,
|
header_url: user.header_url,
|
||||||
custom_css: user.custom_css,
|
custom_css: user.custom_css,
|
||||||
top_friends: vec![], // Defaults to an empty list
|
top_friends: vec![],
|
||||||
joined_at: user.created_at.into(),
|
joined_at: user.created_at.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,3 +63,18 @@ impl From<Vec<user::Model>> for UserListSchema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MeSchema {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
pub top_friends: Vec<String>,
|
||||||
|
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
pub following: Vec<UserSchema>,
|
||||||
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
use api::{setup_db, setup_router};
|
use api::{setup_db, setup_router};
|
||||||
use doc::ApiDoc;
|
use doc::ApiDocExt;
|
||||||
use utils::migrate;
|
use utils::migrate;
|
||||||
|
|
||||||
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {
|
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
use api::{setup_config, setup_db, setup_router};
|
use api::{setup_config, setup_db, setup_router};
|
||||||
use doc::ApiDoc;
|
use utils::migrate;
|
||||||
use utils::{create_dev_db, migrate};
|
|
||||||
|
|
||||||
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
||||||
tracing::info!("Worker {} started", child_num);
|
|
||||||
|
|
||||||
let conn = setup_db(db_url, prefork).await;
|
let conn = setup_db(db_url, prefork).await;
|
||||||
|
|
||||||
if child_num == 0 {
|
if child_num == 0 {
|
||||||
@@ -13,7 +10,7 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
|
|||||||
|
|
||||||
let config = setup_config();
|
let config = setup_config();
|
||||||
|
|
||||||
let router = setup_router(conn, &config).attach_doc();
|
let router = setup_router(conn, &config);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
|
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
|
||||||
axum::serve(listener, router).await.expect("start server");
|
axum::serve(listener, router).await.expect("start server");
|
||||||
@@ -22,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)
|
||||||
@@ -37,18 +33,17 @@ fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) {
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
tracing::info!("Starting server...");
|
||||||
let config = setup_config();
|
let config = setup_config();
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
|
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
|
||||||
listener.set_nonblocking(true).expect("non blocking failed");
|
listener.set_nonblocking(true).expect("non blocking failed");
|
||||||
println!("listening on http://{}", listener.local_addr().unwrap());
|
tracing::info!("listening on http://{}", listener.local_addr().unwrap());
|
||||||
|
|
||||||
#[cfg(feature = "prefork")]
|
#[cfg(feature = "prefork")]
|
||||||
if config.prefork {
|
if config.prefork {
|
||||||
|
@@ -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!"
|
|
||||||
);
|
|
||||||
}
|
|
@@ -31,7 +31,7 @@ async fn test_api_key_flow() {
|
|||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
let plaintext_key = v["plaintext_key"]
|
let plaintext_key = v["plaintextKey"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("Plaintext key not found")
|
.expect("Plaintext key not found")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
@@ -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]["author_username"], "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]["author_username"], "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]["author_username"], "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,6 +1,10 @@
|
|||||||
|
use crate::api::main::login_user;
|
||||||
|
|
||||||
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 utils::testing::make_jwt_request;
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::Value;
|
||||||
|
use utils::testing::{make_get_request, make_jwt_request};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_follow_endpoints() {
|
async fn test_follow_endpoints() {
|
||||||
@@ -67,3 +71,92 @@ async fn test_follow_endpoints() {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_follow_lists() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let user_a = create_user_with_password(&app.db, "userA", "password123", "a@a.com").await;
|
||||||
|
let user_b = create_user_with_password(&app.db, "userB", "password123", "b@b.com").await;
|
||||||
|
let user_c = create_user_with_password(&app.db, "userC", "password123", "c@c.com").await;
|
||||||
|
|
||||||
|
// A follows B, C follows A
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_a.id, user_b.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_c.id, user_a.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Check user A's lists
|
||||||
|
let response_following =
|
||||||
|
make_get_request(app.router.clone(), "/users/userA/following", None).await;
|
||||||
|
let body_following = response_following
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body_following).unwrap();
|
||||||
|
assert_eq!(v["users"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["users"][0]["username"], "userB");
|
||||||
|
|
||||||
|
let response_followers =
|
||||||
|
make_get_request(app.router.clone(), "/users/userA/followers", None).await;
|
||||||
|
let body_followers = response_followers
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body_followers).unwrap();
|
||||||
|
assert_eq!(v["users"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["users"][0]["username"], "userC");
|
||||||
|
|
||||||
|
// 2. Check user A's /me endpoint
|
||||||
|
let jwt_a = login_user(app.router.clone(), "userA", "password123").await;
|
||||||
|
let response_me = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &jwt_a).await;
|
||||||
|
let body_me = response_me.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body_me).unwrap();
|
||||||
|
assert_eq!(v["username"], "userA");
|
||||||
|
assert_eq!(v["following"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["following"][0]["username"], "userB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_friends_list() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
let user_a = create_user_with_password(&app.db, "userA", "password123", "a@a.com").await;
|
||||||
|
let user_b = create_user_with_password(&app.db, "userB", "password123", "b@b.com").await;
|
||||||
|
let user_c = create_user_with_password(&app.db, "userC", "password123", "c@c.com").await;
|
||||||
|
|
||||||
|
// --- Create relationships ---
|
||||||
|
// A and B are friends (reciprocal follow)
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_a.id, user_b.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_b.id, user_a.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A follows C, but C does not follow A back
|
||||||
|
app::persistence::follow::follow_user(&app.db, user_a.id, user_c.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// --- Test as user_a ---
|
||||||
|
let jwt_a = login_user(app.router.clone(), "userA", "password123").await;
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/friends", "GET", None, &jwt_a).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();
|
||||||
|
let friends_list = v["users"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(friends_list.len(), 1, "User A should only have one friend");
|
||||||
|
assert_eq!(
|
||||||
|
friends_list[0]["username"], "userB",
|
||||||
|
"User B should be in User A's friend list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
mod activitypub;
|
|
||||||
mod api_key;
|
mod api_key;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod feed;
|
mod feed;
|
||||||
mod follow;
|
mod follow;
|
||||||
mod main;
|
mod main;
|
||||||
|
mod search;
|
||||||
mod tag;
|
mod tag;
|
||||||
mod thought;
|
mod thought;
|
||||||
mod user;
|
mod user;
|
||||||
|
198
thoughts-backend/tests/api/search.rs
Normal file
198
thoughts-backend/tests/api/search.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_get_request, make_jwt_request};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search_all() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
// 1. Setup users and data
|
||||||
|
let user1 =
|
||||||
|
create_user_with_password(&app.db, "search_user1", "password123", "s1@test.com").await;
|
||||||
|
let user2 =
|
||||||
|
create_user_with_password(&app.db, "search_user2", "password123", "s2@test.com").await;
|
||||||
|
let _user3 =
|
||||||
|
create_user_with_password(&app.db, "stranger_user", "password123", "s3@test.com").await;
|
||||||
|
|
||||||
|
// Make user1 and user2 friends
|
||||||
|
app::persistence::follow::follow_user(&app.db, user1.id, user2.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
app::persistence::follow::follow_user(&app.db, user2.id, user1.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let token1 = login_user(app.router.clone(), "search_user1", "password123").await;
|
||||||
|
let token2 = login_user(app.router.clone(), "search_user2", "password123").await;
|
||||||
|
let token3 = login_user(app.router.clone(), "stranger_user", "password123").await;
|
||||||
|
|
||||||
|
// User1 posts thoughts with different visibilities
|
||||||
|
let thought_public =
|
||||||
|
json!({ "content": "A very public thought about Rust.", "visibility": "Public" })
|
||||||
|
.to_string();
|
||||||
|
let thought_friends =
|
||||||
|
json!({ "content": "A friendly thought, just for pals.", "visibility": "FriendsOnly" })
|
||||||
|
.to_string();
|
||||||
|
let thought_private =
|
||||||
|
json!({ "content": "A private thought, for my eyes only.", "visibility": "Private" })
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_public),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_friends),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_private),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 2. Run search tests
|
||||||
|
|
||||||
|
// -- User Search --
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=search_user1", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v["users"]["users"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["users"]["users"][0]["username"], "search_user1");
|
||||||
|
|
||||||
|
// -- Thought Search (Public) --
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=public", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Guest should find public thought"
|
||||||
|
);
|
||||||
|
assert!(v["thoughts"]["thoughts"][0]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("public"));
|
||||||
|
|
||||||
|
// -- Thought Search (FriendsOnly) --
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=friendly",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Author should find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=friendly",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Friend should find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=friendly",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token3,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Stranger should NOT find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=friendly", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Guest should NOT find friends thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Thought Search (Private) --
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=private",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Author should find private thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/search?q=private",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Friend should NOT find private thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = make_get_request(app.router.clone(), "/search?q=private", None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||||
|
0,
|
||||||
|
"Guest should NOT find private thought"
|
||||||
|
);
|
||||||
|
}
|
@@ -23,7 +23,7 @@ async fn test_thought_endpoints() {
|
|||||||
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["content"], "My first thought!");
|
assert_eq!(v["content"], "My first thought!");
|
||||||
assert_eq!(v["author_username"], "user1");
|
assert_eq!(v["authorUsername"], "user1");
|
||||||
let thought_id = v["id"].as_str().unwrap().to_string();
|
let thought_id = v["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
// 2. Post a thought with invalid content
|
// 2. Post a thought with invalid content
|
||||||
@@ -69,7 +69,7 @@ async fn test_thought_replies() {
|
|||||||
// 2. User 2 replies to the original thought
|
// 2. User 2 replies to the original thought
|
||||||
let reply_body = json!({
|
let reply_body = json!({
|
||||||
"content": "This is a reply.",
|
"content": "This is a reply.",
|
||||||
"reply_to_id": original_thought_id
|
"replyToId": original_thought_id
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let response =
|
let response =
|
||||||
@@ -79,8 +79,8 @@ async fn test_thought_replies() {
|
|||||||
let reply_thought: Value = serde_json::from_slice(&body).unwrap();
|
let reply_thought: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
// 3. Verify the reply is linked correctly
|
// 3. Verify the reply is linked correctly
|
||||||
assert_eq!(reply_thought["reply_to_id"], original_thought_id);
|
assert_eq!(reply_thought["replyToId"], original_thought_id);
|
||||||
assert_eq!(reply_thought["author_username"], "user2");
|
assert_eq!(reply_thought["authorUsername"], "user2");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -163,3 +163,159 @@ async fn test_thought_visibility() {
|
|||||||
"Unauthenticated guest should see only public posts"
|
"Unauthenticated guest should see only public posts"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn post_thought_and_get_id(
|
||||||
|
router: &Router,
|
||||||
|
content: &str,
|
||||||
|
visibility: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> String {
|
||||||
|
let body = json!({ "content": content, "visibility": visibility }).to_string();
|
||||||
|
let response = make_jwt_request(router.clone(), "/thoughts", "POST", Some(body), token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
v["id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_thought_by_id_visibility() {
|
||||||
|
let app = setup().await;
|
||||||
|
let author = create_user_with_password(&app.db, "author", "password123", "a@a.com").await;
|
||||||
|
let friend = create_user_with_password(&app.db, "friend", "password123", "f@f.com").await;
|
||||||
|
let _stranger = create_user_with_password(&app.db, "stranger", "password123", "s@s.com").await;
|
||||||
|
|
||||||
|
// Make author and friend follow each other
|
||||||
|
follow::follow_user(&app.db, author.id, friend.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
follow::follow_user(&app.db, friend.id, author.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let author_jwt = login_user(app.router.clone(), "author", "password123").await;
|
||||||
|
let friend_jwt = login_user(app.router.clone(), "friend", "password123").await;
|
||||||
|
let stranger_jwt = login_user(app.router.clone(), "stranger", "password123").await;
|
||||||
|
|
||||||
|
// Author posts one of each visibility
|
||||||
|
let public_id = post_thought_and_get_id(&app.router, "public", "Public", &author_jwt).await;
|
||||||
|
let friends_id =
|
||||||
|
post_thought_and_get_id(&app.router, "friends", "FriendsOnly", &author_jwt).await;
|
||||||
|
let private_id = post_thought_and_get_id(&app.router, "private", "Private", &author_jwt).await;
|
||||||
|
|
||||||
|
// --- Test Assertions ---
|
||||||
|
|
||||||
|
// 1. Public thought
|
||||||
|
let public_url = format!("/thoughts/{}", public_id);
|
||||||
|
assert_eq!(
|
||||||
|
make_get_request(app.router.clone(), &public_url, None)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"Guest should see public thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Friends-only thought
|
||||||
|
let friends_url = format!("/thoughts/{}", friends_id);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &friends_url, "GET", None, &friend_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"Friend should see friends-only thought"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &friends_url, "GET", None, &stranger_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Stranger should NOT see friends-only thought"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Private thought
|
||||||
|
let private_url = format!("/thoughts/{}", private_id);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &private_url, "GET", None, &author_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"Author should see their private thought"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
make_jwt_request(app.router.clone(), &private_url, "GET", None, &friend_jwt)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Friend should NOT see private thought"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_thought_thread() {
|
||||||
|
let app = setup().await;
|
||||||
|
let _user1 =
|
||||||
|
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
|
||||||
|
let _user2 =
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
|
||||||
|
let user3 =
|
||||||
|
create_user_with_password(&app.db, "user3", "password123", "user3@example.com").await;
|
||||||
|
|
||||||
|
let token1 = login_user(app.router.clone(), "user1", "password123").await;
|
||||||
|
let token2 = login_user(app.router.clone(), "user2", "password123").await;
|
||||||
|
|
||||||
|
// 1. user1 posts a root thought
|
||||||
|
let root_id = post_thought_and_get_id(&app.router, "Root thought", "Public", &token1).await;
|
||||||
|
|
||||||
|
// 2. user2 replies to the root thought
|
||||||
|
let reply1_body = json!({ "content": "First reply", "replyToId": root_id }).to_string();
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(reply1_body),
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let reply1: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let reply1_id = reply1["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// 3. user1 replies to user2's reply
|
||||||
|
let reply2_body =
|
||||||
|
json!({ "content": "Reply to the reply", "replyToId": reply1_id }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(reply2_body),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 4. Fetch the entire thread
|
||||||
|
let response = make_get_request(
|
||||||
|
app.router.clone(),
|
||||||
|
&format!("/thoughts/{}/thread", root_id),
|
||||||
|
Some(user3.id), // Fetch as a third user to test visibility
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let thread: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
// 5. Assert the structure
|
||||||
|
assert_eq!(thread["content"], "Root thought");
|
||||||
|
assert_eq!(thread["authorUsername"], "user1");
|
||||||
|
assert_eq!(thread["replies"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let reply_level_1 = &thread["replies"][0];
|
||||||
|
assert_eq!(reply_level_1["content"], "First reply");
|
||||||
|
assert_eq!(reply_level_1["authorUsername"], "user2");
|
||||||
|
assert_eq!(reply_level_1["replies"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let reply_level_2 = &reply_level_1["replies"][0];
|
||||||
|
assert_eq!(reply_level_2["content"], "Reply to the reply");
|
||||||
|
assert_eq!(reply_level_2["authorUsername"], "user1");
|
||||||
|
assert!(reply_level_2["replies"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
@@ -22,7 +22,7 @@ async fn test_post_users() {
|
|||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
assert_eq!(v["username"], "test");
|
assert_eq!(v["username"], "test");
|
||||||
assert!(v["display_name"].is_string());
|
assert!(v["displayName"].is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -86,13 +86,13 @@ async fn test_me_endpoints() {
|
|||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["username"], "me_user");
|
assert_eq!(v["username"], "me_user");
|
||||||
assert!(v["bio"].is_null());
|
assert!(v["bio"].is_null());
|
||||||
assert!(v["display_name"].is_string());
|
assert!(v["displayName"].is_string());
|
||||||
|
|
||||||
// 4. PUT /users/me to update the profile
|
// 4. PUT /users/me to update the profile
|
||||||
let update_body = json!({
|
let update_body = json!({
|
||||||
"display_name": "Me User",
|
"displayName": "Me User",
|
||||||
"bio": "This is my updated bio.",
|
"bio": "This is my updated bio.",
|
||||||
"avatar_url": "https://example.com/avatar.png"
|
"avatarUrl": "https://example.com/avatar.png"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let response = make_jwt_request(
|
let response = make_jwt_request(
|
||||||
@@ -106,7 +106,7 @@ async fn test_me_endpoints() {
|
|||||||
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_updated: Value = serde_json::from_slice(&body).unwrap();
|
let v_updated: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v_updated["display_name"], "Me User");
|
assert_eq!(v_updated["displayName"], "Me User");
|
||||||
assert_eq!(v_updated["bio"], "This is my updated bio.");
|
assert_eq!(v_updated["bio"], "This is my updated bio.");
|
||||||
|
|
||||||
// 5. GET /users/me again to verify the update was persisted
|
// 5. GET /users/me again to verify the update was persisted
|
||||||
@@ -114,7 +114,7 @@ async fn test_me_endpoints() {
|
|||||||
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_verify: Value = serde_json::from_slice(&body).unwrap();
|
let v_verify: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v_verify["display_name"], "Me User");
|
assert_eq!(v_verify["displayName"], "Me User");
|
||||||
assert_eq!(v_verify["bio"], "This is my updated bio.");
|
assert_eq!(v_verify["bio"], "This is my updated bio.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ async fn test_update_me_top_friends() {
|
|||||||
|
|
||||||
// 3. Update profile to set top friends
|
// 3. Update profile to set top friends
|
||||||
let update_body = json!({
|
let update_body = json!({
|
||||||
"top_friends": ["friend1", "friend2"]
|
"topFriends": ["friend1", "friend2"]
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ async fn test_update_me_top_friends() {
|
|||||||
|
|
||||||
// 5. Update again with a different list to test replacement
|
// 5. Update again with a different list to test replacement
|
||||||
let update_body_2 = json!({
|
let update_body_2 = json!({
|
||||||
"top_friends": ["friend2"]
|
"topFriends": ["friend2"]
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ async fn test_update_me_css_and_images() {
|
|||||||
|
|
||||||
// 2. Attempt to update with an invalid avatar URL
|
// 2. Attempt to update with an invalid avatar URL
|
||||||
let invalid_body = json!({
|
let invalid_body = json!({
|
||||||
"avatar_url": "not-a-valid-url"
|
"avatarUrl": "not-a-valid-url"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -219,9 +219,9 @@ async fn test_update_me_css_and_images() {
|
|||||||
|
|
||||||
// 3. Update profile with valid URLs and custom CSS
|
// 3. Update profile with valid URLs and custom CSS
|
||||||
let valid_body = json!({
|
let valid_body = json!({
|
||||||
"avatar_url": "https://example.com/new-avatar.png",
|
"avatarUrl": "https://example.com/new-avatar.png",
|
||||||
"header_url": "https://example.com/new-header.jpg",
|
"headerUrl": "https://example.com/new-header.jpg",
|
||||||
"custom_css": "body { color: blue; }"
|
"customCss": "body { color: blue; }"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -241,7 +241,72 @@ async fn test_update_me_css_and_images() {
|
|||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
assert_eq!(v["avatar_url"], "https://example.com/new-avatar.png");
|
assert_eq!(v["avatarUrl"], "https://example.com/new-avatar.png");
|
||||||
assert_eq!(v["header_url"], "https://example.com/new-header.jpg");
|
assert_eq!(v["headerUrl"], "https://example.com/new-header.jpg");
|
||||||
assert_eq!(v["custom_css"], "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"]
|
||||||
|
|
||||||
|
12
thoughts-frontend/app/(auth)/layout.tsx
Normal file
12
thoughts-frontend/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// app/(auth)/layout.tsx
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
112
thoughts-frontend/app/(auth)/login/page.tsx
Normal file
112
thoughts-frontend/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { LoginSchema, loginUser } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setToken } = useAuth();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof LoginSchema>>({
|
||||||
|
resolver: zodResolver(LoginSchema),
|
||||||
|
defaultValues: { username: "", password: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof LoginSchema>) {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const { token } = await loginUser(values);
|
||||||
|
setToken(token);
|
||||||
|
router.push("/"); // Redirect to homepage on successful login
|
||||||
|
} catch {
|
||||||
|
setError("Invalid username or password.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Login</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your credentials to access your account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* ... Form fields for username and password ... */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="frutiger" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm font-medium text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? "Logging in..." : "Login"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-600">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="font-medium text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
125
thoughts-frontend/app/(auth)/register/page.tsx
Normal file
125
thoughts-frontend/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { RegisterSchema, registerUser } from "@/lib/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof RegisterSchema>>({
|
||||||
|
resolver: zodResolver(RegisterSchema),
|
||||||
|
defaultValues: { username: "", email: "", password: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof RegisterSchema>) {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await registerUser(values);
|
||||||
|
// You can automatically log the user in here or just redirect them
|
||||||
|
router.push("/login");
|
||||||
|
} catch {
|
||||||
|
setError("Username or email may already be taken.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create an Account</CardTitle>
|
||||||
|
<CardDescription>Enter your details to register.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* ... Form fields for username, email, and password ... */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="frutiger" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="aero@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm font-medium text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? "Creating account..." : "Register"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-600">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="font-medium text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 4.2 KiB |
BIN
thoughts-frontend/app/frutiger-bold.woff
Normal file
BIN
thoughts-frontend/app/frutiger-bold.woff
Normal file
Binary file not shown.
BIN
thoughts-frontend/app/frutiger.woff
Normal file
BIN
thoughts-frontend/app/frutiger.woff
Normal file
Binary file not shown.
@@ -1,26 +1,314 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
--background-start: var(--color-sky-blue);
|
||||||
|
--background-end: var(--color-lime-300);
|
||||||
|
|
||||||
|
/* Frutiger Aero Gradients */
|
||||||
|
--gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%;
|
||||||
|
--gradient-fa-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%;
|
||||||
|
--gradient-fa-card: 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%,
|
||||||
|
hsl(var(--card)) 100%;
|
||||||
|
--gradient-fa-gloss: 135deg, rgba(255, 255, 255, 0.2) 0%,
|
||||||
|
rgba(255, 255, 255, 0) 100%;
|
||||||
|
|
||||||
|
--shadow-fa-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-fa-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-fa-lg: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
--fa-inner: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
--text-shadow-default: 0 1px 1px rgba(0, 0, 0, 0.2);
|
||||||
|
--text-shadow-sm: 0 1px 0px rgba(255, 255, 255, 0.4);
|
||||||
|
--text-shadow-md: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--text-shadow-lg: 0 4px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
--font-display: var(--font-frutiger), "Arial", "Helvetica", "sans-serif";
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root {
|
||||||
:root {
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--background: #0a0a0a;
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--foreground: #ededed;
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
|
--background: hsl(0 0% 98%); /* Light off-white */
|
||||||
|
--foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--muted: hsl(210 20% 96.1%);
|
||||||
|
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||||
|
|
||||||
|
--popover: hsl(0 0% 100%);
|
||||||
|
--popover-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--card: hsl(0 0% 100%); /* Pure white for a crisp look */
|
||||||
|
--card-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--border: hsl(214.3 31.8% 91.4%);
|
||||||
|
--input: hsl(214.3 31.8% 91.4%);
|
||||||
|
--ring: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--primary: hsl(217 91% 60%); /* Vibrant Blue */
|
||||||
|
--primary-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--secondary: hsl(155 70% 55%); /* Vibrant Green */
|
||||||
|
--secondary-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--destructive: hsl(0 84.2% 60.2%);
|
||||||
|
--destructive-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--accent: hsl(210 20% 96.1%);
|
||||||
|
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||||
|
|
||||||
|
--radius: 0.75rem; /* Larger border radius */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
|
||||||
|
--background: hsl(222.2 47.4% 11.2%);
|
||||||
|
--foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--muted: hsl(217.2 32.4% 14.8%);
|
||||||
|
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||||
|
|
||||||
|
--popover: hsl(222.2 47.4% 11.2%);
|
||||||
|
--popover-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--card: hsl(217.2 32.4% 14.8%);
|
||||||
|
--card-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--border: hsl(217.2 32.4% 14.8%);
|
||||||
|
--input: hsl(217.2 32.4% 14.8%);
|
||||||
|
--ring: hsl(212.7 26.8% 83.9%);
|
||||||
|
|
||||||
|
--primary: hsl(217 91% 60%); /* Vibrant Blue (same as light) */
|
||||||
|
--primary-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--secondary: hsl(155 70% 55%); /* Vibrant Green (same as light) */
|
||||||
|
--secondary-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--destructive: hsl(0 62.8% 30.6%);
|
||||||
|
--destructive-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
--accent: hsl(217.2 32.4% 14.8%);
|
||||||
|
--accent-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
/* Frutiger Aero Gradients for dark mode (slightly adjusted) */
|
||||||
|
--color-fa-gradient-blue: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
hsl(217 91% 45%) 0%,
|
||||||
|
hsl(200 90% 55%) 100%
|
||||||
|
);
|
||||||
|
--color-fa-gradient-green: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
hsl(155 70% 40%) 0%,
|
||||||
|
hsl(170 80% 50%) 100%
|
||||||
|
);
|
||||||
|
--color-fa-gradient-card: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
hsl(var(--card)) 0%,
|
||||||
|
hsl(var(--card)) 90%,
|
||||||
|
hsl(var(--card)) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
background-image: url("/background.avif");
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossy-effect::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 50%;
|
||||||
|
border-radius: var(--radius); /* Inherit parent's border radius */
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 255, 255, 0.4) 0%,
|
||||||
|
rgba(255, 255, 255, 0.1) 100%
|
||||||
|
);
|
||||||
|
opacity: 0.8;
|
||||||
|
pointer-events: none; /* Allow clicks to pass through */
|
||||||
|
z-index: 1; /* Ensure it's above the background but below content */
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossy-effect.bottom::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 30%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.1) 0%,
|
||||||
|
rgba(0, 0, 0, 0) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-gradient-blue {
|
||||||
|
background: linear-gradient(var(--gradient-fa-blue));
|
||||||
|
}
|
||||||
|
.fa-gradient-green {
|
||||||
|
background: linear-gradient(var(--gradient-fa-green));
|
||||||
|
}
|
||||||
|
.fa-gradient-card {
|
||||||
|
background: linear-gradient(var(--gradient-fa-card));
|
||||||
|
}
|
||||||
|
.fa-gloss {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.fa-gloss::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 50%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(var(--gradient-fa-gloss));
|
||||||
|
opacity: 0.8;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.fa-gloss.bottom::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 30%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.1) 0%,
|
||||||
|
rgba(0, 0, 0, 0) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@layer components {
|
||||||
background: var(--background);
|
.shadow-fa-sm {
|
||||||
color: var(--foreground);
|
box-shadow: var(--shadow-fa-sm), var(--fa-inner);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
}
|
||||||
|
.shadow-fa-md {
|
||||||
|
box-shadow: var(--shadow-fa-md), var(--fa-inner);
|
||||||
|
}
|
||||||
|
.shadow-fa-lg {
|
||||||
|
box-shadow: var(--shadow-fa-lg), var(--fa-inner);
|
||||||
|
}
|
||||||
|
.text-shadow-default {
|
||||||
|
text-shadow: var(--text-shadow-default);
|
||||||
|
}
|
||||||
|
.text-shadow-sm {
|
||||||
|
text-shadow: var(--text-shadow-sm);
|
||||||
|
}
|
||||||
|
.text-shadow-md {
|
||||||
|
text-shadow: var(--text-shadow-md);
|
||||||
|
}
|
||||||
|
.text-shadow-lg {
|
||||||
|
text-shadow: var(--text-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-effect {
|
||||||
|
@apply bg-card/70 backdrop-blur-lg border border-white/20 shadow-fa-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gloss-highlight::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60%;
|
||||||
|
border-radius: inherit; /* This is key for matching the parent's border radius */
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 255, 255, 0.5) 0%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,22 +1,32 @@
|
|||||||
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";
|
||||||
const geistSans = Geist({
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
variable: "--font-geist-sans",
|
import { Header } from "@/components/header";
|
||||||
subsets: ["latin"],
|
import localFont from "next/font/local";
|
||||||
});
|
import InstallPrompt from "@/components/install-prompt";
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Thoughts",
|
||||||
description: "Generated by create next app",
|
description: "A social network for sharing thoughts",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const frutiger = localFont({
|
||||||
|
src: [
|
||||||
|
{
|
||||||
|
path: "./frutiger.woff",
|
||||||
|
weight: "normal",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "./frutiger-bold.woff",
|
||||||
|
weight: "bold",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variable: "--font-frutiger",
|
||||||
|
});
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -24,10 +34,13 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={`${frutiger.className} antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<AuthProvider>
|
||||||
>
|
<Header />
|
||||||
{children}
|
<main className="flex-1">{children}</main>
|
||||||
|
<InstallPrompt />
|
||||||
|
<Toaster />
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
25
thoughts-frontend/app/manifest.ts
Normal file
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
@@ -1,158 +1,183 @@
|
|||||||
"use client";
|
import { cookies } from "next/headers";
|
||||||
|
import {
|
||||||
|
getFeed,
|
||||||
|
getFriends,
|
||||||
|
getMe,
|
||||||
|
getUserProfile,
|
||||||
|
Me,
|
||||||
|
User,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { PostThoughtForm } from "@/components/post-thought-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PopularTags } from "@/components/popular-tags";
|
||||||
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
|
import { buildThoughtThreads } from "@/lib/utils";
|
||||||
|
import { TopFriends } from "@/components/top-friends";
|
||||||
|
import { UsersCount } from "@/components/users-count";
|
||||||
|
|
||||||
import { useState, useEffect, FormEvent } from "react";
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
interface Thought {
|
export default async function Home({
|
||||||
id: number;
|
searchParams,
|
||||||
author_id: number;
|
}: {
|
||||||
content: string;
|
searchParams: { page?: string };
|
||||||
created_at: string;
|
}) {
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
return <FeedPage token={token} searchParams={searchParams} />;
|
||||||
|
} else {
|
||||||
|
return <LandingPage />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
async function FeedPage({
|
||||||
// State to store the list of thoughts for the feed
|
token,
|
||||||
const [thoughts, setThoughts] = useState<Thought[]>([]);
|
searchParams,
|
||||||
// State for the content of the new thought being typed
|
}: {
|
||||||
const [newThoughtContent, setNewThoughtContent] = useState("");
|
token: string;
|
||||||
// State to manage loading status
|
searchParams: { page?: string };
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
}) {
|
||||||
// State to hold any potential errors during API calls
|
const page = parseInt(searchParams.page ?? "1", 10);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Function to fetch the feed from the backend API
|
const [feedData, me] = await Promise.all([
|
||||||
const fetchFeed = async () => {
|
getFeed(token, page).catch(() => null),
|
||||||
try {
|
getMe(token).catch(() => null) as Promise<Me | null>,
|
||||||
setError(null);
|
]);
|
||||||
const response = await fetch("http://localhost:8000/feed");
|
|
||||||
if (!response.ok) {
|
if (!feedData || !me) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
redirect("/login");
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
|
||||||
// The API returns { thoughts: [...] }, so we access the nested array
|
const { items: allThoughts, totalPages } = feedData!;
|
||||||
setThoughts(data.thoughts || []);
|
const thoughtThreads = buildThoughtThreads(allThoughts);
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error("Failed to fetch feed:", e);
|
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
||||||
setError(
|
const userProfiles = await Promise.all(
|
||||||
"Could not load the feed. The backend might be busy. Please try refreshing."
|
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// useEffect hook to fetch the feed when the component first loads
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||||
useEffect(() => {
|
userProfiles
|
||||||
fetchFeed();
|
.filter((u): u is User => !!u)
|
||||||
}, []);
|
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||||
|
);
|
||||||
|
|
||||||
// Handler for submitting the new thought form
|
const friends = (await getFriends(token)).users.map((user) => user.username);
|
||||||
const handleSubmitThought = async (e: FormEvent) => {
|
const shouldDisplayTopFriends =
|
||||||
e.preventDefault();
|
token && me?.topFriends && me.topFriends.length > 8;
|
||||||
if (!newThoughtContent.trim()) return; // Prevent empty posts
|
|
||||||
|
|
||||||
try {
|
console.log("Should display top friends:", shouldDisplayTopFriends);
|
||||||
const response = await fetch("http://localhost:8000/thoughts", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
// We are hardcoding author_id: 1 as we don't have auth yet
|
|
||||||
body: JSON.stringify({ content: newThoughtContent, author_id: 1 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the input box
|
|
||||||
setNewThoughtContent("");
|
|
||||||
// Refresh the feed to show the new post
|
|
||||||
fetchFeed();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error("Failed to post thought:", e);
|
|
||||||
setError("Failed to post your thought. Please try again.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="font-sans bg-gradient-to-br from-sky-200 via-teal-100 to-green-200 min-h-screen text-gray-800">
|
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
|
||||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
{/* Header */}
|
<aside className="hidden lg:block lg:col-span-1">
|
||||||
<header className="text-center my-6">
|
<div className="sticky top-20 space-y-6 glass-effect glossy-effect bottom rounded-md p-4">
|
||||||
<h1
|
<h2 className="text-lg font-semibold">Filters & Sorting</h2>
|
||||||
className="text-5xl font-bold text-white"
|
<p className="text-sm text-muted-foreground">Coming soon...</p>
|
||||||
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.2)" }}
|
</div>
|
||||||
>
|
</aside>
|
||||||
Thoughts
|
|
||||||
</h1>
|
<main className="col-span-1 lg:col-span-2 space-y-6">
|
||||||
<p className="text-white/80 mt-2">
|
<header className="mb-6">
|
||||||
Your space on the decentralized web.
|
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
<PostThoughtForm />
|
||||||
|
|
||||||
{/* New Thought Form */}
|
<div className="block lg:hidden space-y-6">
|
||||||
<div className="bg-white/70 backdrop-blur-lg rounded-xl shadow-lg p-5 mb-8">
|
<PopularTags />
|
||||||
<form onSubmit={handleSubmitThought}>
|
{shouldDisplayTopFriends && (
|
||||||
<textarea
|
<TopFriends mode="top-friends" usernames={me.topFriends} />
|
||||||
value={newThoughtContent}
|
|
||||||
onChange={(e) => setNewThoughtContent(e.target.value)}
|
|
||||||
className="w-full h-24 p-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-sky-400 focus:outline-none resize-none transition-shadow"
|
|
||||||
placeholder="What's on your mind?"
|
|
||||||
maxLength={128}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between items-center mt-3">
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{128 - newThoughtContent.length} characters remaining
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-6 py-2 bg-sky-500 text-white font-semibold rounded-full shadow-md hover:bg-sky-600 active:scale-95 transition-all duration-150 ease-in-out disabled:bg-gray-400"
|
|
||||||
disabled={!newThoughtContent.trim()}
|
|
||||||
>
|
|
||||||
Post
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feed Section */}
|
|
||||||
<main>
|
|
||||||
{isLoading ? (
|
|
||||||
<p className="text-center text-gray-600">Loading feed...</p>
|
|
||||||
) : error ? (
|
|
||||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg text-center">
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
) : thoughts.length === 0 ? (
|
|
||||||
<p className="text-center text-gray-600">
|
|
||||||
The feed is empty. Follow some users to see their thoughts!
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{thoughts.map((thought) => (
|
|
||||||
<div
|
|
||||||
key={thought.id}
|
|
||||||
className="bg-white/80 backdrop-blur-lg rounded-xl shadow-lg p-4 transition-transform hover:scale-[1.02]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center mb-2">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-green-300 to-sky-400 flex items-center justify-center font-bold text-white mr-3">
|
|
||||||
{/* Placeholder for avatar */}
|
|
||||||
{thought.author_id}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-bold">User {thought.author_id}</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{new Date(thought.created_at).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-800 break-words">{thought.content}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{!shouldDisplayTopFriends && token && friends.length > 0 && (
|
||||||
|
<TopFriends mode="friends" usernames={friends || []} />
|
||||||
|
)}
|
||||||
|
<UsersCount />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{thoughtThreads.map((thought) => (
|
||||||
|
<ThoughtThread
|
||||||
|
key={thought.id}
|
||||||
|
thought={thought}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{thoughtThreads.length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
Your feed is empty. Follow some users to see their thoughts!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Pagination className="mt-8">
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href={page > 1 ? `/?page=${page - 1}` : "#"}
|
||||||
|
aria-disabled={page <= 1}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href={page < totalPages ? `/?page=${page + 1}` : "#"}
|
||||||
|
aria-disabled={page >= totalPages}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<aside className="hidden lg:block lg:col-span-1">
|
||||||
|
<div className="sticky top-20 space-y-6">
|
||||||
|
<PopularTags />
|
||||||
|
{shouldDisplayTopFriends && (
|
||||||
|
<TopFriends mode="top-friends" usernames={me.topFriends} />
|
||||||
|
)}
|
||||||
|
{!shouldDisplayTopFriends && token && friends.length > 0 && (
|
||||||
|
<TopFriends mode="friends" usernames={friends || []} />
|
||||||
|
)}
|
||||||
|
<UsersCount />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LandingPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="font-sans min-h-screen text-gray-800 flex items-center justify-center">
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center glass-effect glossy-effect bottom rounded-md shadow-fa-lg">
|
||||||
|
<h1
|
||||||
|
className="text-5xl font-bold"
|
||||||
|
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
|
||||||
|
>
|
||||||
|
Welcome to Thoughts
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Throwback to the golden age of microblogging.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex justify-center gap-4">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/login">Login</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" asChild>
|
||||||
|
<Link href="/register">Register</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
76
thoughts-frontend/app/search/page.tsx
Normal file
76
thoughts-frontend/app/search/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getMe, search, User } from "@/lib/api";
|
||||||
|
import { UserListCard } from "@/components/user-list-card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ThoughtList } from "@/components/thought-list";
|
||||||
|
|
||||||
|
interface SearchPageProps {
|
||||||
|
searchParams: { q?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||||
|
const query = searchParams.q || "";
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold mt-8">Search Thoughts</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Find users and thoughts across the platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [results, me] = await Promise.all([
|
||||||
|
search(query, token).catch(() => null),
|
||||||
|
token ? getMe(token).catch(() => null) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||||
|
if (results) {
|
||||||
|
results.users.users.forEach((user: User) => {
|
||||||
|
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="text-3xl font-bold">Search Results</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Showing results for: "{query}"
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{results ? (
|
||||||
|
<Tabs defaultValue="thoughts" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="thoughts">
|
||||||
|
Thoughts ({results.thoughts.thoughts.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">
|
||||||
|
Users ({results.users.users.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="thoughts">
|
||||||
|
<ThoughtList
|
||||||
|
thoughts={results.thoughts.thoughts}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="users">
|
||||||
|
<UserListCard users={results.users.users} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
No results found or an error occurred.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
27
thoughts-frontend/app/settings/api-keys/page.tsx
Normal file
27
thoughts-frontend/app/settings/api-keys/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getApiKeys } from "@/lib/api";
|
||||||
|
import { ApiKeyList } from "@/components/api-keys-list";
|
||||||
|
|
||||||
|
export default async function ApiKeysPage() {
|
||||||
|
const token = (await cookies()).get("auth_token")?.value;
|
||||||
|
if (!token) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialApiKeys = await getApiKeys(token).catch(() => ({
|
||||||
|
apiKeys: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
|
||||||
|
<h3 className="text-lg font-medium">API Keys</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage API keys for third-party applications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ApiKeyList initialApiKeys={initialApiKeys.apiKeys} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
38
thoughts-frontend/app/settings/layout.tsx
Normal file
38
thoughts-frontend/app/settings/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// app/settings/layout.tsx
|
||||||
|
import { SettingsNav } from "@/components/settings-nav";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
const sidebarNavItems = [
|
||||||
|
{
|
||||||
|
title: "Profile",
|
||||||
|
href: "/settings/profile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "API Keys",
|
||||||
|
href: "/settings/api-keys",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-5xl space-y-6 p-10 pb-16">
|
||||||
|
<div className="space-y-0.5 p-4 glass-effect rounded-md shadow-fa-lg">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your account settings and profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
||||||
|
<aside className="-mx-4 lg:w-1/5">
|
||||||
|
<SettingsNav items={sidebarNavItems} />
|
||||||
|
</aside>
|
||||||
|
<div className="flex-1 lg:max-w-2xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
31
thoughts-frontend/app/settings/profile/page.tsx
Normal file
31
thoughts-frontend/app/settings/profile/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// app/settings/profile/page.tsx
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getMe } from "@/lib/api";
|
||||||
|
import { EditProfileForm } from "@/components/edit-profile-form";
|
||||||
|
|
||||||
|
export default async function EditProfilePage() {
|
||||||
|
const token = (await cookies()).get("auth_token")?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = await getMe(token).catch(() => null);
|
||||||
|
|
||||||
|
if (!me) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 ">
|
||||||
|
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
|
||||||
|
<h3 className="text-lg font-medium">Profile</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This is how others will see you on the site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<EditProfileForm currentUser={me} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
65
thoughts-frontend/app/tags/[tagName]/page.tsx
Normal file
65
thoughts-frontend/app/tags/[tagName]/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// app/tags/[tagName]/page.tsx
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api";
|
||||||
|
import { buildThoughtThreads } from "@/lib/utils";
|
||||||
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Hash } from "lucide-react";
|
||||||
|
|
||||||
|
interface TagPageProps {
|
||||||
|
params: { tagName: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TagPage({ params }: TagPageProps) {
|
||||||
|
const { tagName } = params;
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const [thoughtsResult, meResult] = await Promise.allSettled([
|
||||||
|
getThoughtsByTag(tagName, token),
|
||||||
|
token ? getMe(token) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (thoughtsResult.status === "rejected") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const allThoughts = thoughtsResult.value.thoughts;
|
||||||
|
const thoughtThreads = buildThoughtThreads(allThoughts);
|
||||||
|
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||||
|
|
||||||
|
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
|
||||||
|
const userProfiles = await Promise.all(
|
||||||
|
authors.map((username) => getUserProfile(username, token).catch(() => null))
|
||||||
|
);
|
||||||
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||||
|
userProfiles
|
||||||
|
.filter((u): u is User => !!u)
|
||||||
|
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="flex items-center gap-2 text-3xl font-bold">
|
||||||
|
<Hash className="h-7 w-7" />
|
||||||
|
{tagName}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<main className="space-y-6">
|
||||||
|
{thoughtThreads.map((thought) => (
|
||||||
|
<ThoughtThread
|
||||||
|
key={thought.id}
|
||||||
|
thought={thought}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{thoughtThreads.length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground pt-8">
|
||||||
|
No thoughts found for this tag.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
69
thoughts-frontend/app/thoughts/[thoughtId]/page.tsx
Normal file
69
thoughts-frontend/app/thoughts/[thoughtId]/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import {
|
||||||
|
getThoughtThread,
|
||||||
|
getUserProfile,
|
||||||
|
getMe,
|
||||||
|
Me,
|
||||||
|
User,
|
||||||
|
ThoughtThread as ThoughtThreadType,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
interface ThoughtPageProps {
|
||||||
|
params: { thoughtId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAuthors(thread: ThoughtThreadType): string[] {
|
||||||
|
const authors = new Set<string>([thread.authorUsername]);
|
||||||
|
for (const reply of thread.replies) {
|
||||||
|
collectAuthors(reply).forEach((author) => authors.add(author));
|
||||||
|
}
|
||||||
|
return Array.from(authors);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ThoughtPage({ params }: ThoughtPageProps) {
|
||||||
|
const { thoughtId } = params;
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const [threadResult, meResult] = await Promise.allSettled([
|
||||||
|
getThoughtThread(thoughtId, token),
|
||||||
|
token ? getMe(token) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (threadResult.status === "rejected") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const thread = threadResult.value;
|
||||||
|
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||||
|
|
||||||
|
// Fetch details for all authors in the thread efficiently
|
||||||
|
const authorUsernames = collectAuthors(thread);
|
||||||
|
const userProfiles = await Promise.all(
|
||||||
|
authorUsernames.map((username) =>
|
||||||
|
getUserProfile(username, token).catch(() => null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>(
|
||||||
|
userProfiles
|
||||||
|
.filter((u): u is User => !!u)
|
||||||
|
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="text-3xl font-bold">Thoughts</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<ThoughtThread
|
||||||
|
thought={thread}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
33
thoughts-frontend/app/users/[username]/followers/page.tsx
Normal file
33
thoughts-frontend/app/users/[username]/followers/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getFollowersList } from "@/lib/api";
|
||||||
|
import { UserListCard } from "@/components/user-list-card";
|
||||||
|
|
||||||
|
interface FollowersPageProps {
|
||||||
|
params: { username: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FollowersPage({ params }: FollowersPageProps) {
|
||||||
|
const { username } = params;
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const followersData = await getFollowersList(username, token).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!followersData) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="text-3xl font-bold">Followers</h1>
|
||||||
|
<p className="text-muted-foreground">Users following @{username}.</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<UserListCard users={followersData.users} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
33
thoughts-frontend/app/users/[username]/following/page.tsx
Normal file
33
thoughts-frontend/app/users/[username]/following/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getFollowingList } from "@/lib/api";
|
||||||
|
import { UserListCard } from "@/components/user-list-card";
|
||||||
|
|
||||||
|
interface FollowingPageProps {
|
||||||
|
params: { username: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FollowingPage({ params }: FollowingPageProps) {
|
||||||
|
const { username } = params;
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const followingData = await getFollowingList(username, token).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!followingData) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||||
|
<header className="my-6">
|
||||||
|
<h1 className="text-3xl font-bold">Following</h1>
|
||||||
|
<p className="text-muted-foreground">Users that @{username} follows.</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<UserListCard users={followingData.users} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
30
thoughts-frontend/app/users/[username]/loading.tsx
Normal file
30
thoughts-frontend/app/users/[username]/loading.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// app/users/[username]/loading.tsx
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
// This is the ProfileSkeleton component from the previous step.
|
||||||
|
// Next.js will automatically render this while page.tsx is loading.
|
||||||
|
export default function ProfileLoading() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
<main className="container mx-auto max-w-3xl p-4 -mt-16">
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<Skeleton className="h-24 w-24 rounded-full" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-40" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-full mt-4" />
|
||||||
|
<Skeleton className="h-6 w-3/4 mt-2" />
|
||||||
|
</Card>
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
230
thoughts-frontend/app/users/[username]/page.tsx
Normal file
230
thoughts-frontend/app/users/[username]/page.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import {
|
||||||
|
getFollowersList,
|
||||||
|
getFollowingList,
|
||||||
|
getFriends,
|
||||||
|
getMe,
|
||||||
|
getUserProfile,
|
||||||
|
getUserThoughts,
|
||||||
|
Me,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
|
import { Calendar, Settings } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { FollowButton } from "@/components/follow-button";
|
||||||
|
import { TopFriends } from "@/components/top-friends";
|
||||||
|
import { buildThoughtThreads } from "@/lib/utils";
|
||||||
|
import { ThoughtThread } from "@/components/thought-thread";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface ProfilePageProps {
|
||||||
|
params: { username: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
|
const { username } = params;
|
||||||
|
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||||
|
|
||||||
|
const userProfilePromise = getUserProfile(username, token);
|
||||||
|
const thoughtsPromise = getUserThoughts(username, token);
|
||||||
|
const mePromise = token ? getMe(token) : Promise.resolve(null);
|
||||||
|
const followersPromise = getFollowersList(username, token);
|
||||||
|
const followingPromise = getFollowingList(username, token);
|
||||||
|
|
||||||
|
const [
|
||||||
|
userResult,
|
||||||
|
thoughtsResult,
|
||||||
|
meResult,
|
||||||
|
followersResult,
|
||||||
|
followingResult,
|
||||||
|
] = await Promise.allSettled([
|
||||||
|
userProfilePromise,
|
||||||
|
thoughtsPromise,
|
||||||
|
mePromise,
|
||||||
|
followersPromise,
|
||||||
|
followingPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (userResult.status === "rejected") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.value;
|
||||||
|
const me = meResult.status === "fulfilled" ? (meResult.value as Me) : null;
|
||||||
|
|
||||||
|
const thoughts =
|
||||||
|
thoughtsResult.status === "fulfilled" ? thoughtsResult.value.thoughts : [];
|
||||||
|
const thoughtThreads = buildThoughtThreads(thoughts);
|
||||||
|
|
||||||
|
const followersCount =
|
||||||
|
followersResult.status === "fulfilled"
|
||||||
|
? followersResult.value.users.length
|
||||||
|
: 0;
|
||||||
|
const followingCount =
|
||||||
|
followingResult.status === "fulfilled"
|
||||||
|
? followingResult.value.users.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const isOwnProfile = me?.username === user.username;
|
||||||
|
const isFollowing =
|
||||||
|
me?.following?.some(
|
||||||
|
(followedUser) => followedUser.username === user.username
|
||||||
|
) || false;
|
||||||
|
|
||||||
|
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||||
|
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||||
|
|
||||||
|
const friends =
|
||||||
|
typeof token === "string"
|
||||||
|
? (await getFriends(token)).users.map((user) => user.username)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const shouldDisplayTopFriends = token && friends.length > 8;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id={`profile-page-${user.username}`}>
|
||||||
|
{user.customCss && (
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: user.customCss }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="profile-header"
|
||||||
|
className="h-48 bg-gray-200 bg-cover bg-center profile-header"
|
||||||
|
style={{
|
||||||
|
backgroundImage: user.headerUrl ? `url(${user.headerUrl})` : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main
|
||||||
|
id="main-container"
|
||||||
|
className="container mx-auto max-w-6xl p-4 -mt-16 grid grid-cols-1 lg:grid-cols-4 gap-8"
|
||||||
|
>
|
||||||
|
{/* Left Sidebar (Profile Card & Top Friends) */}
|
||||||
|
<aside id="left-sidebar" className="col-span-1 lg:col-span-1 space-y-6">
|
||||||
|
<div id="left-sidebar__inner" className="sticky top-20 space-y-6">
|
||||||
|
<Card id="profile-card" className="p-6 bg-card/80 backdrop-blur-lg">
|
||||||
|
<div
|
||||||
|
id="profile-card__inner"
|
||||||
|
className="flex justify-between items-start"
|
||||||
|
>
|
||||||
|
<div id="profile-card__avatar" className="flex items-end gap-4">
|
||||||
|
<div
|
||||||
|
id="profile-card__avatar-image"
|
||||||
|
className="w-24 h-24 rounded-full border-4 border-background shrink-0"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Action Button */}
|
||||||
|
<div id="profile-card__action">
|
||||||
|
{isOwnProfile ? (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/settings/profile">
|
||||||
|
<Settings className="mr-2 h-4 w-4" /> Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : token ? (
|
||||||
|
<FollowButton
|
||||||
|
username={user.username}
|
||||||
|
isInitiallyFollowing={isFollowing}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="profile-card__info" className="mt-4">
|
||||||
|
<h1 id="profile-card__name" className="text-2xl font-bold">
|
||||||
|
{user.displayName || user.username}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
id="profile-card__username"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
@{user.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
id="profile-card__bio"
|
||||||
|
className="mt-4 text-sm whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
{user.bio}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isOwnProfile && (
|
||||||
|
<div
|
||||||
|
id="profile-card__stats"
|
||||||
|
className="flex items-center gap-4 mt-4 text-sm"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/users/${user.username}/following`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
<span className="font-bold">{followingCount}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
Following
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/users/${user.username}/followers`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
<span className="font-bold">{followersCount}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
Followers
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="profile-card__joined"
|
||||||
|
className="flex items-center gap-2 mt-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Joined {new Date(user.joinedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{shouldDisplayTopFriends && (
|
||||||
|
<TopFriends mode="top-friends" usernames={user.topFriends} />
|
||||||
|
)}
|
||||||
|
{token && <TopFriends mode="friends" usernames={friends || []} />}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="profile-card__thoughts"
|
||||||
|
className="col-span-1 lg:col-span-3 space-y-4"
|
||||||
|
>
|
||||||
|
{thoughtThreads.map((thought) => (
|
||||||
|
<ThoughtThread
|
||||||
|
key={thought.id}
|
||||||
|
thought={thought}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
currentUser={me}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{thoughtThreads.length === 0 && (
|
||||||
|
<Card
|
||||||
|
id="profile-card__no-thoughts"
|
||||||
|
className="flex items-center justify-center h-48"
|
||||||
|
>
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
This user hasn't posted any public thoughts yet.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
63
thoughts-frontend/app/users/all/page.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
@@ -4,9 +4,55 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "thoughts-frontend",
|
"name": "thoughts-frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"lucide-react": "^0.542.0",
|
||||||
"next": "15.5.2",
|
"next": "15.5.2",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-day-picker": "^9.9.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.62.0",
|
||||||
|
"react-resizable-panels": "^3.0.5",
|
||||||
|
"recharts": "2.15.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tone": "^15.1.22",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.1.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -17,6 +63,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.2",
|
"eslint-config-next": "15.5.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.3.8",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -24,6 +71,10 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
|
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||||
|
|
||||||
|
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
"@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||||
@@ -48,6 +99,16 @@
|
|||||||
|
|
||||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="],
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="],
|
||||||
|
|
||||||
|
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
||||||
|
|
||||||
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
||||||
|
|
||||||
|
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
|
||||||
|
|
||||||
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||||
|
|
||||||
|
"@hookform/resolvers": ["@hookform/resolvers@5.2.1", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
|
|
||||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
@@ -142,10 +203,120 @@
|
|||||||
|
|
||||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||||
|
|
||||||
|
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||||
|
|
||||||
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||||
|
|
||||||
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||||
|
|
||||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||||
|
|
||||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.12.0", "", {}, "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw=="],
|
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.12.0", "", {}, "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw=="],
|
||||||
|
|
||||||
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.13", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.18", "source-map-js": "^1.2.1", "tailwindcss": "4.1.13" } }, "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.13", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.18", "source-map-js": "^1.2.1", "tailwindcss": "4.1.13" } }, "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw=="],
|
||||||
@@ -180,8 +351,28 @@
|
|||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
|
||||||
|
|
||||||
|
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
|
||||||
|
|
||||||
|
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||||
|
|
||||||
|
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||||
|
|
||||||
|
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||||
|
|
||||||
|
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||||
|
|
||||||
|
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||||
|
|
||||||
|
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
|
||||||
|
|
||||||
|
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||||
|
|
||||||
|
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
||||||
@@ -260,6 +451,8 @@
|
|||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||||
@@ -282,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=="],
|
||||||
@@ -308,8 +503,14 @@
|
|||||||
|
|
||||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||||
|
|
||||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
@@ -324,6 +525,28 @@
|
|||||||
|
|
||||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||||
|
|
||||||
|
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||||
|
|
||||||
|
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||||
|
|
||||||
|
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
|
||||||
|
|
||||||
|
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||||
|
|
||||||
|
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||||
|
|
||||||
|
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||||
|
|
||||||
|
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||||
|
|
||||||
|
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||||
|
|
||||||
|
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||||
|
|
||||||
|
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|
||||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
||||||
|
|
||||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||||
@@ -332,8 +555,14 @@
|
|||||||
|
|
||||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||||
|
|
||||||
|
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||||
|
|
||||||
|
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||||
|
|
||||||
|
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||||
|
|
||||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||||
@@ -342,10 +571,20 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||||
|
|
||||||
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||||
|
|
||||||
|
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
|
||||||
|
|
||||||
|
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
|
||||||
|
|
||||||
|
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||||
@@ -400,8 +639,12 @@
|
|||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="],
|
||||||
|
|
||||||
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||||
|
|
||||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
@@ -432,6 +675,8 @@
|
|||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||||
@@ -470,8 +715,12 @@
|
|||||||
|
|
||||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
|
|
||||||
|
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
|
||||||
|
|
||||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||||
|
|
||||||
|
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||||
|
|
||||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||||
|
|
||||||
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
|
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
|
||||||
@@ -534,6 +783,8 @@
|
|||||||
|
|
||||||
"jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
|
"jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
|
||||||
|
|
||||||
|
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||||
@@ -580,10 +831,14 @@
|
|||||||
|
|
||||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
|
|
||||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
|
"magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
@@ -612,6 +867,8 @@
|
|||||||
|
|
||||||
"next": ["next@15.5.2", "", { "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.2", "@next/swc-darwin-x64": "15.5.2", "@next/swc-linux-arm64-gnu": "15.5.2", "@next/swc-linux-arm64-musl": "15.5.2", "@next/swc-linux-x64-gnu": "15.5.2", "@next/swc-linux-x64-musl": "15.5.2", "@next/swc-win32-arm64-msvc": "15.5.2", "@next/swc-win32-x64-msvc": "15.5.2", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q=="],
|
"next": ["next@15.5.2", "", { "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.2", "@next/swc-darwin-x64": "15.5.2", "@next/swc-linux-arm64-gnu": "15.5.2", "@next/swc-linux-arm64-musl": "15.5.2", "@next/swc-linux-x64-gnu": "15.5.2", "@next/swc-linux-x64-musl": "15.5.2", "@next/swc-win32-arm64-msvc": "15.5.2", "@next/swc-win32-x64-msvc": "15.5.2", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q=="],
|
||||||
|
|
||||||
|
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||||
|
|
||||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
@@ -662,9 +919,29 @@
|
|||||||
|
|
||||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||||
|
|
||||||
|
"react-day-picker": ["react-day-picker@9.9.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
||||||
|
|
||||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"react-hook-form": ["react-hook-form@7.62.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="],
|
||||||
|
|
||||||
|
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||||
|
|
||||||
|
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
|
||||||
|
|
||||||
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
|
|
||||||
|
"react-resizable-panels": ["react-resizable-panels@3.0.5", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-3z1yN25DMTXLg2wfyFrW32r5k4WEcUa3F7cJ2EgtNK07lnOs4mpM8yWLGunCpkhcQRwJX4fqoLcIh/pHPxzlmQ=="],
|
||||||
|
|
||||||
|
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
|
||||||
|
|
||||||
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||||
|
|
||||||
|
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
|
||||||
|
|
||||||
|
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
|
||||||
|
|
||||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||||
|
|
||||||
@@ -712,10 +989,14 @@
|
|||||||
|
|
||||||
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
|
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
|
||||||
|
|
||||||
|
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
@@ -740,22 +1021,30 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.13", "", {}, "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w=="],
|
"tailwindcss": ["tailwindcss@4.1.13", "", {}, "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.2.3", "", {}, "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg=="],
|
"tapable": ["tapable@2.2.3", "", {}, "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg=="],
|
||||||
|
|
||||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||||
|
|
||||||
|
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tw-animate-css": ["tw-animate-css@1.3.8", "", {}, "sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw=="],
|
||||||
|
|
||||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||||
@@ -776,6 +1065,16 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
|
|
||||||
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
|
|
||||||
|
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||||
|
|
||||||
|
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||||
|
|
||||||
|
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||||
@@ -792,6 +1091,8 @@
|
|||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.1.5", "", {}, "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg=="],
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||||
@@ -830,6 +1131,8 @@
|
|||||||
|
|
||||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||||
|
|
||||||
|
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
"sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
22
thoughts-frontend/components.json
Normal file
22
thoughts-frontend/components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
214
thoughts-frontend/components/api-keys-list.tsx
Normal file
214
thoughts-frontend/components/api-keys-list.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// thoughts-frontend/components/api-key-list.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import {
|
||||||
|
ApiKey,
|
||||||
|
CreateApiKeySchema,
|
||||||
|
createApiKey,
|
||||||
|
deleteApiKey,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Copy, KeyRound, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
interface ApiKeyListProps {
|
||||||
|
initialApiKeys: ApiKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyList({ initialApiKeys }: ApiKeyListProps) {
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>(initialApiKeys);
|
||||||
|
const [newKey, setNewKey] = useState<string | null>(null);
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof CreateApiKeySchema>>({
|
||||||
|
resolver: zodResolver(CreateApiKeySchema),
|
||||||
|
defaultValues: { name: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof CreateApiKeySchema>) {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const newKeyResponse = await createApiKey(values, token);
|
||||||
|
setKeys((prev) => [...prev, newKeyResponse]);
|
||||||
|
setNewKey(newKeyResponse.plaintextKey ?? null);
|
||||||
|
form.reset();
|
||||||
|
toast.success("API Key created successfully.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create API key.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (keyId: string) => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await deleteApiKey(keyId, token);
|
||||||
|
setKeys((prev) => prev.filter((key) => key.id !== keyId));
|
||||||
|
toast.success("API Key deleted successfully.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete API key.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success("Key copied to clipboard!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Existing Keys</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
These are the API keys associated with your account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{keys.length > 0 ? (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<li
|
||||||
|
key={key.id}
|
||||||
|
className="flex items-center justify-between p-4 glass-effect rounded-md shadow-fa-sm glossy-effect bottom"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<KeyRound className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{key.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{`Created on ${format(key.createdAt, "PPP")}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-mono text-muted-foreground mt-1">
|
||||||
|
{`${key.keyPrefix}...`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the key "{key.name}
|
||||||
|
". This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => handleDelete(key.id)}>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have no API keys.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display New Key */}
|
||||||
|
{newKey && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>New API Key Generated</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Please copy this key and store it securely. You will not be able
|
||||||
|
to see it again.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center gap-4">
|
||||||
|
<Input readOnly value={newKey} className="font-mono" />
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(newKey)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button onClick={() => setNewKey(null)}>Done</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create New API Key</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Give your new key a descriptive name.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<CardContent>
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Key Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g., My Cool App" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="px-6 py-4">
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create Key"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
127
thoughts-frontend/components/confetti.tsx
Normal file
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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
167
thoughts-frontend/components/edit-profile-form.tsx
Normal file
167
thoughts-frontend/components/edit-profile-form.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Me, UpdateProfileSchema, updateProfile } from "@/lib/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
FormDescription,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { TopFriendsCombobox } from "@/components/top-friends-combobox";
|
||||||
|
|
||||||
|
interface EditProfileFormProps {
|
||||||
|
currentUser: Me;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditProfileForm({ currentUser }: EditProfileFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof UpdateProfileSchema>>({
|
||||||
|
resolver: zodResolver(UpdateProfileSchema),
|
||||||
|
defaultValues: {
|
||||||
|
displayName: currentUser.displayName ?? undefined,
|
||||||
|
bio: currentUser.bio ?? undefined,
|
||||||
|
avatarUrl: currentUser.avatarUrl ?? undefined,
|
||||||
|
headerUrl: currentUser.headerUrl ?? undefined,
|
||||||
|
customCss: currentUser.customCss ?? undefined,
|
||||||
|
topFriends: currentUser.topFriends ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof UpdateProfileSchema>) {
|
||||||
|
if (!token) return;
|
||||||
|
toast.info("Updating your profile...");
|
||||||
|
try {
|
||||||
|
await updateProfile(values, token);
|
||||||
|
toast.success("Profile updated successfully!");
|
||||||
|
router.push(`/users/${currentUser.username}`);
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Failed to update profile. ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-6 pt-6">
|
||||||
|
<FormField
|
||||||
|
name="displayName"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Display Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Your display name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="bio"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="Tell us about yourself" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="avatarUrl"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Avatar URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/avatar.png"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="headerUrl"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Header URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/header.jpg"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="customCss"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Custom CSS</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="body { font-family: 'Comic Sans MS'; }"
|
||||||
|
rows={5}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="topFriends"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>Top Friends</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TopFriendsCombobox
|
||||||
|
value={field.value || []}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Select up to 8 of your friends to display on your profile.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="border-t px-6 py-4">
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
66
thoughts-frontend/components/follow-button.tsx
Normal file
66
thoughts-frontend/components/follow-button.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { followUser, unfollowUser } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { UserPlus, UserMinus } from "lucide-react";
|
||||||
|
|
||||||
|
interface FollowButtonProps {
|
||||||
|
username: string;
|
||||||
|
isInitiallyFollowing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowButton({
|
||||||
|
username,
|
||||||
|
isInitiallyFollowing,
|
||||||
|
}: FollowButtonProps) {
|
||||||
|
const [isFollowing, setIsFollowing] = useState(isInitiallyFollowing);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (!token) {
|
||||||
|
toast.error("You must be logged in to follow users.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const action = isFollowing ? unfollowUser : followUser;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Optimistic update
|
||||||
|
setIsFollowing(!isFollowing);
|
||||||
|
await action(username, token);
|
||||||
|
router.refresh(); // Re-fetch server component data to get the latest follower count etc.
|
||||||
|
} catch {
|
||||||
|
// Revert on error
|
||||||
|
setIsFollowing(isFollowing);
|
||||||
|
toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} user.`);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant={isFollowing ? "secondary" : "default"}
|
||||||
|
data-following={isFollowing}
|
||||||
|
>
|
||||||
|
{isFollowing ? (
|
||||||
|
<>
|
||||||
|
<UserMinus className="mr-2 h-4 w-4" /> Unfollow
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" /> Follow
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
40
thoughts-frontend/components/header.tsx
Normal file
40
thoughts-frontend/components/header.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { UserNav } from "./user-nav";
|
||||||
|
import { MainNav } from "./main-nav";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-primary/20 bg-background/80 glass-effect glossy-effect bottom rounded-none">
|
||||||
|
<div className="container flex h-14 items-center px-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href="/" className="flex items-center gap-1">
|
||||||
|
<span className="hidden font-bold text-primary sm:inline-block">
|
||||||
|
Thoughts
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<MainNav />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||||
|
{token ? (
|
||||||
|
<UserNav />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link href="/login">Login</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link href="/register">Register</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
119
thoughts-frontend/components/install-prompt.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
24
thoughts-frontend/components/main-nav.tsx
Normal file
24
thoughts-frontend/components/main-nav.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SearchInput } from "./search-input";
|
||||||
|
|
||||||
|
export function MainNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<nav className="inline-flex md:flex items-center space-x-6 text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href="/users/all"
|
||||||
|
className={cn(
|
||||||
|
"transition-colors hover:text-foreground/80",
|
||||||
|
pathname === "/users/all" ? "text-foreground" : "text-foreground/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Discover
|
||||||
|
</Link>
|
||||||
|
<SearchInput />
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
48
thoughts-frontend/components/popular-tags.tsx
Normal file
48
thoughts-frontend/components/popular-tags.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { getPopularTags } from "@/lib/api";
|
||||||
|
import { Hash } from "lucide-react";
|
||||||
|
|
||||||
|
export async function PopularTags() {
|
||||||
|
const tags = await getPopularTags().catch(() => []);
|
||||||
|
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Popular Tags</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
No popular tags to display.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<CardHeader className="p-0 pb-2">
|
||||||
|
<CardTitle className="text-lg">Popular Tags</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2 p-0">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Link href={`/tags/${tag}`} key={tag}>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="hover:shadow-lg transition-shadow text-shadow-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<Hash className="mr-1 h-3 w-3" />
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{tags.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No popular tags yet.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
125
thoughts-frontend/components/post-thought-form.tsx
Normal file
125
thoughts-frontend/components/post-thought-form.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { CreateThoughtSchema, createThought } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Globe, Lock, Users } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Confetti } from "./confetti";
|
||||||
|
|
||||||
|
export function PostThoughtForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||||||
|
resolver: zodResolver(CreateThoughtSchema),
|
||||||
|
defaultValues: { content: "", visibility: "Public" },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
|
||||||
|
if (!token) {
|
||||||
|
toast.error("You must be logged in to post.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createThought(values, token);
|
||||||
|
toast.success("Your thought has been posted!");
|
||||||
|
setShowConfetti(true);
|
||||||
|
form.reset();
|
||||||
|
router.refresh(); // This is the key to updating the feed
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to post thought. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
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
|
||||||
|
control={form.control}
|
||||||
|
name="visibility"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
97
thoughts-frontend/components/reply-form.tsx
Normal file
97
thoughts-frontend/components/reply-form.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { CreateThoughtSchema, createThought } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Confetti } from "./confetti";
|
||||||
|
|
||||||
|
interface ReplyFormProps {
|
||||||
|
parentThoughtId: string;
|
||||||
|
onReplySuccess: () => void; // A callback to close the form after success
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
|
||||||
|
resolver: zodResolver(CreateThoughtSchema),
|
||||||
|
defaultValues: {
|
||||||
|
content: "",
|
||||||
|
replyToId: parentThoughtId,
|
||||||
|
visibility: "Public", // Replies default to Public
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
|
||||||
|
if (!token) {
|
||||||
|
toast.error("You must be logged in to reply.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createThought(values, token);
|
||||||
|
toast.success("Your reply has been posted!");
|
||||||
|
form.reset();
|
||||||
|
setShowConfetti(true);
|
||||||
|
console.log("Showing confetti");
|
||||||
|
onReplySuccess();
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to post reply. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 p-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="content"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Post your reply..."
|
||||||
|
className="resize-none bg-white glass-effect glossy-efect bottom shadow-fa-sm"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onReplySuccess} // Close button
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? "Replying..." : "Reply"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
29
thoughts-frontend/components/search-input.tsx
Normal file
29
thoughts-frontend/components/search-input.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Search as SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export function SearchInput() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const query = formData.get("q") as string;
|
||||||
|
if (query) {
|
||||||
|
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSearch} className="relative w-full max-w-sm">
|
||||||
|
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
name="q"
|
||||||
|
placeholder="Search for users or thoughts..."
|
||||||
|
className="pl-9 md:min-w-[250px]"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
41
thoughts-frontend/components/settings-nav.tsx
Normal file
41
thoughts-frontend/components/settings-nav.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface SettingsNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
items: {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsNav({ className, items, ...props }: SettingsNavProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
"flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
pathname === item.href ? "bg-muted" : "hover:underline",
|
||||||
|
"justify-start glass-effect glossy-effect bottom shadow-fa-md"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user