Compare commits

..

8 Commits

Author SHA1 Message Date
08213133be feat: update environment configuration, enhance Dockerfiles, and refactor API handling 2025-09-07 19:55:49 +02:00
5f8cf49ec9 feat: simplify error handling in login and registration pages, add install prompt component, and update favicon and icons 2025-09-07 18:43:56 +02:00
c6f5bab1eb feat: update background image format and remove unused SVG files 2025-09-07 18:11:53 +02:00
72b4cb0851 feat: add confetti animation on thought submission and update dependencies 2025-09-07 17:43:17 +02:00
dd279a1434 feat: add popular tags section to FeedPage and update LandingPage text 2025-09-07 17:36:32 +02:00
6efab333f3 Remove federation functionality and related tests
- Deleted the `federation.rs` module and its associated functionality for federating thoughts to followers.
- Removed the `well_known.rs` module and its WebFinger discovery functionality.
- Eliminated references to federation in the `thought.rs` router and removed the spawning of background tasks for federating thoughts.
- Deleted tests related to WebFinger and user inbox interactions in `activitypub.rs`.
- Updated `Cargo.toml` to remove the `activitypub_federation` dependency.
2025-09-07 17:22:58 +02:00
1a405500ca feat: update top friends display condition to require more than 8 friends 2025-09-07 15:16:18 +02:00
3d25ffca4f feat: add visibility check for tagging in thought creation 2025-09-07 15:15:24 +02:00
58 changed files with 502 additions and 1153 deletions

7
.env
View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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 openssl && 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

View File

@@ -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]

View File

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

View File

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

View File

@@ -9,9 +9,8 @@ pub mod search;
pub mod tag; pub mod tag;
pub mod thought; pub mod thought;
pub mod user; pub mod user;
pub mod well_known;
use crate::routers::{auth::create_auth_router, well_known::create_well_known_router}; use crate::routers::auth::create_auth_router;
use app::state::AppState; use app::state::AppState;
use root::create_root_router; use root::create_root_router;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
@@ -24,7 +23,6 @@ pub fn create_router(state: AppState) -> Router {
Router::new() Router::new()
.merge(create_root_router()) .merge(create_root_router())
.nest("/.well-known", create_well_known_router())
.nest("/auth", create_auth_router()) .nest("/auth", create_auth_router())
.nest("/users", create_user_router()) .nest("/users", create_user_router())
.nest("/thoughts", create_thought_router()) .nest("/thoughts", create_thought_router())

View File

@@ -20,7 +20,6 @@ use sea_orm::prelude::Uuid;
use crate::{ use crate::{
error::ApiError, error::ApiError,
extractor::{AuthUser, Json, OptionalAuthUser, Valid}, extractor::{AuthUser, Json, OptionalAuthUser, Valid},
federation,
models::{ApiErrorResponse, ParamsErrorResponse}, models::{ApiErrorResponse, ParamsErrorResponse},
}; };
@@ -77,13 +76,6 @@ async fn thoughts_post(
.await? .await?
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid .ok_or(UserError::NotFound)?; // Should not happen if auth is valid
// Spawn a background task to handle federation without blocking the response
tokio::spawn(federation::federate_thought(
state.clone(),
thought.clone(),
author.clone(),
));
let schema = ThoughtSchema::from_models(&thought, &author); let schema = ThoughtSchema::from_models(&thought, &author);
Ok((StatusCode::CREATED, Json(schema))) Ok((StatusCode::CREATED, Json(schema)))
} }

View File

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

View File

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

View File

@@ -35,11 +35,13 @@ pub async fn create_thought(
.insert(&txn) .insert(&txn)
.await?; .await?;
if new_thought.visibility == thought::Visibility::Public {
let tag_names = parse_hashtags(&params.content); let tag_names = parse_hashtags(&params.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)

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
use api::{setup_config, setup_db, setup_router}; use api::{setup_config, setup_db, setup_router};
use utils::{create_dev_db, migrate}; use utils::migrate;
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) { async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
let conn = setup_db(db_url, prefork).await; let conn = setup_db(db_url, prefork).await;
@@ -19,7 +19,6 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
#[cfg(feature = "prefork")] #[cfg(feature = "prefork")]
fn run_prefork(db_url: &str, listener: std::net::TcpListener) { fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
let db_url: &'static str = Box::leak(db_url.to_owned().into_boxed_str()); let db_url: &'static str = Box::leak(db_url.to_owned().into_boxed_str());
create_dev_db(db_url);
let num_of_cores = std::thread::available_parallelism().unwrap().get() as u32; let num_of_cores = std::thread::available_parallelism().unwrap().get() as u32;
let is_parent = prefork::Prefork::from_resource(listener) let is_parent = prefork::Prefork::from_resource(listener)
@@ -34,8 +33,6 @@ fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
} }
fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) { fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) {
create_dev_db(db_url);
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(worker(0, db_url, false, listener)); rt.block_on(worker(0, db_url, false, listener));
} }

