Rewrite app: entries → projects with S3 file storage
All checks were successful
ci/woodpecker/push/build Pipeline was successful
All checks were successful
ci/woodpecker/push/build Pipeline was successful
Replace flat text entries with project-based structure. Each project has name, local/corp fields, content textarea, and file upload (up to 100MB) stored in MinIO S3. New API: CRUD projects + file download + content copy. Frontend: two views (project list + project page). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
60
CLAUDE.md
60
CLAUDE.md
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
**Репозиторий:** https://git.mikhailkilin.ru/killingdruid/blood-brain-barrier
|
**Репозиторий:** https://git.mikhailkilin.ru/killingdruid/blood-brain-barrier
|
||||||
|
|
||||||
Веб-приложение для хранения текстовых записей. Пользователь вводит текст через textarea, сохраняет в PostgreSQL, просматривает список всех записей с возможностью удаления и копирования.
|
Веб-приложение для управления проектами с текстовыми полями и файлами. Главная страница — список проектов, клик по проекту — страница проекта с полями (local, corp), textarea (content) и загрузкой файлов в MinIO S3.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
- **Frontend:** React + TypeScript + Vite
|
- **Frontend:** React + TypeScript + Vite
|
||||||
- **Backend:** Rust (Axum + SQLx)
|
- **Backend:** Rust (Axum + SQLx + rust-s3)
|
||||||
- **БД:** PostgreSQL
|
- **БД:** PostgreSQL
|
||||||
|
- **Файлы:** MinIO S3
|
||||||
- **Async runtime:** Tokio
|
- **Async runtime:** Tokio
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
@@ -17,17 +18,19 @@
|
|||||||
blood-brain-barrier/
|
blood-brain-barrier/
|
||||||
├── frontend/ # React-приложение (Vite)
|
├── frontend/ # React-приложение (Vite)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── App.tsx # Основной компонент: textarea + список записей
|
│ │ ├── App.tsx # Две view: ProjectList + ProjectPage
|
||||||
│ │ ├── api.ts # Функции для запросов к API
|
│ │ ├── api.ts # Функции для запросов к API
|
||||||
|
│ │ ├── App.css # Стили
|
||||||
│ │ └── main.tsx
|
│ │ └── main.tsx
|
||||||
│ ├── Dockerfile # Сборка фронтенда + nginx
|
│ ├── Dockerfile # Сборка фронтенда + nginx
|
||||||
│ ├── nginx.conf # Конфиг nginx для раздачи SPA и проксирования API
|
│ ├── nginx.conf # Nginx: SPA + проксирование API (110MB limit)
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── backend/ # Rust API (Axum)
|
├── backend/ # Rust API (Axum)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── main.rs # Точка входа, подключение к БД, CORS, запуск сервера
|
│ │ ├── main.rs # Точка входа, AppState, роуты
|
||||||
│ │ ├── db.rs # Подключение к PostgreSQL, init_db
|
│ │ ├── db.rs # Подключение к PostgreSQL, init_db
|
||||||
│ │ └── routes.rs # Хендлеры API
|
│ │ ├── s3.rs # Инициализация MinIO S3 bucket
|
||||||
|
│ │ └── routes.rs # Хендлеры API (projects + файлы)
|
||||||
│ ├── Dockerfile # Сборка бэкенда
|
│ ├── Dockerfile # Сборка бэкенда
|
||||||
│ └── Cargo.toml
|
│ └── Cargo.toml
|
||||||
├── .woodpecker.yml # CI pipeline
|
├── .woodpecker.yml # CI pipeline
|
||||||
@@ -37,30 +40,36 @@ blood-brain-barrier/
|
|||||||
|
|
||||||
## База данных
|
## База данных
|
||||||
|
|
||||||
Одна таблица `entries`:
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE entries (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
name TEXT NOT NULL,
|
||||||
content TEXT NOT NULL
|
local TEXT NOT NULL DEFAULT '',
|
||||||
|
corp TEXT NOT NULL DEFAULT '',
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
file_name TEXT,
|
||||||
|
file_key TEXT,
|
||||||
|
file_size BIGINT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|--------|------------------|-------------------------|
|
|--------|----------------------------|---------------------------------------|
|
||||||
| GET | /api/entries | Список записей (id, created_at — без content) |
|
| GET | /api/projects | Список проектов (id, name, created_at)|
|
||||||
| POST | /api/entries | Создать запись |
|
| POST | /api/projects | Создать проект (JSON: {name}) |
|
||||||
| DELETE | /api/entries/:id | Удалить запись по id |
|
| GET | /api/projects/:id | Получить проект целиком |
|
||||||
| GET | /api/entries/:id/content | Получить контент записи (plain text) |
|
| PUT | /api/projects/:id | Обновить (multipart: local, corp, content, file?) |
|
||||||
|
| DELETE | /api/projects/:id | Удалить проект + файл из S3 |
|
||||||
|
| GET | /api/projects/:id/file | Скачать файл |
|
||||||
|
| GET | /api/projects/:id/content | Получить content (plain text) |
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- Сверху: большая `<textarea>` + кнопка "Create"
|
- **Главная:** список проектов + инпут для создания нового
|
||||||
- Ниже: список всех записей (новые сверху), у каждой кнопки "Delete" и "Copy"
|
- **Страница проекта:** поля Local/Corp, textarea Content, загрузка файла, кнопки Save/Copy text/Download file/Delete
|
||||||
- Копирование — через `navigator.clipboard.writeText()`
|
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
@@ -74,3 +83,12 @@ cd backend && cargo run
|
|||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Env vars (backend)
|
||||||
|
|
||||||
|
- `DATABASE_URL` — PostgreSQL connection string
|
||||||
|
- `S3_ENDPOINT` — MinIO endpoint (default: http://localhost:9000)
|
||||||
|
- `S3_ACCESS_KEY` — MinIO access key (default: minioadmin)
|
||||||
|
- `S3_SECRET_KEY` — MinIO secret key (default: minioadmin)
|
||||||
|
- `S3_BUCKET` — bucket name (default: bbb)
|
||||||
|
- `S3_REGION` — S3 region (default: us-east-1)
|
||||||
|
|||||||
956
backend/Cargo.lock
generated
956
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,12 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
rust-s3 = "0.35"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
pub async fn init_db(pool: &PgPool) {
|
pub async fn init_db(pool: &PgPool) {
|
||||||
|
sqlx::query("DROP TABLE IF EXISTS entries")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to drop entries table");
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS entries (
|
"CREATE TABLE IF NOT EXISTS projects (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
name TEXT NOT NULL,
|
||||||
content TEXT NOT NULL
|
local TEXT NOT NULL DEFAULT '',
|
||||||
|
corp TEXT NOT NULL DEFAULT '',
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
file_name TEXT,
|
||||||
|
file_key TEXT,
|
||||||
|
file_size BIGINT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create entries table");
|
.expect("Failed to create projects table");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
mod db;
|
mod db;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod s3;
|
||||||
|
|
||||||
use axum::{Router, routing::{get, delete}};
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use routes::AppState;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
@@ -18,12 +24,18 @@ async fn main() {
|
|||||||
|
|
||||||
db::init_db(&pool).await;
|
db::init_db(&pool).await;
|
||||||
|
|
||||||
|
let bucket = s3::init_bucket().await;
|
||||||
|
|
||||||
|
let state = AppState { pool, bucket };
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/entries", get(routes::get_entries).post(routes::create_entry))
|
.route("/api/projects", get(routes::list_projects).post(routes::create_project))
|
||||||
.route("/api/entries/{id}", delete(routes::delete_entry))
|
.route("/api/projects/{id}", get(routes::get_project).put(routes::update_project).delete(routes::delete_project))
|
||||||
.route("/api/entries/{id}/content", get(routes::get_entry_content))
|
.route("/api/projects/{id}/file", get(routes::get_project_file))
|
||||||
|
.route("/api/projects/{id}/content", get(routes::get_project_content))
|
||||||
|
.layer(DefaultBodyLimit::max(110 * 1024 * 1024))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.with_state(pool);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -1,75 +1,202 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, State},
|
body::Body,
|
||||||
http::StatusCode,
|
extract::{Multipart, Path, State},
|
||||||
|
http::{StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use s3::Bucket;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(Serialize, sqlx::FromRow)]
|
#[derive(Clone)]
|
||||||
pub struct Entry {
|
pub struct AppState {
|
||||||
pub id: i32,
|
pub pool: PgPool,
|
||||||
pub created_at: Option<DateTime<Utc>>,
|
pub bucket: Box<Bucket>,
|
||||||
pub content: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, sqlx::FromRow)]
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
pub struct EntryMeta {
|
pub struct Project {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub local: String,
|
||||||
|
pub corp: String,
|
||||||
|
pub content: String,
|
||||||
|
pub file_name: Option<String>,
|
||||||
|
pub file_key: Option<String>,
|
||||||
|
pub file_size: Option<i64>,
|
||||||
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ProjectMeta {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
pub created_at: Option<DateTime<Utc>>,
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateEntry {
|
pub struct CreateProject {
|
||||||
pub content: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_entries(State(pool): State<PgPool>) -> Result<Json<Vec<EntryMeta>>, StatusCode> {
|
pub async fn list_projects(
|
||||||
let entries = sqlx::query_as::<_, EntryMeta>("SELECT id, created_at FROM entries ORDER BY created_at DESC")
|
State(state): State<AppState>,
|
||||||
.fetch_all(&pool)
|
) -> Result<Json<Vec<ProjectMeta>>, StatusCode> {
|
||||||
.await
|
let projects = sqlx::query_as::<_, ProjectMeta>(
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
"SELECT id, name, created_at FROM projects ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
Ok(Json(entries))
|
Ok(Json(projects))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_entry_content(
|
pub async fn create_project(
|
||||||
State(pool): State<PgPool>,
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateProject>,
|
||||||
|
) -> Result<(StatusCode, Json<ProjectMeta>), StatusCode> {
|
||||||
|
let project = sqlx::query_as::<_, ProjectMeta>(
|
||||||
|
"INSERT INTO projects (name) VALUES ($1) RETURNING id, name, created_at",
|
||||||
|
)
|
||||||
|
.bind(&payload.name)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(project)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_project(
|
||||||
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<i32>,
|
||||||
) -> Result<String, StatusCode> {
|
) -> Result<Json<Project>, StatusCode> {
|
||||||
let row: (String,) = sqlx::query_as("SELECT content FROM entries WHERE id = $1")
|
let project = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
Ok(row.0)
|
Ok(Json(project))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_entry(
|
pub async fn update_project(
|
||||||
State(pool): State<PgPool>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreateEntry>,
|
Path(id): Path<i32>,
|
||||||
) -> Result<(StatusCode, Json<Entry>), StatusCode> {
|
mut multipart: Multipart,
|
||||||
let entry = sqlx::query_as::<_, Entry>(
|
) -> Result<StatusCode, StatusCode> {
|
||||||
"INSERT INTO entries (content) VALUES ($1) RETURNING id, created_at, content",
|
let mut local: Option<String> = None;
|
||||||
|
let mut corp: Option<String> = None;
|
||||||
|
let mut content: Option<String> = None;
|
||||||
|
let mut file_data: Option<(String, Vec<u8>)> = None;
|
||||||
|
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||||
|
{
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
match name.as_str() {
|
||||||
|
"local" => {
|
||||||
|
local = Some(field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?);
|
||||||
|
}
|
||||||
|
"corp" => {
|
||||||
|
corp = Some(field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?);
|
||||||
|
}
|
||||||
|
"content" => {
|
||||||
|
content = Some(field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?);
|
||||||
|
}
|
||||||
|
"file" => {
|
||||||
|
let file_name = field
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
if file_name.is_empty() || file_name == "unknown" {
|
||||||
|
// Skip empty file fields
|
||||||
|
let _ = field.bytes().await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let data = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
if !data.is_empty() {
|
||||||
|
file_data = Some((file_name, data.to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update text fields
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE projects SET local = COALESCE($1, local), corp = COALESCE($2, corp), content = COALESCE($3, content) WHERE id = $4",
|
||||||
)
|
)
|
||||||
.bind(&payload.content)
|
.bind(&local)
|
||||||
.fetch_one(&pool)
|
.bind(&corp)
|
||||||
|
.bind(&content)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(entry)))
|
// Handle file upload
|
||||||
|
if let Some((file_name, data)) = file_data {
|
||||||
|
// Delete old file if exists
|
||||||
|
let old_key: Option<(Option<String>,)> =
|
||||||
|
sqlx::query_as("SELECT file_key FROM projects WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if let Some((Some(old_key),)) = old_key {
|
||||||
|
let _ = state.bucket.delete_object(&old_key).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_key = format!("projects/{}/{}", id, uuid::Uuid::new_v4());
|
||||||
|
let file_size = data.len() as i64;
|
||||||
|
|
||||||
|
state
|
||||||
|
.bucket
|
||||||
|
.put_object(&file_key, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE projects SET file_name = $1, file_key = $2, file_size = $3 WHERE id = $4",
|
||||||
|
)
|
||||||
|
.bind(&file_name)
|
||||||
|
.bind(&file_key)
|
||||||
|
.bind(file_size)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_entry(
|
pub async fn delete_project(
|
||||||
State(pool): State<PgPool>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<i32>,
|
||||||
) -> StatusCode {
|
) -> StatusCode {
|
||||||
let result = sqlx::query("DELETE FROM entries WHERE id = $1")
|
// Delete file from S3 if exists
|
||||||
|
let row: Option<(Option<String>,)> =
|
||||||
|
sqlx::query_as("SELECT file_key FROM projects WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
if let Some((Some(file_key),)) = row {
|
||||||
|
let _ = state.bucket.delete_object(&file_key).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM projects WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -78,3 +205,50 @@ pub async fn delete_entry(
|
|||||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_project_file(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let project = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let file_key = project.file_key.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
let file_name = project.file_name.unwrap_or_else(|| "file".to_string());
|
||||||
|
|
||||||
|
let response_data = state
|
||||||
|
.bucket
|
||||||
|
.get_object(&file_key)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let body = Body::from(response_data.to_vec());
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
|
.header(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{}\"", file_name),
|
||||||
|
)
|
||||||
|
.body(body)
|
||||||
|
.unwrap()
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_project_content(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<String, StatusCode> {
|
||||||
|
let row: (String,) = sqlx::query_as("SELECT content FROM projects WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
Ok(row.0)
|
||||||
|
}
|
||||||
|
|||||||
35
backend/src/s3.rs
Normal file
35
backend/src/s3.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use s3::creds::Credentials;
|
||||||
|
use s3::{Bucket, Region};
|
||||||
|
|
||||||
|
pub async fn init_bucket() -> Box<Bucket> {
|
||||||
|
let endpoint = std::env::var("S3_ENDPOINT").unwrap_or_else(|_| "http://localhost:9000".into());
|
||||||
|
let access_key = std::env::var("S3_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".into());
|
||||||
|
let secret_key = std::env::var("S3_SECRET_KEY").unwrap_or_else(|_| "minioadmin".into());
|
||||||
|
let bucket_name = std::env::var("S3_BUCKET").unwrap_or_else(|_| "bbb".into());
|
||||||
|
let region_name = std::env::var("S3_REGION").unwrap_or_else(|_| "us-east-1".into());
|
||||||
|
|
||||||
|
let region = Region::Custom {
|
||||||
|
region: region_name,
|
||||||
|
endpoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
let credentials = Credentials::new(Some(&access_key), Some(&secret_key), None, None, None)
|
||||||
|
.expect("Failed to create S3 credentials");
|
||||||
|
|
||||||
|
// Try to create bucket (ignore error if already exists)
|
||||||
|
let create_result = Bucket::create_with_path_style(
|
||||||
|
&bucket_name,
|
||||||
|
region.clone(),
|
||||||
|
credentials.clone(),
|
||||||
|
s3::BucketConfiguration::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match create_result {
|
||||||
|
Ok(_) => println!("Created S3 bucket: {}", bucket_name),
|
||||||
|
Err(e) => println!("Bucket may already exist: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
Bucket::new(&bucket_name, region, credentials)
|
||||||
|
.expect("Failed to create S3 bucket handle")
|
||||||
|
.with_path_style()
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
client_max_body_size 110m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
@@ -13,5 +14,6 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,26 +9,25 @@ h1 {
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.create-section {
|
.create-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-section textarea {
|
.create-section input {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
padding: 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
resize: vertical;
|
|
||||||
font-family: inherit;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-section button {
|
.create-section button {
|
||||||
align-self: flex-end;
|
|
||||||
padding: 0.5rem 1.5rem;
|
padding: 0.5rem 1.5rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -42,51 +41,121 @@ h1 {
|
|||||||
background: #555;
|
background: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entries-list {
|
.projects-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry {
|
.project-item {
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-content {
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-meta {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-date {
|
.project-item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-date {
|
||||||
color: #888;
|
color: #888;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-actions {
|
.back-btn {
|
||||||
display: flex;
|
padding: 0.4rem 1rem;
|
||||||
gap: 0.5rem;
|
font-size: 0.9rem;
|
||||||
}
|
|
||||||
|
|
||||||
.entry-actions button {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: white;
|
background: white;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-actions button:hover {
|
.back-btn:hover {
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input[type="file"] {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.danger {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.danger:hover {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,77 +1,204 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { type EntryMeta, fetchEntries, createEntry, deleteEntry, getEntryContent } from "./api";
|
import {
|
||||||
|
type ProjectMeta,
|
||||||
|
type Project,
|
||||||
|
fetchProjects,
|
||||||
|
createProject,
|
||||||
|
getProject,
|
||||||
|
updateProject,
|
||||||
|
deleteProject,
|
||||||
|
getProjectContent,
|
||||||
|
} from "./api";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
function App() {
|
function ProjectList({
|
||||||
const [entries, setEntries] = useState<EntryMeta[]>([]);
|
onSelect,
|
||||||
const [content, setContent] = useState("");
|
}: {
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
}) {
|
||||||
|
const [projects, setProjects] = useState<ProjectMeta[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
const loadEntries = async () => {
|
const load = async () => {
|
||||||
const data = await fetchEntries();
|
setProjects(await fetchProjects());
|
||||||
setEntries(data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEntries();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!content.trim()) return;
|
if (!name.trim()) return;
|
||||||
await createEntry(content);
|
await createProject(name);
|
||||||
setContent("");
|
setName("");
|
||||||
await loadEntries();
|
await load();
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
await deleteEntry(id);
|
|
||||||
await loadEntries();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async (id: number) => {
|
|
||||||
const text = await getEntryContent(id);
|
|
||||||
if (navigator.clipboard) {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
} else {
|
|
||||||
const textarea = document.createElement("textarea");
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.style.position = "fixed";
|
|
||||||
textarea.style.opacity = "0";
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<>
|
||||||
<h1>Blood Brain Barrier</h1>
|
<h1>Blood Brain Barrier</h1>
|
||||||
|
|
||||||
<div className="create-section">
|
<div className="create-section">
|
||||||
<textarea
|
<input
|
||||||
value={content}
|
type="text"
|
||||||
onChange={(e) => setContent(e.target.value)}
|
value={name}
|
||||||
placeholder="Enter text..."
|
onChange={(e) => setName(e.target.value)}
|
||||||
rows={6}
|
placeholder="Project name"
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||||
/>
|
/>
|
||||||
<button onClick={handleCreate}>Create</button>
|
<button onClick={handleCreate}>Create</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="projects-list">
|
||||||
<div className="entries-list">
|
{projects.map((p) => (
|
||||||
{entries.map((entry) => (
|
<div key={p.id} className="project-item" onClick={() => onSelect(p.id)}>
|
||||||
<div key={entry.id} className="entry">
|
<span className="project-name">{p.name}</span>
|
||||||
<div className="entry-meta">
|
<span className="project-date">
|
||||||
<span className="entry-date">
|
{new Date(p.created_at).toLocaleString()}
|
||||||
{new Date(entry.created_at).toLocaleString()}
|
</span>
|
||||||
</span>
|
|
||||||
<div className="entry-actions">
|
|
||||||
<button onClick={() => handleCopy(entry.id)}>Copy</button>
|
|
||||||
<button onClick={() => handleDelete(entry.id)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectPage({
|
||||||
|
projectId,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [local, setLocal] = useState("");
|
||||||
|
const [corp, setCorp] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const p = await getProject(projectId);
|
||||||
|
setProject(p);
|
||||||
|
setLocal(p.local);
|
||||||
|
setCorp(p.corp);
|
||||||
|
setContent(p.content);
|
||||||
|
})();
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
await updateProject(projectId, {
|
||||||
|
local,
|
||||||
|
corp,
|
||||||
|
content,
|
||||||
|
file: file ?? undefined,
|
||||||
|
});
|
||||||
|
setFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
const p = await getProject(projectId);
|
||||||
|
setProject(p);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const text = await getProjectContent(projectId);
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
window.open(`/api/projects/${projectId}/file`, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
await deleteProject(projectId);
|
||||||
|
onBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!project) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="back-btn" onClick={onBack}>Back</button>
|
||||||
|
<h2>{project.name}</h2>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label>Local</label>
|
||||||
|
<input type="text" value={local} onChange={(e) => setLocal(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label>Corp</label>
|
||||||
|
<input type="text" value={corp} onChange={(e) => setCorp(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label>Content</label>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label>File (max 100MB)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{project.file_name && (
|
||||||
|
<span className="file-info">
|
||||||
|
Attached: {project.file_name} ({((project.file_size ?? 0) / 1024).toFixed(1)} KB)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleCopy}>Copy text</button>
|
||||||
|
{project.file_name && (
|
||||||
|
<button onClick={handleDownload}>Download file</button>
|
||||||
|
)}
|
||||||
|
<button className="danger" onClick={handleDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [view, setView] = useState<"list" | "project">("list");
|
||||||
|
const [projectId, setProjectId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{view === "list" ? (
|
||||||
|
<ProjectList
|
||||||
|
onSelect={(id) => {
|
||||||
|
setProjectId(id);
|
||||||
|
setView("project");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProjectPage
|
||||||
|
projectId={projectId!}
|
||||||
|
onBack={() => setView("list")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,72 @@
|
|||||||
const API_URL = "/api";
|
const API_URL = "/api";
|
||||||
|
|
||||||
export interface EntryMeta {
|
export interface ProjectMeta {
|
||||||
id: number;
|
id: number;
|
||||||
|
name: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEntries(): Promise<EntryMeta[]> {
|
export interface Project {
|
||||||
const res = await fetch(`${API_URL}/entries`);
|
id: number;
|
||||||
if (!res.ok) throw new Error("Failed to fetch entries");
|
name: string;
|
||||||
|
local: string;
|
||||||
|
corp: string;
|
||||||
|
content: string;
|
||||||
|
file_name: string | null;
|
||||||
|
file_key: string | null;
|
||||||
|
file_size: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjects(): Promise<ProjectMeta[]> {
|
||||||
|
const res = await fetch(`${API_URL}/projects`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch projects");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEntryContent(id: number): Promise<string> {
|
export async function createProject(name: string): Promise<ProjectMeta> {
|
||||||
const res = await fetch(`${API_URL}/entries/${id}/content`);
|
const res = await fetch(`${API_URL}/projects`, {
|
||||||
if (!res.ok) throw new Error("Failed to get entry content");
|
|
||||||
return res.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createEntry(content: string): Promise<EntryMeta> {
|
|
||||||
const res = await fetch(`${API_URL}/entries`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to create entry");
|
if (!res.ok) throw new Error("Failed to create project");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEntry(id: number): Promise<void> {
|
export async function getProject(id: number): Promise<Project> {
|
||||||
const res = await fetch(`${API_URL}/entries/${id}`, {
|
const res = await fetch(`${API_URL}/projects/${id}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to get project");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProject(
|
||||||
|
id: number,
|
||||||
|
data: { local: string; corp: string; content: string; file?: File }
|
||||||
|
): Promise<void> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("local", data.local);
|
||||||
|
form.append("corp", data.corp);
|
||||||
|
form.append("content", data.content);
|
||||||
|
if (data.file) {
|
||||||
|
form.append("file", data.file);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_URL}/projects/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update project");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProject(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`${API_URL}/projects/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to delete entry");
|
if (!res.ok) throw new Error("Failed to delete project");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectContent(id: number): Promise<string> {
|
||||||
|
const res = await fetch(`${API_URL}/projects/${id}/content`);
|
||||||
|
if (!res.ok) throw new Error("Failed to get project content");
|
||||||
|
return res.text();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user