add base files

This commit is contained in:
2025-09-28 11:06:59 +02:00
parent a0f05e8eb5
commit 12a02b07b6
14 changed files with 1128 additions and 0 deletions

88
app/main.py Executable file
View File

@@ -0,0 +1,88 @@
import os
import re
import smtplib
from email.mime.text import MIMEText
from xml.etree.ElementTree import Element, SubElement, tostring
from fastapi import FastAPI, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory="app/templates")
# Serve static files (CSS, images)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# add somewhere near the top-level constants
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# SMTP / email configuration via environment
SMTP_HOST = os.getenv("SMTP_HOST", "smtp")
SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
MAIL_FROM = os.getenv("MAIL_FROM", "noreply@example.com")
MAIL_TO = os.getenv("MAIL_TO", "destination@example.com")
@app.get("/", response_class=HTMLResponse)
async def show_form(request: Request):
return templates.TemplateResponse("form.html", {"request": request})
@app.post("/submit")
async def handle_form(
request: Request,
name: str = Form(...),
lastname: str = Form(...),
title: str = Form(...),
telno: str = Form(...),
mail: str = Form(...),
apparatsname: str = Form(...),
subject: str = Form(...),
semester_type: str = Form(...), # "summer" or "winter"
semester_year: str = Form(...),
authorname: list[str] = Form(...),
year: list[str] = Form(...),
booktitle: list[str] = Form(...),
signature: list[str] = Form(...),
):
# Build XML
root = Element("form_submission")
static_data = SubElement(root, "static")
SubElement(static_data, "name").text = name
SubElement(static_data, "lastname").text = lastname
SubElement(static_data, "title").text = title
SubElement(static_data, "telno").text = telno
SubElement(static_data, "mail").text = mail
SubElement(static_data, "apparatsname").text = apparatsname
SubElement(static_data, "subject").text = subject
SubElement(static_data, "semester").text = f"{semester_type} {semester_year}"
# inside handle_form(), right after parameters are received, before building XML:
# Basic email validation (server-side)
if not EMAIL_REGEX.match(mail):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid email address format.",
)
books = SubElement(root, "books")
for i in range(len(authorname)):
book = SubElement(books, "book")
SubElement(book, "authorname").text = authorname[i]
SubElement(book, "year").text = year[i]
SubElement(book, "title").text = booktitle[i]
SubElement(book, "signature").text = signature[i]
xml_data = tostring(root, encoding="unicode")
# Send mail
msg = MIMEText(xml_data, "xml")
msg["Subject"] = "New Form Submission"
msg["From"] = MAIL_FROM
msg["To"] = MAIL_TO
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
server.send_message(msg)
return RedirectResponse("/", status_code=303)

291
app/static/styles.css Normal file
View File

