Rewrite app: entries → projects with S3 file storage
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:
Mikhail Kilin
2026-03-18 13:50:28 +03:00
parent d23043a489
commit 570e0ca643
11 changed files with 1586 additions and 201 deletions

View File

@@ -2,13 +2,14 @@
**Репозиторий:** https://git.mikhailkilin.ru/killingdruid/blood-brain-barrier
Веб-приложение для хранения текстовых записей. Пользователь вводит текст через textarea, сохраняет в PostgreSQL, просматривает список всех записей с возможностью удаления и копирования.
Веб-приложение для управления проектами с текстовыми полями и файлами. Главная страница — список проектов, клик по проекту — страница проекта с полями (local, corp), textarea (content) и загрузкой файлов в MinIO S3.
## Стек
- **Frontend:** React + TypeScript + Vite
- **Backend:** Rust (Axum + SQLx)
- **Backend:** Rust (Axum + SQLx + rust-s3)
- **БД:** PostgreSQL
- **Файлы:** MinIO S3
- **Async runtime:** Tokio
## Структура проекта
@@ -17,17 +18,19 @@
blood-brain-barrier/
├── frontend/ # React-приложение (Vite)
│ ├── src/
│ │ ├── App.tsx # Основной компонент: textarea + список записей
│ │ ├── App.tsx # Две view: ProjectList + ProjectPage
│ │ ├── api.ts # Функции для запросов к API
│ │ ├── App.css # Стили
│ │ └── main.tsx
│ ├── Dockerfile # Сборка фронтенда + nginx
│ ├── nginx.conf # Конфиг nginx для раздачи SPA и проксирования API
│ ├── nginx.conf # Nginx: SPA + проксирование API (110MB limit)
│ └── package.json
├── backend/ # Rust API (Axum)
│ ├── src/
│ │ ├── main.rs # Точка входа, подключение к БД, CORS, запуск сервера
│ │ ├── main.rs # Точка входа, AppState, роуты
│ │ ├── db.rs # Подключение к PostgreSQL, init_db
│ │ ── routes.rs # Хендлеры API
│ │ ── s3.rs # Инициализация MinIO S3 bucket
│ │ └── routes.rs # Хендлеры API (projects + файлы)
│ ├── Dockerfile # Сборка бэкенда
│ └── Cargo.toml
├── .woodpecker.yml # CI pipeline
@@ -37,30 +40,36 @@ blood-brain-barrier/
## База данных
Одна таблица `entries`:
```sql
CREATE TABLE entries (
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
content TEXT NOT NULL
name 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
| Метод | Путь | Описание |
|--------|------------------|-------------------------|
| GET | /api/entries | Список записей (id, created_at — без content) |
| POST | /api/entries | Создать запись |
| DELETE | /api/entries/:id | Удалить запись по id |
| GET | /api/entries/:id/content | Получить контент записи (plain text) |
|--------|----------------------------|---------------------------------------|
| GET | /api/projects | Список проектов (id, name, created_at)|
| POST | /api/projects | Создать проект (JSON: {name}) |
| GET | /api/projects/:id | Получить проект целиком |
| 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
- Сверху: большая `<textarea>` + кнопка "Create"
- Ниже: список всех записей (новые сверху), у каждой кнопки "Delete" и "Copy"
- Копирование — через `navigator.clipboard.writeText()`
- **Главная:** список проектов + инпут для создания нового
- **Страница проекта:** поля Local/Corp, textarea Content, загрузка файла, кнопки Save/Copy text/Download file/Delete
## Команды
@@ -74,3 +83,12 @@ cd backend && cargo run
# PostgreSQL
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

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,12 @@ version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
axum = { version = "0.8", features = ["multipart"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }
chrono = { version = "0.4", features = ["serde"] }
rust-s3 = "0.35"
uuid = { version = "1", features = ["v4"] }

View File

@@ -1,14 +1,25 @@
use sqlx::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(
"CREATE TABLE IF NOT EXISTS entries (
"CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
content TEXT NOT NULL
name 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)
.await
.expect("Failed to create entries table");
.expect("Failed to create projects table");
}

View File

@@ -1,7 +1,13 @@
mod db;
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 tower_http::cors::CorsLayer;
@@ -18,12 +24,18 @@ async fn main() {
db::init_db(&pool).await;
let bucket = s3::init_bucket().await;
let state = AppState { pool, bucket };
let app = Router::new()
.route("/api/entries", get(routes::get_entries).post(routes::create_entry))
.route("/api/entries/{id}", delete(routes::delete_entry))
.route("/api/entries/{id}/content", get(routes::get_entry_content))
.route("/api/projects", get(routes::list_projects).post(routes::create_project))
.route("/api/projects/{id}", get(routes::get_project).put(routes::update_project).delete(routes::delete_project))
.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())
.with_state(pool);
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await

View File

@@ -1,75 +1,202 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
body::Body,
extract::{Multipart, Path, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use chrono::{DateTime, Utc};
use s3::Bucket;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
#[derive(Serialize, sqlx::FromRow)]
pub struct Entry {
pub id: i32,
pub created_at: Option<DateTime<Utc>>,
pub content: String,
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub bucket: Box<Bucket>,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct EntryMeta {
pub struct Project {
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>>,
}
#[derive(Deserialize)]
pub struct CreateEntry {
pub content: String,
pub struct CreateProject {
pub name: String,
}
pub async fn get_entries(State(pool): State<PgPool>) -> Result<Json<Vec<EntryMeta>>, StatusCode> {
let entries = sqlx::query_as::<_, EntryMeta>("SELECT id, created_at FROM entries ORDER BY created_at DESC")
.fetch_all(&pool)
pub async fn list_projects(
State(state): State<AppState>,
) -> Result<Json<Vec<ProjectMeta>>, StatusCode> {
let projects = sqlx::query_as::<_, ProjectMeta>(
"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(
State(pool): State<PgPool>,
pub async fn create_project(
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>,
) -> Result<String, StatusCode> {
let row: (String,) = sqlx::query_as("SELECT content FROM entries WHERE id = $1")
) -> Result<Json<Project>, StatusCode> {
let project = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1")
.bind(id)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(row.0)
Ok(Json(project))
}
pub async fn create_entry(
State(pool): State<PgPool>,
Json(payload): Json<CreateEntry>,
) -> Result<(StatusCode, Json<Entry>), StatusCode> {
let entry = sqlx::query_as::<_, Entry>(
"INSERT INTO entries (content) VALUES ($1) RETURNING id, created_at, content",
pub async fn update_project(
State(state): State<AppState>,
Path(id): Path<i32>,
mut multipart: Multipart,
) -> Result<StatusCode, StatusCode> {
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)
.fetch_one(&pool)
.bind(&local)
.bind(&corp)
.bind(&content)
.bind(id)
.execute(&state.pool)
.await
.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(
State(pool): State<PgPool>,
pub async fn delete_project(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> 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)
.execute(&pool)
.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)
.execute(&state.pool)
.await;
match result {
@@ -78,3 +205,50 @@ pub async fn delete_entry(
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
View 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()
}

View File

@@ -1,6 +1,7 @@
server {
listen 80;
server_name _;
client_max_body_size 110m;
location / {
root /usr/share/nginx/html;
@@ -13,5 +14,6 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
}

View File

@@ -9,26 +9,25 @@ h1 {
margin-bottom: 1.5rem;
}
h2 {
margin-bottom: 1rem;
}
.create-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2rem;
}
.create-section textarea {
width: 100%;
padding: 0.75rem;
.create-section input {
flex: 1;
padding: 0.5rem 0.75rem;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
resize: vertical;
font-family: inherit;
box-sizing: border-box;
}
.create-section button {
align-self: flex-end;
padding: 0.5rem 1.5rem;
font-size: 1rem;
cursor: pointer;
@@ -42,51 +41,121 @@ h1 {
background: #555;
}
.entries-list {
.projects-list {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.5rem;
}
.entry {
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 {
.project-item {
display: flex;
justify-content: space-between;
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;
font-size: 0.85rem;
}
.entry-actions {
display: flex;
gap: 0.5rem;
}
.entry-actions button {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
.back-btn {
padding: 0.4rem 1rem;
font-size: 0.9rem;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
margin-bottom: 1rem;
}
.entry-actions button:hover {
.back-btn:hover {
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;
}

View File

@@ -1,77 +1,204 @@
import { useEffect, useState } from "react";
import { type EntryMeta, fetchEntries, createEntry, deleteEntry, getEntryContent } from "./api";
import { useEffect, useRef, useState } from "react";
import {
type ProjectMeta,
type Project,
fetchProjects,
createProject,
getProject,
updateProject,
deleteProject,
getProjectContent,
} from "./api";
import "./App.css";
function App() {
const [entries, setEntries] = useState<EntryMeta[]>([]);
const [content, setContent] = useState("");
function ProjectList({
onSelect,
}: {
onSelect: (id: number) => void;
}) {
const [projects, setProjects] = useState<ProjectMeta[]>([]);
const [name, setName] = useState("");
const loadEntries = async () => {
const data = await fetchEntries();
setEntries(data);
const load = async () => {
setProjects(await fetchProjects());
};
useEffect(() => {
loadEntries();
load();
}, []);
const handleCreate = async () => {
if (!content.trim()) return;
await createEntry(content);
setContent("");
await loadEntries();
};
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);
}
if (!name.trim()) return;
await createProject(name);
setName("");
await load();
};
return (
<div className="app">
<>
<h1>Blood Brain Barrier</h1>
<div className="create-section">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Enter text..."
rows={6}
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
<button onClick={handleCreate}>Create</button>
</div>
<div className="entries-list">
{entries.map((entry) => (
<div key={entry.id} className="entry">
<div className="entry-meta">
<span className="entry-date">
{new Date(entry.created_at).toLocaleString()}
<div className="projects-list">
{projects.map((p) => (
<div key={p.id} className="project-item" onClick={() => onSelect(p.id)}>
<span className="project-name">{p.name}</span>
<span className="project-date">
{new Date(p.created_at).toLocaleString()}
</span>
<div className="entry-actions">
<button onClick={() => handleCopy(entry.id)}>Copy</button>
<button onClick={() => handleDelete(entry.id)}>Delete</button>
</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>
);
}

View File

@@ -1,35 +1,72 @@
const API_URL = "/api";
export interface EntryMeta {
export interface ProjectMeta {
id: number;
name: string;
created_at: string;
}
export async function fetchEntries(): Promise<EntryMeta[]> {
const res = await fetch(`${API_URL}/entries`);
if (!res.ok) throw new Error("Failed to fetch entries");
export interface Project {
id: number;
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();
}
export async function getEntryContent(id: number): Promise<string> {
const res = await fetch(`${API_URL}/entries/${id}/content`);
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`, {
export async function createProject(name: string): Promise<ProjectMeta> {
const res = await fetch(`${API_URL}/projects`, {
method: "POST",
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();
}
export async function deleteEntry(id: number): Promise<void> {
const res = await fetch(`${API_URL}/entries/${id}`, {
export async function getProject(id: number): Promise<Project> {
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",
});
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();
}