first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+asyncpg://pastebin:pastebin@db:5432/pastebin"
|
||||
admin_password: str = "change-me"
|
||||
max_paste_length: int = 100_000
|
||||
|
||||
model_config = {"env_file": ".env"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,89 @@
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Header
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db, engine, Base
|
||||
from app.models import Paste
|
||||
from app.schemas import (
|
||||
PasteCreate,
|
||||
PasteResponse,
|
||||
PasteCreatedResponse,
|
||||
PasteListResponse,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Pastebin API", lifespan=lifespan)
|
||||
|
||||
|
||||
def verify_admin(password: str = Header(..., alias="X-Admin-Password")):
|
||||
if password != settings.admin_password:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin password")
|
||||
|
||||
|
||||
@app.post("/api/pastes", response_model=PasteCreatedResponse, status_code=201)
|
||||
async def create_paste(data: PasteCreate, db: AsyncSession = Depends(get_db)):
|
||||
paste = Paste(content=data.content)
|
||||
db.add(paste)
|
||||
await db.commit()
|
||||
await db.refresh(paste)
|
||||
return PasteCreatedResponse(
|
||||
id=paste.id, delete_token=paste.delete_token, created_at=paste.created_at
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/pastes/{paste_id}", response_model=PasteResponse)
|
||||
async def get_paste(paste_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
paste = await db.get(Paste, paste_id)
|
||||
if not paste:
|
||||
raise HTTPException(status_code=404, detail="Paste not found")
|
||||
return PasteResponse(id=paste.id, content=paste.content, created_at=paste.created_at)
|
||||
|
||||
|
||||
@app.delete("/api/pastes/{paste_id}")
|
||||
async def delete_paste(
|
||||
paste_id: uuid.UUID,
|
||||
token: uuid.UUID = Query(..., alias="token"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
paste = await db.get(Paste, paste_id)
|
||||
if not paste:
|
||||
raise HTTPException(status_code=404, detail="Paste not found")
|
||||
if paste.delete_token != token:
|
||||
raise HTTPException(status_code=403, detail="Invalid delete token")
|
||||
await db.delete(paste)
|
||||
await db.commit()
|
||||
return {"detail": "Paste deleted"}
|
||||
|
||||
|
||||
@app.get("/api/admin/pastes", response_model=list[PasteListResponse])
|
||||
async def list_pastes(
|
||||
_=Depends(verify_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Paste).order_by(Paste.created_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@app.delete("/api/admin/pastes/{paste_id}")
|
||||
async def admin_delete_paste(
|
||||
paste_id: uuid.UUID,
|
||||
_=Depends(verify_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
paste = await db.get(Paste, paste_id)
|
||||
if not paste:
|
||||
raise HTTPException(status_code=404, detail="Paste not found")
|
||||
await db.delete(paste)
|
||||
await db.commit()
|
||||
return {"detail": "Paste deleted"}
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Text, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Paste(Base):
|
||||
__tablename__ = "pastes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
delete_token: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=False, default=uuid.uuid4
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class PasteCreate(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=settings.max_paste_length)
|
||||
|
||||
|
||||
class PasteResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PasteCreatedResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
delete_token: uuid.UUID
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PasteListResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,9 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
asyncpg==0.30.0
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-dotenv==1.0.1
|
||||
alembic==1.14.0
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
|
||||
proxy:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pastebin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "pastebin-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Outlet, Link } from "react-router-dom";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif", maxWidth: 800, margin: "0 auto", padding: "1rem" }}>
|
||||
<nav style={{ marginBottom: "2rem", borderBottom: "1px solid #ddd", paddingBottom: "1rem" }}>
|
||||
<Link to="/" style={{ marginRight: "1rem", textDecoration: "none", color: "#0066cc" }}>
|
||||
Neuer Paste
|
||||
</Link>
|
||||
<Link to="/admin" style={{ textDecoration: "none", color: "#0066cc" }}>
|
||||
Admin
|
||||
</Link>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
const API_BASE = "/api";
|
||||
|
||||
export interface PasteCreated {
|
||||
id: string;
|
||||
delete_token: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Paste {
|
||||
id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PasteListItem {
|
||||
id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function createPaste(content: string): Promise<PasteCreated> {
|
||||
const res = await fetch(`${API_BASE}/pastes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create paste");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getPaste(id: string): Promise<Paste> {
|
||||
const res = await fetch(`${API_BASE}/pastes/${id}`);
|
||||
if (!res.ok) throw new Error("Paste not found");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deletePaste(id: string, token: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/pastes/${id}?token=${token}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete paste");
|
||||
}
|
||||
|
||||
export async function listPastes(adminPassword: string): Promise<PasteListItem[]> {
|
||||
const res = await fetch(`${API_BASE}/admin/pastes`, {
|
||||
headers: { "X-Admin-Password": adminPassword },
|
||||
});
|
||||
if (!res.ok) throw new Error("Unauthorized");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function adminDeletePaste(
|
||||
id: string,
|
||||
adminPassword: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/pastes/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Admin-Password": adminPassword },
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete paste");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import PastePage from "./pages/PastePage";
|
||||
import AdminPage from "./pages/AdminPage";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<App />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="paste/:id" element={<PastePage />} />
|
||||
<Route path="admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { listPastes, adminDeletePaste, type PasteListItem } from "../api/client";
|
||||
|
||||
export default function AdminPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [pastes, setPastes] = useState<PasteListItem[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listPastes(password);
|
||||
setPastes(data);
|
||||
setAuthenticated(true);
|
||||
} catch {
|
||||
setError("Falsches Passwort.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Paste wirklich löschen?")) return;
|
||||
try {
|
||||
await adminDeletePaste(id, password);
|
||||
setPastes((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch {
|
||||
setError("Löschen fehlgeschlagen.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Admin-Bereich</h1>
|
||||
<form onSubmit={handleLogin} style={{ maxWidth: 400 }}>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.25rem" }}>Admin-Passwort:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{ width: "100%", padding: "0.4rem", boxSizing: "border-box" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
style={{
|
||||
padding: "0.5rem 1.5rem",
|
||||
backgroundColor: "#0066cc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{loading ? "Prüfe..." : "Anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Alle Pastes ({pastes.length})</h1>
|
||||
{pastes.length === 0 ? (
|
||||
<p>Keine Pastes vorhanden.</p>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "2px solid #ddd" }}>
|
||||
<th style={{ textAlign: "left", padding: "0.5rem" }}>ID</th>
|
||||
<th style={{ textAlign: "left", padding: "0.5rem" }}>Erstellt am</th>
|
||||
<th style={{ textAlign: "left", padding: "0.5rem" }}>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pastes.map((p) => (
|
||||
<tr key={p.id} style={{ borderBottom: "1px solid #eee" }}>
|
||||
<td style={{ padding: "0.5rem" }}>
|
||||
<a href={`/paste/${p.id}`} style={{ fontFamily: "monospace", fontSize: 13 }}>
|
||||
{p.id}
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ padding: "0.5rem" }}>
|
||||
{new Date(p.created_at).toLocaleString("de-DE")}
|
||||
</td>
|
||||
<td style={{ padding: "0.5rem" }}>
|
||||
<button
|
||||
onClick={() => handleDelete(p.id)}
|
||||
style={{
|
||||
padding: "0.25rem 0.75rem",
|
||||
backgroundColor: "#dc3545",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPaste } from "../api/client";
|
||||
|
||||
const MAX_LENGTH = 100_000;
|
||||
|
||||
export default function HomePage() {
|
||||
const [content, setContent] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!content.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await createPaste(content);
|
||||
navigate(`/paste/${result.id}?token=${result.delete_token}`);
|
||||
} catch {
|
||||
setError("Fehler beim Erstellen des Pastes.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Neuen Paste erstellen</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
maxLength={MAX_LENGTH}
|
||||
placeholder="Dein Text hier..."
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 300,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 14,
|
||||
padding: "0.5rem",
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: 4,
|
||||
resize: "vertical",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: "0.5rem", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ color: "#888", fontSize: 14 }}>
|
||||
{content.length.toLocaleString()} / {MAX_LENGTH.toLocaleString()} Zeichen
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !content.trim()}
|
||||
style={{
|
||||
padding: "0.5rem 1.5rem",
|
||||
backgroundColor: "#0066cc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: loading ? "default" : "pointer",
|
||||
opacity: loading || !content.trim() ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? "Erstelle..." : "Paste erstellen"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { getPaste, deletePaste, type Paste } from "../api/client";
|
||||
|
||||
export default function PastePage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const deleteToken = searchParams.get("token");
|
||||
|
||||
const [paste, setPaste] = useState<Paste | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [deleted, setDeleted] = useState(false);
|
||||
const [copied, setCopied] = useState<"view" | "delete" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
getPaste(id)
|
||||
.then(setPaste)
|
||||
.catch(() => setError("Paste nicht gefunden."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id || !deleteToken) return;
|
||||
if (!confirm("Paste wirklich löschen?")) return;
|
||||
try {
|
||||
await deletePaste(id, deleteToken);
|
||||
setDeleted(true);
|
||||
} catch {
|
||||
setError("Löschen fehlgeschlagen.");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, type: "view" | "delete") => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(type);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
if (loading) return <p>Lade...</p>;
|
||||
if (deleted) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Paste gelöscht</h1>
|
||||
<p>Der Paste wurde erfolgreich gelöscht.</p>
|
||||
<button onClick={() => navigate("/")}>Neuen Paste erstellen</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) return <p style={{ color: "red" }}>{error}</p>;
|
||||
if (!paste) return null;
|
||||
|
||||
const viewUrl = `${window.location.origin}/paste/${paste.id}`;
|
||||
const deleteUrl = deleteToken
|
||||
? `${window.location.origin}/paste/${paste.id}?token=${deleteToken}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Paste anzeigen</h1>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.25rem", fontWeight: "bold" }}>
|
||||
Link zum Teilen:
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<input
|
||||
readOnly
|
||||
value={viewUrl}
|
||||
style={{ flex: 1, padding: "0.4rem", fontFamily: "monospace", fontSize: 13 }}
|
||||
/>
|
||||
<button onClick={() => copyToClipboard(viewUrl, "view")}>
|
||||
{copied === "view" ? "Kopiert!" : "Kopieren"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteUrl && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "1rem",
|
||||
padding: "1rem",
|
||||
backgroundColor: "#fff3cd",
|
||||
border: "1px solid #ffc107",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<strong>⚠️ Löschen-Link (wird nur einmal angezeigt!):</strong>
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
<input
|
||||
readOnly
|
||||
value={deleteUrl}
|
||||
style={{ flex: 1, padding: "0.4rem", fontFamily: "monospace", fontSize: 13 }}
|
||||
/>
|
||||
<button onClick={() => copyToClipboard(deleteUrl, "delete")}>
|
||||
{copied === "delete" ? "Kopiert!" : "Kopieren"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
padding: "0.4rem 1rem",
|
||||
backgroundColor: "#dc3545",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Paste löschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: "0.5rem", color: "#888", fontSize: 14 }}>
|
||||
Erstellt am: {new Date(paste.created_at).toLocaleString("de-DE")}
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
padding: "1rem",
|
||||
backgroundColor: "#f5f5f5",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 4,
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{paste.content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 100k;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
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_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user