View File

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

View File

@@ -1,4 +1,3 @@
mod activitypub;
mod api_key; mod api_key;
mod auth; mod auth;
mod feed; mod feed;

View File

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

View File

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

View File

@@ -1,30 +1,27 @@
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 COPY --from=builder /app/public ./public
COPY --from=prerelease /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=prerelease /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
USER bun
EXPOSE 3000 EXPOSE 3000
CMD ["bun", "run", "server.js"] CMD ["node", "server.js"]

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -183,7 +183,7 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
background-image: url("/background.jpeg"); background-image: url("/background.avif");
background-size: cover; background-size: cover;
background-position: center; background-position: center;
background-attachment: fixed; background-attachment: fixed;

View File

@@ -1,10 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { AuthProvider } from "@/hooks/use-auth"; import { AuthProvider } from "@/hooks/use-auth";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import localFont from "next/font/local"; import localFont from "next/font/local";
import InstallPrompt from "@/components/install-prompt";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Thoughts", title: "Thoughts",
@@ -38,6 +38,7 @@ export default function RootLayout({
<AuthProvider> <AuthProvider>
<Header /> <Header />
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<InstallPrompt />
<Toaster /> <Toaster />
</AuthProvider> </AuthProvider>
</body> </body>

View File

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

View File

@@ -46,7 +46,7 @@ async function FeedPage({ token }: { token: string }) {
); );
const friends = (await getFriends(token)).users.map((user) => user.username); const friends = (await getFriends(token)).users.map((user) => user.username);
const shouldDisplayTopFriends = me?.topFriends && me.topFriends.length > 0; const shouldDisplayTopFriends = me?.topFriends && me.topFriends.length > 8;
return ( return (
<div className="container mx-auto max-w-6xl p-4 sm:p-6"> <div className="container mx-auto max-w-6xl p-4 sm:p-6">
@@ -63,6 +63,11 @@ async function FeedPage({ token }: { token: string }) {
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1> <h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
</header> </header>
<PostThoughtForm /> <PostThoughtForm />
<div className="block lg:hidden space-y-6">
<PopularTags />
</div>
<div className="space-y-6"> <div className="space-y-6">
{thoughtThreads.map((thought) => ( {thoughtThreads.map((thought) => (
<ThoughtThread <ThoughtThread
@@ -96,8 +101,9 @@ async function FeedPage({ token }: { token: string }) {
function LandingPage() { function LandingPage() {
return ( return (
<>
<div className="font-sans min-h-screen text-gray-800 flex items-center justify-center"> <div className="font-sans min-h-screen text-gray-800 flex items-center justify-center">
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center"> <div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center glass-effect glossy-effect bottom rounded-md shadow-fa-lg">
<h1 <h1
className="text-5xl font-bold" className="text-5xl font-bold"
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }} style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
@@ -105,7 +111,7 @@ function LandingPage() {
Welcome to Thoughts Welcome to Thoughts
</h1> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Your space on the decentralized web. Throwback to the golden age of microblogging.
</p> </p>
<div className="mt-8 flex justify-center gap-4"> <div className="mt-8 flex justify-center gap-4">
<Button asChild> <Button asChild>
@@ -117,5 +123,6 @@ function LandingPage() {
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
"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";
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);
useEffect(() => {
// Cast window to our custom type instead of 'any'
const customWindow = window as CustomWindow;
setIsIOS(
/iPad|iPhone|iPod/.test(navigator.userAgent) && !customWindow.MSStream
);
setIsStandalone(window.matchMedia("(display-mode: standalone)").matches);
const 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");
}
setDeferredPrompt(null);
};
if (isStandalone || (!isIOS && !deferredPrompt)) {
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={() => setIsStandalone(true)}
>
&times;
</Button>
</CardAction>
</CardHeader>
<CardContent>
{!isIOS && deferredPrompt && (
<Button className="w-full" onClick={handleInstallClick}>
<Download className="mr-2 h-4 w-4" />
Add to Home Screen
</Button>
)}
{isIOS && (
<p className="text-sm text-muted-foreground">
To install, tap the Share icon
<span className="mx-1 text-lg"></span>
and then &quot;Add to Home Screen&quot;
<span className="mx-1 text-lg"></span>.
</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -8,7 +8,7 @@ import { SearchInput } from "./search-input";
export function MainNav() { export function MainNav() {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium"> <nav className="inline-flex md:flex items-center space-x-6 text-sm font-medium">
<Link <Link
href="/" href="/"
className={cn( className={cn(

View File

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

View File

@@ -1,4 +1,3 @@
// components/reply-form.tsx
"use client"; "use client";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -17,6 +16,8 @@ import { Textarea } from "@/components/ui/textarea";
import { CreateThoughtSchema, createThought } from "@/lib/api"; import { CreateThoughtSchema, createThought } from "@/lib/api";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState } from "react";
import { Confetti } from "./confetti";
interface ReplyFormProps { interface ReplyFormProps {
parentThoughtId: string; parentThoughtId: string;
@@ -26,6 +27,7 @@ interface ReplyFormProps {
export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) { export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
const router = useRouter(); const router = useRouter();
const { token } = useAuth(); const { token } = useAuth();
const [showConfetti, setShowConfetti] = useState(false);
const form = useForm<z.infer<typeof CreateThoughtSchema>>({ const form = useForm<z.infer<typeof CreateThoughtSchema>>({
resolver: zodResolver(CreateThoughtSchema), resolver: zodResolver(CreateThoughtSchema),
@@ -46,14 +48,18 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
await createThought(values, token); await createThought(values, token);
toast.success("Your reply has been posted!"); toast.success("Your reply has been posted!");
form.reset(); form.reset();
onReplySuccess(); // Call the callback setShowConfetti(true);
router.refresh(); // Refresh the page to show the new reply console.log("Showing confetti");
} catch (err) { onReplySuccess();
router.refresh();
} catch {
toast.error("Failed to post reply. Please try again."); toast.error("Failed to post reply. Please try again.");
} }
} }
return ( return (
<>
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 p-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 p-4">
<FormField <FormField
@@ -86,5 +92,6 @@ export function ReplyForm({ parentThoughtId, onReplySuccess }: ReplyFormProps) {
</div> </div>
</form> </form>
</Form> </Form>
</>
); );
} }

View File

@@ -17,7 +17,7 @@ const buttonVariants = cva(
"glass-effect fa-gradient-green text-secondary-foreground shadow-fa-md hover:bg-secondary/90 active:shadow-fa-inner transition-transform active:scale-[0.98] glossy-effect", "glass-effect fa-gradient-green text-secondary-foreground shadow-fa-md hover:bg-secondary/90 active:shadow-fa-inner transition-transform active:scale-[0.98] glossy-effect",
// Ghost and Link should be more subtle // Ghost and Link should be more subtle
ghost: ghost:
"glass-effect hover:bg-accent hover:text-accent-foreground rounded-lg", // Keep them simple, maybe a slight blur/gloss on hover "glass-effect hover:bg-accent hover:text-accent-foreground rounded-lg",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
// Outline button for a transparent-ish, glassy feel // Outline button for a transparent-ish, glassy feel
outline: outline:

View File

@@ -111,7 +111,10 @@ export type ApiKey = z.infer<typeof ApiKeySchema>;
export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>; export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>;
export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>; export type ThoughtThread = z.infer<typeof ThoughtThreadSchema>;
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; const API_BASE_URL =
typeof window === "undefined"
? process.env.NEXT_PUBLIC_SERVER_SIDE_API_URL // Server-side
: process.env.NEXT_PUBLIC_API_URL; // Client-side
async function apiFetch<T>( async function apiFetch<T>(
endpoint: string, endpoint: string,
@@ -119,6 +122,10 @@ async function apiFetch<T>(
schema: z.ZodType<T>, schema: z.ZodType<T>,
token?: string | null token?: string | null
): Promise<T> { ): Promise<T> {
if (!API_BASE_URL) {
throw new Error("API_BASE_URL is not defined");
}
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
@@ -128,7 +135,8 @@ async function apiFetch<T>(
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const response = await fetch(`${API_BASE_URL}${endpoint}`, { const fullUrl = `${API_BASE_URL}${endpoint}`;
const response = await fetch(fullUrl, {
...options, ...options,
headers, headers,
}); });

View File

@@ -55,6 +55,7 @@
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tone": "^15.1.22",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B