feat: initialize thoughts-frontend with Next.js, TypeScript, and ESLint

- Add ESLint configuration for Next.js and TypeScript support.
- Create Next.js configuration file with standalone output option.
- Initialize package.json with scripts for development, build, and linting.
- Set up PostCSS configuration for Tailwind CSS.
- Add SVG assets for UI components.
- Create TypeScript configuration for strict type checking and module resolution.
This commit is contained in:
2025-09-05 17:14:45 +02:00
parent 6bd06ff2c8
commit e5747eaaf3
104 changed files with 7484 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
use axum::{extract::rejection::JsonRejection, http::StatusCode};
use sea_orm::DbErr;
use app::error::UserError;
use super::traits::HTTPError;
impl HTTPError for JsonRejection {
fn to_status_code(&self) -> StatusCode {
match self {
JsonRejection::JsonSyntaxError(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_REQUEST,
}
}
}
impl HTTPError for DbErr {
fn to_status_code(&self) -> StatusCode {
match self {
DbErr::ConnectionAcquire(_) => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::INTERNAL_SERVER_ERROR, // TODO:: more granularity
}
}
}
impl HTTPError for UserError {
fn to_status_code(&self) -> StatusCode {
match self {
UserError::NotFound => StatusCode::NOT_FOUND,
}
}
}

View File

@@ -0,0 +1,10 @@
pub struct ApiError(pub(super) anyhow::Error);
impl<E> From<E> for ApiError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

View File

@@ -0,0 +1,36 @@
use axum::{
extract::rejection::JsonRejection,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use sea_orm::DbErr;
use app::error::UserError;
use super::{ApiError, HTTPError};
use crate::models::ApiErrorResponse;
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let err = self.0;
let (status, message) = if let Some(err) = err.downcast_ref::<DbErr>() {
tracing::error!(%err, "error from db:");
(err.to_status_code(), "DB error".to_string()) // hide the detail
} else if let Some(err) = err.downcast_ref::<UserError>() {
(err.to_status_code(), err.to_string())
} else if let Some(err) = err.downcast_ref::<JsonRejection>() {
tracing::error!(%err, "error from extractor:");
(err.to_status_code(), err.to_string())
} else {
tracing::error!(%err, "error from other source:");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Unknown error".to_string(),
)
};
(status, Json(ApiErrorResponse { message })).into_response()
}
}

View File

@@ -0,0 +1,7 @@
mod adapter;
mod core;
mod handler;
mod traits;
pub use core::ApiError;
pub use traits::HTTPError;

View File

@@ -0,0 +1,5 @@
use axum::http::StatusCode;
pub trait HTTPError {
fn to_status_code(&self) -> StatusCode;
}

View File

@@ -0,0 +1,26 @@
use axum::{
extract::FromRequest,
response::{IntoResponse, Response},
};
use validator::Validate;
use crate::error::ApiError;
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
pub struct Json<T>(pub T);
impl<T> IntoResponse for Json<T>
where
axum::Json<T>: IntoResponse,
{
fn into_response(self) -> Response {
axum::Json(self.0).into_response()
}
}
impl<T: Validate> Validate for Json<T> {
fn validate(&self) -> Result<(), validator::ValidationErrors> {
self.0.validate()
}
}

View File

@@ -0,0 +1,5 @@
mod json;
mod valid;
pub use json::Json;
pub use valid::Valid;

View File

@@ -0,0 +1,23 @@
use axum::extract::{FromRequest, Request};
use validator::Validate;
use crate::validation::ValidRejection;
#[derive(Debug, Clone, Copy, Default)]
pub struct Valid<T>(pub T);
impl<State, Extractor> FromRequest<State> for Valid<Extractor>
where
State: Send + Sync,
Extractor: Validate + FromRequest<State>,
{
type Rejection = ValidRejection<<Extractor as FromRequest<State>>::Rejection>;
async fn from_request(req: Request, state: &State) -> Result<Self, Self::Rejection> {
let inner = Extractor::from_request(req, state)
.await
.map_err(ValidRejection::Extractor)?;
inner.validate()?;
Ok(Valid(inner))
}
}

View File

