feat: rewrite frontend from Go templates to Bun + TSX
Replace Go HTML template rendering with a Bun + TSX build-time static site generator. Go backend becomes API-only for auth. Frontend: - Custom JSX-to-HTML-string factory (zero dependencies) - TSX components for Header, Footer, index page, login page - Client-side login.ts handles tab switching and fetch()-based auth - Bun bundler compiles client JS, build.ts renders pages to dist/ Backend: - Auth handlers return JSON (POST /api/login, POST /api/register) - Login page served as static HTML from dist/ - Static assets served from /assets/ (public) - Auth middleware unchanged (cookie check, redirect to /login) - Removed template parsing and renderPage Dockerfile: - 3-stage build: Bun frontend -> Go backend -> alpine runtime - Frontend dist copied to /app/dist in final image Removed: templates/, static/css/ (replaced by frontend/)
This commit is contained in:
@@ -4,3 +4,5 @@
|
|||||||
.m
|
.m
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,6 +3,10 @@
|
|||||||
/patholo
|
/patholo
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -1,5 +1,9 @@
|
|||||||
FROM golang:1.23-alpine AS build
|
FROM oven/bun:1 AS frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN bun install && bun run build
|
||||||
|
|
||||||
|
FROM golang:1.23-alpine AS backend
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod ./
|
COPY go.mod ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
@@ -9,9 +13,7 @@ RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /patholo ./cmd/server
|
|||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /patholo /app/patholo
|
COPY --from=backend /patholo /app/patholo
|
||||||
COPY templates/ /app/templates/
|
COPY --from=frontend /app/frontend/dist /app/dist
|
||||||
COPY static/ /app/static/
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["/app/patholo"]
|
CMD ["/app/patholo"]
|
||||||
|
|||||||
42
frontend/build.ts
Normal file
42
frontend/build.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { mkdir, cp, rm } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { renderIndex } from "./src/index";
|
||||||
|
import { renderLogin } from "./src/login";
|
||||||
|
|
||||||
|
const DIST = join(import.meta.dir, "dist");
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
// Clean dist/
|
||||||
|
await rm(DIST, { recursive: true, force: true });
|
||||||
|
await mkdir(join(DIST, "assets"), { recursive: true });
|
||||||
|
|
||||||
|
// Bundle client-side JS
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [join(import.meta.dir, "src/client/login.ts")],
|
||||||
|
outdir: join(DIST, "assets"),
|
||||||
|
naming: "[name].js",
|
||||||
|
minify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("JS build failed:");
|
||||||
|
for (const log of result.logs) {
|
||||||
|
console.error(log);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy CSS
|
||||||
|
await cp(
|
||||||
|
join(import.meta.dir, "src/styles/global.css"),
|
||||||
|
join(DIST, "assets/global.css"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render HTML pages
|
||||||
|
await Bun.write(join(DIST, "index.html"), renderIndex());
|
||||||
|
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
||||||
|
|
||||||
|
console.log("Build complete \u2192 dist/");
|
||||||
|
}
|
||||||
|
|
||||||
|
build();
|
||||||
21
frontend/bun.lock
Normal file
21
frontend/bun.lock
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "patholo-frontend",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
2
frontend/bunfig.toml
Normal file
2
frontend/bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[install]
|
||||||
|
peer = false
|
||||||
10
frontend/package.json
Normal file
10
frontend/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "patholo-frontend",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun run build.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
108
frontend/src/client/login.ts
Normal file
108
frontend/src/client/login.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const tabs = document.querySelectorAll<HTMLButtonElement>(".login-tab");
|
||||||
|
const loginForm = document.getElementById("login-form") as HTMLFormElement;
|
||||||
|
const registerForm = document.getElementById("register-form") as HTMLFormElement;
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
tabs.forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
tabs.forEach((t) => t.classList.remove("active"));
|
||||||
|
btn.classList.add("active");
|
||||||
|
const isLogin = btn.dataset.tab === "login";
|
||||||
|
loginForm.style.display = isLogin ? "" : "none";
|
||||||
|
registerForm.style.display = isLogin ? "none" : "";
|
||||||
|
clearMessages();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login form
|
||||||
|
loginForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearMessages();
|
||||||
|
const data = new FormData(loginForm);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: data.get("email"),
|
||||||
|
password: data.get("password"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = "/";
|
||||||
|
} else {
|
||||||
|
showError(json.error);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showError("Verbindungsfehler. Bitte versuchen Sie es erneut.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register form
|
||||||
|
registerForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearMessages();
|
||||||
|
const data = new FormData(registerForm);
|
||||||
|
const password = data.get("password") as string;
|
||||||
|
const confirm = data.get("confirm") as string;
|
||||||
|
|
||||||
|
if (password !== confirm) {
|
||||||
|
showError("Passw\u00F6rter stimmen nicht \u00FCberein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
showError("Passwort muss mindestens 8 Zeichen lang sein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: data.get("email"),
|
||||||
|
password: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
if (json.redirect) {
|
||||||
|
window.location.href = json.redirect;
|
||||||
|
} else {
|
||||||
|
showSuccess(json.message || "Account erstellt. Bitte melden Sie sich an.");
|
||||||
|
tabs[0].click();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(json.error);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showError("Verbindungsfehler. Bitte versuchen Sie es erneut.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearMessages() {
|
||||||
|
document.querySelectorAll(".login-error, .login-success").forEach((el) => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg: string) {
|
||||||
|
showMessage(msg, "login-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(msg: string) {
|
||||||
|
showMessage(msg, "login-success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(msg: string, cls: string) {
|
||||||
|
clearMessages();
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = cls;
|
||||||
|
div.textContent = msg;
|
||||||
|
const tabs = document.querySelector(".login-tabs");
|
||||||
|
if (tabs) {
|
||||||
|
tabs.after(div);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/components/Footer.tsx
Normal file
11
frontend/src/components/Footer.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { h } from "../jsx";
|
||||||
|
|
||||||
|
export function Footer(): string {
|
||||||
|
return (
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="container">
|
||||||
|
<p>{"\u00A9 2026 patholo \u2014 Internal use only. Hogan Lovells Patent Practice."}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/Header.tsx
Normal file
30
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { h } from "../jsx";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
showLogout?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ showLogout }: HeaderProps): string {
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="nav">
|
||||||
|
<a href="/" className="logo">
|
||||||
|
<span className="logo-mark">p</span>
|
||||||
|
<span className="logo-text">patholo</span>
|
||||||
|
</a>
|
||||||
|
{showLogout && (
|
||||||
|
<div className="nav-right">
|
||||||
|
<a href="/logout" className="nav-logout">Abmelden</a>
|
||||||
|
<div className="nav-lang">
|
||||||
|
<span className="lang-active">DE</span>
|
||||||
|
<span className="lang-sep">/</span>
|
||||||
|
<span className="lang-inactive">EN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/index.tsx
Normal file
74
frontend/src/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { h } from "./jsx";
|
||||||
|
import { Header } from "./components/Header";
|
||||||
|
import { Footer } from "./components/Footer";
|
||||||
|
|
||||||
|
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M8 7h6"/><path d="M8 11h4"/></svg>';
|
||||||
|
const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
|
||||||
|
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||||
|
|
||||||
|
export function renderIndex(): string {
|
||||||
|
return "<!DOCTYPE html>" + (
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>patholo — Patent Knowledge for Hogan Lovells</title>
|
||||||
|
<link rel="stylesheet" href="/assets/global.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Header showLogout={true} />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className="hero">
|
||||||
|
<div className="container">
|
||||||
|
<h1>Patent Knowledge<br /><span className="hero-accent">für Hogan Lovells</span></h1>
|
||||||
|
<p className="hero-sub">
|
||||||
|
Leitfäden, Vorlagen und Dokumente für das HL Patent-Team.
|
||||||
|
<br />
|
||||||
|
<span className="hero-en">Guides, templates, and documents for the HL patent team.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sections">
|
||||||
|
<div className="container">
|
||||||
|
<div className="grid">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_BOOK }} />
|
||||||
|
<h2>Leitfäden <span className="card-en">Guides</span></h2>
|
||||||
|
<p>Praxisleitfäden zu Verfahren vor dem EPA, BPatG und UPC. Schritt-für-Schritt-Anleitungen für typische Workflows.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_FILE }} />
|
||||||
|
<h2>Vorlagen <span className="card-en">Templates</span></h2>
|
||||||
|
<p>Standardisierte Vorlagen für Schriftsätze, Korrespondenz und interne Dokumente. HL Patents Style Guide.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
|
||||||
|
<h2>Dokumente <span className="card-en">Documents</span></h2>
|
||||||
|
<p>Referenzmaterialien, Checklisten und Arbeitshilfen für den Praxisalltag im Patentrecht.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="offices">
|
||||||
|
<div className="container">
|
||||||
|
<h3>Standorte <span className="card-en">Offices</span></h3>
|
||||||
|
<div className="office-list">
|
||||||
|
<span>München</span>
|
||||||
|
<span>Düsseldorf</span>
|
||||||
|
<span>Amsterdam</span>
|
||||||
|
<span>London</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/jsx.ts
Normal file
62
frontend/src/jsx.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const VOID_ELEMENTS = new Set([
|
||||||
|
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||||||
|
"link", "meta", "param", "source", "track", "wbr",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ATTR_MAP: Record<string, string> = {
|
||||||
|
className: "class",
|
||||||
|
htmlFor: "for",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatten(children: any[]): string {
|
||||||
|
return children
|
||||||
|
.flat(Infinity)
|
||||||
|
.filter((c) => c != null && c !== false && c !== true)
|
||||||
|
.map((c) => String(c))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function h(
|
||||||
|
tag: string | ((props: any) => string),
|
||||||
|
props: Record<string, any> | null,
|
||||||
|
...children: any[]
|
||||||
|
): string {
|
||||||
|
if (typeof tag === "function") {
|
||||||
|
return tag({ ...props, children: children.length === 1 ? children[0] : children });
|
||||||
|
}
|
||||||
|
|
||||||
|
let attrs = "";
|
||||||
|
let innerHTML = "";
|
||||||
|
|
||||||
|
if (props) {
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (key === "children") continue;
|
||||||
|
if (key === "dangerouslySetInnerHTML") {
|
||||||
|
innerHTML = value.__html;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value == null || value === false) continue;
|
||||||
|
const name = ATTR_MAP[key] || key;
|
||||||
|
if (value === true) {
|
||||||
|
attrs += ` ${name}`;
|
||||||
|
} else {
|
||||||
|
attrs += ` ${name}="${escapeAttr(String(value))}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VOID_ELEMENTS.has(tag)) {
|
||||||
|
return `<${tag}${attrs}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = innerHTML || flatten(children);
|
||||||
|
return `<${tag}${attrs}>${content}</${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Fragment({ children }: { children: any }): string {
|
||||||
|
return Array.isArray(children) ? flatten(children) : String(children ?? "");
|
||||||
|
}
|
||||||
52
frontend/src/login.tsx
Normal file
52
frontend/src/login.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { h } from "./jsx";
|
||||||
|
import { Header } from "./components/Header";
|
||||||
|
import { Footer } from "./components/Footer";
|
||||||
|
|
||||||
|
export function renderLogin(loginJs: string): string {
|
||||||
|
return "<!DOCTYPE html>" + (
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Anmelden — patholo</title>
|
||||||
|
<link rel="stylesheet" href="/assets/global.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="login-main">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-tabs">
|
||||||
|
<button className="login-tab active" data-tab="login">Anmelden</button>
|
||||||
|
<button className="login-tab" data-tab="register">Registrieren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="login-form" id="login-form">
|
||||||
|
<label htmlFor="login-email" className="login-label">E-Mail</label>
|
||||||
|
<input type="email" id="login-email" name="email" placeholder="name@hoganlovells.com" required autofocus className="login-input" />
|
||||||
|
<label htmlFor="login-password" className="login-label">Passwort</label>
|
||||||
|
<input type="password" id="login-password" name="password" placeholder="Passwort" required className="login-input" />
|
||||||
|
<button type="submit" className="login-button">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form className="login-form" id="register-form" style="display:none">
|
||||||
|
<label htmlFor="reg-email" className="login-label">E-Mail</label>
|
||||||
|
<input type="email" id="reg-email" name="email" placeholder="name@hoganlovells.com" required className="login-input" />
|
||||||
|
<label htmlFor="reg-password" className="login-label">Passwort</label>
|
||||||
|
<input type="password" id="reg-password" name="password" placeholder="Mind. 8 Zeichen" required minlength="8" className="login-input" />
|
||||||
|
<label htmlFor="reg-confirm" className="login-label">Passwort bestätigen</label>
|
||||||
|
<input type="password" id="reg-confirm" name="confirm" placeholder="Passwort wiederholen" required minlength="8" className="login-input" />
|
||||||
|
<button type="submit" className="login-button">Registrieren</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="login-hint">{"Nur f\u00FCr @hoganlovells.com Adressen."}</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
|
<script src={`/assets/${loginJs}`}></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ main {
|
|||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Header ─── */
|
/* --- Header --- */
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
@@ -117,7 +117,7 @@ main {
|
|||||||
margin: 0 0.25rem;
|
margin: 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Hero ─── */
|
/* --- Hero --- */
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
background: var(--color-hero-bg);
|
background: var(--color-hero-bg);
|
||||||
@@ -151,7 +151,7 @@ main {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Card Grid ─── */
|
/* --- Card Grid --- */
|
||||||
|
|
||||||
.sections {
|
.sections {
|
||||||
padding: 4rem 0;
|
padding: 4rem 0;
|
||||||
@@ -207,7 +207,7 @@ main {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Offices ─── */
|
/* --- Offices --- */
|
||||||
|
|
||||||
.offices {
|
.offices {
|
||||||
padding: 0 0 4rem;
|
padding: 0 0 4rem;
|
||||||
@@ -252,7 +252,7 @@ main {
|
|||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Footer ─── */
|
/* --- Footer --- */
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
@@ -266,7 +266,7 @@ main {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Login ─── */
|
/* --- Login --- */
|
||||||
|
|
||||||
.login-main {
|
.login-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -401,7 +401,7 @@ main {
|
|||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Responsive ─── */
|
/* --- Responsive --- */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hero {
|
.hero {
|
||||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "h",
|
||||||
|
"jsxFragmentFactory": "Fragment",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "build.ts"]
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,24 +10,6 @@ import (
|
|||||||
"mgit.msbls.de/m/patholo/internal/auth"
|
"mgit.msbls.de/m/patholo/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type loginData struct {
|
|
||||||
Mode string
|
|
||||||
Error string
|
|
||||||
Success string
|
|
||||||
Email string
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
handleLoginPage(w, r)
|
|
||||||
case http.MethodPost:
|
|
||||||
handleLoginSubmit(w, r)
|
|
||||||
default:
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
func handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" {
|
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" {
|
||||||
if exp, err := auth.DecodeJWTExpiry(cookie.Value); err == nil && time.Now().Before(exp) {
|
if exp, err := auth.DecodeJWTExpiry(cookie.Value); err == nil && time.Now().Before(exp) {
|
||||||
@@ -34,130 +17,89 @@ func handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
http.ServeFile(w, r, "dist/login.html")
|
||||||
data := loginData{
|
|
||||||
Mode: r.URL.Query().Get("mode"),
|
|
||||||
Error: r.URL.Query().Get("error"),
|
|
||||||
}
|
|
||||||
if data.Mode == "" {
|
|
||||||
data.Mode = "login"
|
|
||||||
}
|
|
||||||
renderPage(w, "login.html", data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
func handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
||||||
email := strings.TrimSpace(r.FormValue("email"))
|
var req struct {
|
||||||
password := r.FormValue("password")
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
if email == "" || password == "" {
|
}
|
||||||
renderPage(w, "login.html", loginData{
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
Mode: "login",
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage."})
|
||||||
Error: "Bitte E-Mail und Passwort eingeben.",
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isHoganLovellsEmail(email) {
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
renderPage(w, "login.html", loginData{
|
if req.Email == "" || req.Password == "" {
|
||||||
Mode: "login",
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bitte E-Mail und Passwort eingeben."})
|
||||||
Error: "Zugang nur für @hoganlovells.com E-Mail-Adressen.",
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := authClient.SignIn(email, password)
|
if !isHoganLovellsEmail(req.Email) {
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Zugang nur für @hoganlovells.com E-Mail-Adressen."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := authClient.SignIn(req.Email, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("sign in failed for %s: %v", email, err)
|
log.Printf("sign in failed for %s: %v", req.Email, err)
|
||||||
errMsg := "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
errMsg := "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||||
if strings.Contains(err.Error(), "Invalid login credentials") {
|
if strings.Contains(err.Error(), "Invalid login credentials") {
|
||||||
errMsg = "Ungültige E-Mail-Adresse oder Passwort."
|
errMsg = "Ungültige E-Mail-Adresse oder Passwort."
|
||||||
}
|
}
|
||||||
renderPage(w, "login.html", loginData{
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": errMsg})
|
||||||
Mode: "login",
|
|
||||||
Error: errMsg,
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.SetAuthCookies(w, r, tokens)
|
auth.SetAuthCookies(w, r, tokens)
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRegister(w http.ResponseWriter, r *http.Request) {
|
func handleAPIRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
var req struct {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email := strings.TrimSpace(r.FormValue("email"))
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
password := r.FormValue("password")
|
if req.Email == "" || req.Password == "" {
|
||||||
confirm := r.FormValue("confirm")
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bitte alle Felder ausfüllen."})
|
||||||
|
|
||||||
if email == "" || password == "" {
|
|
||||||
renderPage(w, "login.html", loginData{
|
|
||||||
Mode: "register",
|
|
||||||
Error: "Bitte alle Felder ausfüllen.",
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if password != confirm {
|
if len(req.Password) < 8 {
|
||||||
renderPage(w, "login.html", loginData{
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Passwort muss mindestens 8 Zeichen lang sein."})
|
||||||
Mode: "register",
|
|
||||||
Error: "Passwörter stimmen nicht überein.",
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(password) < 8 {
|
if !isHoganLovellsEmail(req.Email) {
|
||||||
renderPage(w, "login.html", loginData{
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Registrierung nur für @hoganlovells.com E-Mail-Adressen."})
|
||||||
Mode: "register",
|
|
||||||
Error: "Passwort muss mindestens 8 Zeichen lang sein.",
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isHoganLovellsEmail(email) {
|
tokens, err := authClient.SignUp(req.Email, req.Password)
|
||||||
renderPage(w, "login.html", loginData{
|
|
||||||
Mode: "register",
|
|
||||||
Error: "Registrierung nur für @hoganlovells.com E-Mail-Adressen.",
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := authClient.SignUp(email, password)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("sign up failed for %s: %v", email, err)
|
log.Printf("sign up failed for %s: %v", req.Email, err)
|
||||||
errMsg := "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
errMsg := "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||||
if strings.Contains(err.Error(), "already registered") || strings.Contains(err.Error(), "already been registered") {
|
if strings.Contains(err.Error(), "already registered") || strings.Contains(err.Error(), "already been registered") {
|
||||||
errMsg = "Ein Account mit dieser E-Mail existiert bereits."
|
errMsg = "Ein Account mit dieser E-Mail existiert bereits."
|
||||||
}
|
}
|
||||||
renderPage(w, "login.html", loginData{
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": errMsg})
|
||||||
Mode: "register",
|
|
||||||
Error: errMsg,
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokens != nil {
|
if tokens != nil {
|
||||||
auth.SetAuthCookies(w, r, tokens)
|
auth.SetAuthCookies(w, r, tokens)
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
writeJSON(w, http.StatusOK, map[string]string{"redirect": "/"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPage(w, "login.html", loginData{
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Account erstellt. Bitte melden Sie sich an."})
|
||||||
Mode: "login",
|
|
||||||
Success: "Account erstellt. Bitte melden Sie sich an.",
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1,65 +1,40 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"encoding/json"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"mgit.msbls.de/m/patholo/internal/auth"
|
"mgit.msbls.de/m/patholo/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var authClient *auth.Client
|
||||||
templates map[string]*template.Template
|
|
||||||
authClient *auth.Client
|
|
||||||
)
|
|
||||||
|
|
||||||
func Register(mux *http.ServeMux, client *auth.Client) {
|
func Register(mux *http.ServeMux, client *auth.Client) {
|
||||||
authClient = client
|
authClient = client
|
||||||
|
|
||||||
// Parse each page template separately so "content" blocks don't collide
|
// API endpoints (JSON, public)
|
||||||
templates = make(map[string]*template.Template)
|
mux.HandleFunc("POST /api/login", handleAPILogin)
|
||||||
for _, page := range []string{"index.html", "login.html"} {
|
mux.HandleFunc("POST /api/register", handleAPIRegister)
|
||||||
t, err := template.ParseFiles(
|
|
||||||
filepath.Join("templates", "base.html"),
|
|
||||||
filepath.Join("templates", page),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("parse template %s: %v", page, err)
|
|
||||||
}
|
|
||||||
templates[page] = t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public routes
|
// Public pages
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
mux.HandleFunc("GET /login", handleLoginPage)
|
||||||
mux.HandleFunc("/login", handleLogin)
|
mux.HandleFunc("GET /logout", handleLogout)
|
||||||
mux.HandleFunc("/register", handleRegister)
|
|
||||||
mux.HandleFunc("/logout", handleLogout)
|
|
||||||
|
|
||||||
// Protected routes — everything else goes through auth middleware
|
// Static assets (public)
|
||||||
|
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
protected := http.NewServeMux()
|
protected := http.NewServeMux()
|
||||||
protected.HandleFunc("/", handleIndex)
|
protected.HandleFunc("GET /{$}", handleIndex)
|
||||||
mux.Handle("/", client.Middleware(protected))
|
mux.Handle("/", client.Middleware(protected))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPage(w http.ResponseWriter, name string, data interface{}) {
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
t, ok := templates[name]
|
http.ServeFile(w, r, "dist/index.html")
|
||||||
if !ok {
|
|
||||||
log.Printf("template %s not found", name)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := t.ExecuteTemplate(w, name, data); err != nil {
|
|
||||||
log.Printf("template error: %v", err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||||
if r.URL.Path != "/" {
|
w.Header().Set("Content-Type", "application/json")
|
||||||
http.NotFound(w, r)
|
w.WriteHeader(status)
|
||||||
return
|
json.NewEncoder(w).Encode(data)
|
||||||
}
|
|
||||||
renderPage(w, "index.html", nil)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
{{define "base"}}<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>patholo — Patent Knowledge for Hogan Lovells</title>
|
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{{template "content" .}}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
{{define "index.html"}}
|
|
||||||
{{template "base" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<header class="header">
|
|
||||||
<div class="container">
|
|
||||||
<nav class="nav">
|
|
||||||
<a href="/" class="logo">
|
|
||||||
<span class="logo-mark">p</span>
|
|
||||||
<span class="logo-text">patholo</span>
|
|
||||||
</a>
|
|
||||||
<div class="nav-right">
|
|
||||||
<a href="/logout" class="nav-logout">Abmelden</a>
|
|
||||||
<div class="nav-lang">
|
|
||||||
<span class="lang-active">DE</span>
|
|
||||||
<span class="lang-sep">/</span>
|
|
||||||
<span class="lang-inactive">EN</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section class="hero">
|
|
||||||
<div class="container">
|
|
||||||
<h1>Patent Knowledge<br><span class="hero-accent">für Hogan Lovells</span></h1>
|
|
||||||
<p class="hero-sub">
|
|
||||||
Leitfäden, Vorlagen und Dokumente für das HL Patent-Team.
|
|
||||||
<br>
|
|
||||||
<span class="hero-en">Guides, templates, and documents for the HL patent team.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sections">
|
|
||||||
<div class="container">
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M8 7h6"/><path d="M8 11h4"/></svg>
|
|
||||||
</div>
|
|
||||||
<h2>Leitfäden <span class="card-en">Guides</span></h2>
|
|
||||||
<p>Praxisleitfäden zu Verfahren vor dem EPA, BPatG und UPC. Schritt-für-Schritt-Anleitungen für typische Workflows.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
|
|
||||||
</div>
|
|
||||||
<h2>Vorlagen <span class="card-en">Templates</span></h2>
|
|
||||||
<p>Standardisierte Vorlagen für Schriftsätze, Korrespondenz und interne Dokumente. HL Patents Style Guide.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
||||||
</div>
|
|
||||||
<h2>Dokumente <span class="card-en">Documents</span></h2>
|
|
||||||
<p>Referenzmaterialien, Checklisten und Arbeitshilfen für den Praxisalltag im Patentrecht.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="offices">
|
|
||||||
<div class="container">
|
|
||||||
<h3>Standorte <span class="card-en">Offices</span></h3>
|
|
||||||
<div class="office-list">
|
|
||||||
<span>München</span>
|
|
||||||
<span>Düsseldorf</span>
|
|
||||||
<span>Amsterdam</span>
|
|
||||||
<span>London</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="container">
|
|
||||||
<p>© 2026 patholo — Internal use only. Hogan Lovells Patent Practice.</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
{{define "login.html"}}
|
|
||||||
{{template "base" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<header class="header">
|
|
||||||
<div class="container">
|
|
||||||
<nav class="nav">
|
|
||||||
<a href="/" class="logo">
|
|
||||||
<span class="logo-mark">p</span>
|
|
||||||
<span class="logo-text">patholo</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="login-main">
|
|
||||||
<div class="login-card">
|
|
||||||
<div class="login-tabs">
|
|
||||||
<button class="login-tab{{if ne .Mode "register"}} active{{end}}" data-tab="login">Anmelden</button>
|
|
||||||
<button class="login-tab{{if eq .Mode "register"}} active{{end}}" data-tab="register">Registrieren</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{if .Error}}
|
|
||||||
<div class="login-error">{{.Error}}</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .Success}}
|
|
||||||
<div class="login-success">{{.Success}}</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<form method="POST" action="/login" class="login-form" id="login-form"{{if eq .Mode "register"}} style="display:none"{{end}}>
|
|
||||||
<label for="login-email" class="login-label">E-Mail</label>
|
|
||||||
<input type="email" id="login-email" name="email" value="{{.Email}}" placeholder="name@hoganlovells.com" required autofocus class="login-input">
|
|
||||||
<label for="login-password" class="login-label">Passwort</label>
|
|
||||||
<input type="password" id="login-password" name="password" placeholder="Passwort" required class="login-input">
|
|
||||||
<button type="submit" class="login-button">Anmelden</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="POST" action="/register" class="login-form" id="register-form"{{if ne .Mode "register"}} style="display:none"{{end}}>
|
|
||||||
<label for="reg-email" class="login-label">E-Mail</label>
|
|
||||||
<input type="email" id="reg-email" name="email" value="{{.Email}}" placeholder="name@hoganlovells.com" required class="login-input">
|
|
||||||
<label for="reg-password" class="login-label">Passwort</label>
|
|
||||||
<input type="password" id="reg-password" name="password" placeholder="Mind. 8 Zeichen" required minlength="8" class="login-input">
|
|
||||||
<label for="reg-confirm" class="login-label">Passwort bestätigen</label>
|
|
||||||
<input type="password" id="reg-confirm" name="confirm" placeholder="Passwort wiederholen" required minlength="8" class="login-input">
|
|
||||||
<button type="submit" class="login-button">Registrieren</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="login-hint">Nur für @hoganlovells.com Adressen.</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="container">
|
|
||||||
<p>© 2026 patholo — Internal use only. Hogan Lovells Patent Practice.</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('.login-tab').forEach(function(btn) {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
document.querySelectorAll('.login-tab').forEach(function(t) { t.classList.remove('active'); });
|
|
||||||
btn.classList.add('active');
|
|
||||||
document.getElementById('login-form').style.display = btn.dataset.tab === 'login' ? '' : 'none';
|
|
||||||
document.getElementById('register-form').style.display = btn.dataset.tab === 'register' ? '' : 'none';
|
|
||||||
document.querySelectorAll('.login-error, .login-success').forEach(function(el) { el.style.display = 'none'; });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{end}}
|
|
||||||
Reference in New Issue
Block a user