init
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
frontend/dist
|
||||||
|
creator.db
|
||||||
|
.git
|
||||||
|
.env
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
creator.db
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Stage 1: Frontend bauen
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN useradd -m -u 1000 app
|
||||||
|
|
||||||
|
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||||
|
|
||||||
|
COPY --chown=app:app backend/ /app/backend/
|
||||||
|
COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist
|
||||||
|
|
||||||
|
RUN chown app:app /app
|
||||||
|
|
||||||
|
USER app
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
26
Makefile
Normal file
26
Makefile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.PHONY: install dev prod stop logs
|
||||||
|
|
||||||
|
COMPOSE = docker compose
|
||||||
|
|
||||||
|
install:
|
||||||
|
pip install --break-system-packages -r backend/requirements.txt
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@echo "Backend: http://localhost:8000"
|
||||||
|
@echo "Frontend: http://localhost:5173"
|
||||||
|
@cd backend && uvicorn main:app --reload --port 8000 &
|
||||||
|
@cd frontend && npx vite --port 5173
|
||||||
|
|
||||||
|
prod:
|
||||||
|
@touch creator.db
|
||||||
|
$(COMPOSE) up -d --build
|
||||||
|
|
||||||
|
stop:
|
||||||
|
-@pkill -f "uvicorn main:app" 2>/dev/null
|
||||||
|
-@pkill -f "vite --port 5173" 2>/dev/null
|
||||||
|
$(COMPOSE) down
|
||||||
|
@echo "Server gestoppt."
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(COMPOSE) logs -f
|
||||||
5
backend/config.py
Normal file
5
backend/config.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
|
||||||
|
DB_PATH = PROJECT_ROOT / "creator.db"
|
||||||
30
backend/database.py
Normal file
30
backend/database.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from config import DB_PATH
|
||||||
|
|
||||||
|
_db: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> aiosqlite.Connection:
|
||||||
|
global _db
|
||||||
|
if _db is None:
|
||||||
|
_db = await aiosqlite.connect(DB_PATH)
|
||||||
|
return _db
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
db = await get_db()
|
||||||
|
# Tabellen folgen, sobald die ersten Features stehen.
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db():
|
||||||
|
global _db
|
||||||
|
if _db is not None:
|
||||||
|
await _db.close()
|
||||||
|
_db = None
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row, cursor):
|
||||||
|
columns = [d[0] for d in cursor.description]
|
||||||
|
return dict(zip(columns, row))
|
||||||
23
backend/main.py
Normal file
23
backend/main.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from config import FRONTEND_DIST
|
||||||
|
from database import init_db, close_db
|
||||||
|
from routes import router
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Creator", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
if FRONTEND_DIST.exists():
|
||||||
|
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
||||||
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
aiosqlite
|
||||||
8
backend/routes.py
Normal file
8
backend/routes.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"ok": True}
|
||||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
creator:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
container_name: creator
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
volumes:
|
||||||
|
- ./creator.db:/app/creator.db
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.creatorapp.rule=Host(`creator.marha.de`)"
|
||||||
|
- "traefik.http.routers.creatorapp.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.creatorapp.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.creatorapp.loadbalancer.server.port=8000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
external: true
|
||||||
22
frontend/index.html
Normal file
22
frontend/index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Creator</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var stored = localStorage.getItem('darkMode')
|
||||||
|
var dark = stored === null
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
: stored === 'true'
|
||||||
|
if (dark) document.documentElement.classList.add('dark')
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
frontend/jsconfig.json
Normal file
8
frontend/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
2305
frontend/package-lock.json
generated
Normal file
2305
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.32"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"vite": "^8.0.8",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
127
frontend/src/App.vue
Normal file
127
frontend/src/App.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { fetchHealth } from './api.js'
|
||||||
|
|
||||||
|
const backendOk = ref(false)
|
||||||
|
const darkMode = ref(
|
||||||
|
localStorage.getItem('darkMode') === null
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
: localStorage.getItem('darkMode') === 'true',
|
||||||
|
)
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
document.documentElement.classList.toggle('dark', darkMode.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDark() {
|
||||||
|
darkMode.value = !darkMode.value
|
||||||
|
localStorage.setItem('darkMode', darkMode.value ? 'true' : 'false')
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchHealth()
|
||||||
|
backendOk.value = res.ok === true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Backend nicht erreichbar:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>Creator</h1>
|
||||||
|
<button class="theme-toggle" @click="toggleDark">{{ darkMode ? '☀' : '☾' }}</button>
|
||||||
|
</header>
|
||||||
|
<main class="main">
|
||||||
|
<p>Backend: <span :class="backendOk ? 'ok' : 'err'">{{ backendOk ? 'verbunden' : 'nicht erreichbar' }}</span></p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f8f9fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--border: #e2e5e9;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #4b5563;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--success: #065f46;
|
||||||
|
--danger: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
--bg: #15171c;
|
||||||
|
--panel: #1c1f26;
|
||||||
|
--border: #2c3038;
|
||||||
|
--text: #e6e8ee;
|
||||||
|
--text-muted: #9aa3b2;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--success: #34d399;
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.err {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
6
frontend/src/api.js
Normal file
6
frontend/src/api.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const BASE = '/api'
|
||||||
|
|
||||||
|
export async function fetchHealth() {
|
||||||
|
const res = await fetch(`${BASE}/health`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
4
frontend/src/main.js
Normal file
4
frontend/src/main.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
23
frontend/vite.config.js
Normal file
23
frontend/vite.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user