@@ -0,0 +1,32 @@
use std::time::Duration;
use axum::Router;
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use app::config::Config;
use app::state::AppState;
use crate::routers::create_router;
// TODO: middleware, logging, authentication
pub fn setup_router(conn: DatabaseConnection) -> Router {
create_router(AppState { conn })
}
pub fn setup_config() -> Config {
dotenvy::dotenv().ok();
Config::from_env()
}
pub async fn setup_db(db_url: &str, prefork: bool) -> DatabaseConnection {
let mut opt = ConnectOptions::new(db_url);
opt.max_lifetime(Duration::from_secs(60));
if !prefork {
opt.min_connections(10).max_connections(100);
}
Database::connect(opt)
.await
.expect("Database connection failed")
}

View File

@@ -0,0 +1,9 @@
mod error;
mod extractor;
mod init;
mod validation;
pub mod models;
pub mod routers;
pub use init::{setup_config, setup_db, setup_router};

View File

@@ -0,0 +1,3 @@
mod response;
pub use response::{ApiErrorResponse, ParamsErrorResponse, ValidationErrorResponse};

View File

@@ -0,0 +1,27 @@
use std::collections::HashMap;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct ApiErrorResponse {
pub message: String,
}
#[derive(Serialize, ToSchema)]
pub struct ValidationErrorResponse<T> {
pub message: String,
pub details: T,
}
pub type ParamsErrorResponse =
ValidationErrorResponse<HashMap<String, Vec<HashMap<String, String>>>>;
impl<T> From<T> for ValidationErrorResponse<T> {
fn from(t: T) -> Self {
Self {
message: "Validation error".to_string(),
details: t,
}
}
}

View File

@@ -0,0 +1,68 @@
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Router,
};
use sea_orm::TryIntoModel;
use app::persistence::blog::{create_blog, search_blogs};
use app::state::AppState;
use models::params::blog::CreateBlogParams;
use models::queries::blog::BlogQuery;
use models::schemas::blog::{BlogListSchema, BlogSchema};
use crate::error::ApiError;
use crate::extractor::{Json, Valid};
use crate::models::{ApiErrorResponse, ParamsErrorResponse};
#[utoipa::path(
post,
path = "",
request_body = CreateBlogParams,
responses(
(status = 201, description = "Blog created", body = BlogSchema),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ParamsErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
)
)]
async fn blogs_post(
state: State<AppState>,
Valid(Json(params)): Valid<Json<CreateBlogParams>>,
) -> Result<impl IntoResponse, ApiError> {
let blog = create_blog(&state.conn, params)
.await
.map_err(ApiError::from)?;
let blog = blog.try_into_model().unwrap();
Ok((StatusCode::CREATED, Json(BlogSchema::from(blog))))
}
#[utoipa::path(
get,
path = "",
params(
BlogQuery
),
responses(
(status = 200, description = "List blogs", body = BlogListSchema),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
)
)]
async fn blogs_get(
state: State<AppState>,
query: Query<BlogQuery>,
) -> Result<impl IntoResponse, ApiError> {
let Query(query) = query;
let blogs = search_blogs(&state.conn, query)
.await
.map_err(ApiError::from)?;
Ok(Json(BlogListSchema::from(blogs)))
}
pub fn create_blog_router() -> Router<AppState> {
Router::new().route("/", get(blogs_get).post(blogs_post))
}

View File

@@ -0,0 +1,18 @@
use axum::Router;
pub mod blog;
pub mod root;
pub mod user;
use app::state::AppState;
use blog::create_blog_router;
use root::create_root_router;
use user::create_user_router;
pub fn create_router(state: AppState) -> Router {
Router::new()
.merge(create_root_router())
.nest("/users", create_user_router())
.nest("/blogs", create_blog_router())
.with_state(state)
}

View File

@@ -0,0 +1,30 @@
use axum::{extract::State, routing::get, Router};
use sea_orm::{ConnectionTrait, Statement};
use app::state::AppState;
use crate::error::ApiError;
#[utoipa::path(
get,
path = "",
responses(
(status = 200, description = "Hello world", body = String)
)
)]
async fn root_get(state: State<AppState>) -> Result<String, ApiError> {
let result = state
.conn
.query_one(Statement::from_string(
state.conn.get_database_backend(),
"SELECT 'Hello, World from DB!'",
))
.await
.map_err(ApiError::from)?;
result.unwrap().try_get_by(0).map_err(|e| e.into())
}
pub fn create_root_router() -> Router<AppState> {
Router::new().route("/", get(root_get))
}

