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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user