@@ -0,0 +1,291 @@
:root {
--bg: #f7f8fb;
--card-bg: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--primary: #0b7bd6; /* Accessible blue */
--primary-600: #0a6ec0;
--primary-700: #095fa6;
--border: #e5e7eb;
--ring: rgba(11, 123, 214, 0.35);
--table-head-bg: #f3f4f6;
--input-bg: #ffffff;
--control-accent: var(--primary);
}
/* Dark theme variables */
[data-theme="dark"] {
--bg: #0b1220;
--card-bg: #121a2a;
--text: #e5e7eb;
--muted: #9aa4b2;
--primary: #5aa1ff;
--primary-600: #4b90ea;
--primary-700: #3c7ace;
--border: #243045;
--ring: rgba(90, 161, 255, 0.35);
--table-head-bg: #172136;
--input-bg: #0f1726;
--control-accent: #2a3448;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
color: var(--text);
background: var(--bg);
line-height: 1.5;
}
/* Header */
.site-header {
background: var(--card-bg);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.header-inner {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 0;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.logo { height: 48px; width: auto; }
.brand-title { font-weight: 700; letter-spacing: .2px; }
.brand-sub { color: var(--muted); font-size: .95rem; }
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
/* padding: 8px 10px; */
scale: 2;
border-radius: 9999px;
border: none;/*1px solid var(--border); */
background: var(--card-bg);
color: var(--text);
cursor: pointer;
}
.theme-toggle:hover { filter: brightness(1.05); }
.theme-toggle:focus-visible { outline: none; box-shadow: 0 0 0 4px var(--ring); }
/* Icon emphasis depending on theme */
[data-theme="light"] .theme-toggle .sun { opacity: 1; }
[data-theme="light"] .theme-toggle .moon { opacity: 0.5; }
[data-theme="dark"] .theme-toggle .sun { opacity: 0.5; }
[data-theme="dark"] .theme-toggle .moon { opacity: 1; }
/* Layout */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.card {
background: var(--card-bg);
margin: 24px 0;
padding: 22px;
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 8px 30px rgba(0,0,0,.05);
}
h1 {
margin: 0 0 14px;
font-size: 1.5rem;
}
h2 {
margin: 20px 0 10px;
font-size: 1.25rem;
}
/* Tables */
.table-wrapper { overflow-x: auto; }
.form-table,
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: var(--card-bg);
/* Ensure full rounded corners and visible outer border */
border: none;
border-radius: 12px;
overflow: hidden;
position: relative;
}
/* Draw the outer border inside the rounded area to avoid clipping */
.form-table::after,
.data-table::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--border);
border-radius: 12px;
pointer-events: none;
}
.form-table td,
.form-table th {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.data-table td,
.data-table th {
padding: 10px 12px;
border-bottom: 0;
}
.form-table tr:last-child td,
.data-table tr:last-child td { border-bottom: 0; }
.data-table th {
background: var(--table-head-bg);
text-align: left;
font-weight: 600;
}
/* Inputs */
input[type="text"],
input[type="email"],
input[type="number"],
select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--input-bg);
color: var(--text);
outline: none;
transition: border-color .2s ease, box-shadow .2s ease;
}
input:focus,
select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px var(--ring);
}
input[type="radio"] { accent-color: var(--control-accent); }
.radio { margin-right: 12px; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
transition: background-color .2s ease, color .2s ease, border-color .2s ease, transform .02s ease;
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover { background: var(--primary-600); }
.btn-primary:focus { box-shadow: 0 0 0 4px var(--ring); }
.btn-secondary {
background: #e7eef8;
color: var(--primary-700);
border-color: #cfd9ea;
}
.btn-secondary:hover { background: #dfe8f6; }
[data-theme="dark"] .btn-secondary {
background: #1c2637;
color: var(--text);
border-color: #2a3852;
}
[data-theme="dark"] .btn-secondary:hover { background: #1f2b3f; }
.actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.footer-note {
margin-top: 18px;
color: var(--muted);
}
.footer-note a { color: var(--primary-700); text-decoration: none; }
.footer-note a:hover { text-decoration: underline; }
/* Responsive */
@media (max-width: 720px) {
.header-inner { padding: 12px 0; }
.logo { height: 40px; }
.form-table td:first-child { width: 40%; }
}
/* Static Information grid */
.grid-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 16px; /* row x column gap */
margin-bottom: 16px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-field label {
font-size: 0.9rem;
color: var(--muted);
}
/* Inline info message under Apparatsname */
.field-info {
margin-top: 4px;
font-size: 0.85rem;
color: var(--primary-700);
}
[data-theme="dark"] .field-info { color: #a9c8ff; }
.hidden { display: none !important; }
.inline-controls {
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
text-align: center;
}
.inline-controls input[type="number"] {
width: 120px;
text-align: center;
}
/* Responsive tweaks for the grid */
@media (max-width: 1024px) {
.grid-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.grid-form { grid-template-columns: 1fr; }
.inline-controls { flex-wrap: wrap; }
}
/* Darken secondary button and add spacing after tables */
.data-table + .btn { margin-top: 14px; }

197
app/templates/form.html Executable file
View File

@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html>
<head>
<title>Semesterapparatsantrag</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/styles.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
<!-- removed legacy inline styles that forced black borders on tables/td/th -->
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<div class="header-left">
<img class="logo" src="https://www.ph-freiburg.de/_assets/cc3dd7db45300ecc1f3aeed85bda5532/Images/logo-big-blue.svg" alt="PH Freiburg Logo" />
<div class="brand">
<div class="brand-title">Bibliothek der Pädagogischen Hochschule Freiburg</div>
<div class="brand-sub">Hochschulbibliothek · Semesterapparate</div>
</div>
</div>
<button type="button" id="theme-toggle" class="theme-toggle" aria-label="Switch to dark mode" title="Switch to dark mode">
<span class="mdi mdi-theme-light-dark" aria-hidden="true"></span>
</button>
</div>
</header>
<main class="container">
<section class="card">
<h1>Static Information</h1>
<form method="post" action="/submit" class="request-form">
<!-- Replace table with two-row grid layout -->
<div class="grid-form">
<div class="form-field">
<label for="name">Name</label>
<input type="text" name="name" id="name" required>
</div>
<div class="form-field">
<label for="lastname">Nachname</label>
<input type="text" name="lastname" id="lastname" required>
</div>
<div class="form-field">
<label for="title">Titel</label>
<input type="text" name="title" id="title" required>
</div>
<div class="form-field">
<label for="telno">Telefonnummer</label>
<input type="number" name="telno" id="telno" required>
</div>
<div class="form-field">
<label for="mail">Email</label>
<input type="email" name="mail" id="mail" required>
</div>
<div class="form-field">
<label for="apparatsname">Apparatsname</label>
<input type="text" name="apparatsname" id="apparatsname" maxlength="40" required>
<div id="apparatsname-warning" class="field-info hidden" role="status" aria-live="polite">Name will be changed to keep in line with requirements</div>
</div>
<div class="form-field">
<label for="subject">Fach</label>
<select name="subject" id="subject" required>
<option value="">-- Auswählen --</option>
<option>Biologie</option>
<option>Chemie</option>
<option>Deutsch</option>
<option>Englisch</option>
<option>Erziehungswirtschaft</option>
<option>Französisch</option>
<option>Geographie</option>
<option>Geschichte</option>
<option>Gesundheitspädagogik</option>
<option>Haushalt / Textil</option>
<option>Kunst</option>
<option>Mathematik / Informatik</option>
<option>Medien in der Bildung</option>
<option>Musik</option>
<option>Philosophie</option>
<option>Physik</option>
<option>Politikwissenschaft</option>
<option>Prorektorat Lehre und Studium</option>
<option>Psychologie</option>
<option>Soziologie</option>
<option>Sport</option>
<option>Technik</option>
<option>Theologie</option>
<option>Wirtschaftslehre</option>
</select>
</div>
<div class="form-field">
<label>Semester</label>
<div class="inline-controls">
<label class="radio"><input type="radio" name="semester_type" value="summer" required>Sommer</label>
<label class="radio"><input type="radio" name="semester_type" value="winter" required>Winter</label>
<input type="number" name="semester_year" placeholder="Jahr" required>
</div>
</div>
</div>
<h2>Medien</h2>
<table id="book-table" class="data-table">
<tr>
<th>Autorenname</th><th>Jahr/Auflage</th><th>Titel</th><th>Signatur</th>
</tr>
<tr>
<td><input type="text" name="authorname" required></td>
<td><input type="text" name="year" required></td>
<td><input type="text" name="booktitle" required></td>
<td><input type="text" name="signature" required></td>
</tr>
</table>
<button type="button" class="btn btn-secondary" onclick="addRow()">+ Medium hinzufügen</button>
<div class="actions">
<button type="submit" class="btn btn-primary">Absenden</button>
</div>
</form>
<p class="footer-note">
Hinweis: Weitere Informationen zu den Semesterapparaten finden Sie auf den Seiten der Hochschulbibliothek der PH Freiburg.
<br>
<a href="https://www.ph-freiburg.de/bibliothek.html" target="_blank" rel="noopener noreferrer">Zur Bibliothek</a>
<!-- add a spacer -->
<br>
<a href="https://www.ph-freiburg.de/bibliothek/lernen/semesterapparate.html" target="_blank" rel="noopener noreferrer">Zu den Semesterapparaten</a>
</p>
</section>
</main>
<script>
// Provide global addRow used by the "+ Medium hinzufügen" button
function addRow() {
const table = document.getElementById("book-table");
const row = table.insertRow(-1);
row.innerHTML = `
<td><input type="text" name="authorname" required></td>
<td><input type="text" name="year" required></td>
<td><input type="text" name="booktitle" required></td>
<td><input type="text" name="signature" required></td>
`;
}
(function() {
const STORAGE_KEY = 'theme';
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const saved = localStorage.getItem(STORAGE_KEY);
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
const btn = document.getElementById('theme-toggle');
if (btn) {
const label = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
btn.setAttribute('aria-label', label);
btn.title = label;
}
}
setTheme(saved || (prefersDark ? 'dark' : 'light'));
const btn = document.getElementById('theme-toggle');
if (btn) {
btn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
setTheme(current === 'dark' ? 'light' : 'dark');
});
}
})();
// Apparatsname dynamic warning: show when length exceeds (37 - len(lastname))
(function() {
function updateWarning() {
const last = document.getElementById('lastname');
const app = document.getElementById('apparatsname');
const warn = document.getElementById('apparatsname-warning');
if (!last || !app || !warn) return;
const lastLen = (last.value || '').length;
const appLen = (app.value || '').length;
const threshold = Math.max(0, 37 - lastLen);
if (appLen > threshold) {
warn.classList.remove('hidden');
} else {
warn.classList.add('hidden');
}
}
document.addEventListener('input', (e) => {
if (e.target && (e.target.id === 'lastname' || e.target.id === 'apparatsname')) {
updateWarning();
}
});
document.addEventListener('DOMContentLoaded', updateWarning);
// run once on load
updateWarning();
})();
</script>
</body>
</html>