View File

@@ -0,0 +1,93 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Router,
};
use sea_orm::TryIntoModel;
use app::error::UserError;
use app::persistence::user::{create_user, get_user, search_users};
use app::state::AppState;
use models::params::user::CreateUserParams;
use models::queries::user::UserQuery;
use models::schemas::user::{UserListSchema, UserSchema};
use crate::error::ApiError;
use crate::extractor::{Json, Valid};
use crate::models::{ApiErrorResponse, ParamsErrorResponse};
#[utoipa::path(
post,
path = "",
request_body = CreateUserParams,
responses(
(status = 201, description = "User created", body = UserSchema),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ParamsErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
)
)]
async fn users_post(
state: State<AppState>,
Valid(Json(params)): Valid<Json<CreateUserParams>>,
) -> Result<impl IntoResponse, ApiError> {
let user = create_user(&state.conn, params)
.await
.map_err(ApiError::from)?;
let user = user.try_into_model().unwrap();
Ok((StatusCode::CREATED, Json(UserSchema::from(user))))
}
#[utoipa::path(
get,
path = "",
params(
UserQuery
),
responses(
(status = 200, description = "List users", body = UserListSchema),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
)
)]
async fn users_get(
state: State<AppState>,
query: Query<UserQuery>,
) -> Result<impl IntoResponse, ApiError> {
let Query(query) = query;
let users = search_users(&state.conn, query)
.await
.map_err(ApiError::from)?;
Ok(Json(UserListSchema::from(users)))
}
#[utoipa::path(
get,
path = "/{id}",
params(
("id" = i32, Path, description = "User id")
),
responses(
(status = 200, description = "Get user", body = UserSchema),
(status = 404, description = "Not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
)
)]
async fn users_id_get(
state: State<AppState>,
Path(id): Path<i32>,
) -> Result<impl IntoResponse, ApiError> {
let user = get_user(&state.conn, id).await.map_err(ApiError::from)?;
user.map(|user| Json(UserSchema::from(user)))
.ok_or_else(|| UserError::NotFound.into())
}
pub fn create_user_router() -> Router<AppState> {
Router::new()
.route("/", post(users_post).get(users_get))
.route("/{id}", get(users_id_get))
}

View File

@@ -0,0 +1,3 @@
mod rejection;
pub use rejection::ValidRejection;

View File

@@ -0,0 +1,58 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use validator::ValidationErrors;
use crate::models::ValidationErrorResponse;
#[derive(Debug)]
pub enum ValidationRejection<V, E> {
Validator(V), // Validation errors
Extractor(E), // Extraction errors, e.g. axum's JsonRejection
}
impl<V: std::fmt::Display, E: std::fmt::Display> std::fmt::Display for ValidationRejection<V, E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationRejection::Validator(v) => write!(f, "{v}"),
ValidationRejection::Extractor(e) => write!(f, "{e}"),
}
}
}
impl<V: std::error::Error + 'static, E: std::error::Error + 'static> std::error::Error
for ValidationRejection<V, E>
{
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ValidationRejection::Validator(v) => Some(v),
ValidationRejection::Extractor(e) => Some(e),
}
}
}
impl<V: serde::Serialize + std::error::Error, E: IntoResponse> IntoResponse
for ValidationRejection<V, E>
{
fn into_response(self) -> Response {
match self {
ValidationRejection::Validator(v) => {
tracing::error!("Validation error: {v}");
(
StatusCode::UNPROCESSABLE_ENTITY,
axum::Json(ValidationErrorResponse::from(v)),
)
.into_response()
}
// logged by ApiError
ValidationRejection::Extractor(e) => e.into_response(),
}
}
}
pub type ValidRejection<E> = ValidationRejection<ValidationErrors, E>;
impl<E> From<ValidationErrors> for ValidRejection<E> {
fn from(v: ValidationErrors) -> Self {
Self::Validator(v)
}
}