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

@@ -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()}
</span>
<div className="entry-actions">
<button onClick={() => handleCopy(entry.id)}>Copy</button>
<button onClick={() => handleDelete(entry.id)}>Delete</button>
</div>
</div>
<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>
))}
</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();
}