first wokring prototype

This commit is contained in:
2026-06-10 04:10:02 +02:00
parent 50d779a537
commit 7491c0a1b4
90 changed files with 10799 additions and 1 deletions

48
src/layout/AppShell.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useLocation } from "react-router-dom";
import type { ApiSettings, AuthInfo } from "../types";
import IconRail from "./IconRail";
import Titlebar from "./Titlebar";
import BreadcrumbBar from "./BreadcrumbBar";
type Props = {
children: React.ReactNode;
settings: ApiSettings;
auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
publicMode?: boolean;
};
export default function AppShell({
children,
settings,
auth,
onSettingsChange,
onAuthChange,
publicMode = false
}: Props) {
const location = useLocation();
if (publicMode) {
return (
<div className="app-shell public-shell">
<IconRail compact />
<div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
<main className="public-content">{children}</main>
</div>
</div>
);
}
return (
<div className="app-shell">
<IconRail />
<div className="app-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
<BreadcrumbBar pathname={location.pathname} />
<main className="app-content">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
export default function BreadcrumbBar({ pathname }: { pathname: string }) {
const parts = pathname.split("/").filter(Boolean);
const labels = parts.length ? parts : ["campaigns"];
return (
<div className="breadcrumb-bar">
<nav className="breadcrumbs" aria-label="Breadcrumb">
{labels.map((part, index) => {
const href = `/${labels.slice(0, index + 1).join("/")}`;
return (
<span className="crumb" key={`${part}-${index}`}>
<Link className="crumb-link" to={href}>{labelFor(part, labels, index)}</Link>
{index < labels.length - 1 && <ChevronRight size={16} aria-hidden="true" />}
</span>
);
})}
</nav>
</div>
);
}
const campaignRouteLabels: Record<string, string> = {
data: "General",
campaign: "General",
settings: "Global settings",
"global-settings": "Global settings",
fields: "Fields",
recipients: "Recipients",
template: "Template",
files: "Attachments",
attachments: "Attachments",
mail: "Server settings",
"mail-settings": "Server settings",
"server-settings": "Server settings",
review: "Review",
send: "Send",
report: "Report",
reports: "Report",
audit: "Audit log",
json: "JSON",
wizard: "Wizard",
create: "Create",
};
const topLevelRouteLabels: Record<string, string> = {
campaigns: "Campaigns",
dashboard: "Dashboard",
templates: "Templates",
files: "Files",
reports: "Reports",
settings: "Settings",
admin: "Admin",
};
function labelFor(value: string, parts: string[], index: number): string {
if (parts[0] === "campaigns" && index === 1) return "Campaign";
if (parts[0] === "campaigns" && index >= 2) {
const mapped = campaignRouteLabels[value];
if (mapped) return mapped;
}
const mapped = topLevelRouteLabels[value];
if (mapped) return mapped;
if (value.length > 18) return "Campaign";
return value.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

122
src/layout/HelpMenu.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
import Button from "../components/Button";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
export default function HelpMenu() {
const [open, setOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const [contextOpen, setContextOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const helpContext = helpContextForPathname(location.pathname);
useEffect(() => {
function openContextHelp(event: KeyboardEvent) {
if (event.key !== "F1") return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
setOpen(false);
setContextOpen(true);
}
function onPointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
window.addEventListener("keydown", openContextHelp, true);
window.addEventListener("mousedown", onPointerDown);
return () => {
window.removeEventListener("keydown", openContextHelp, true);
window.removeEventListener("mousedown", onPointerDown);
};
}, []);
function openHelp() {
setOpen(false);
setContextOpen(true);
}
return (
<div className="context-menu-wrap" ref={wrapRef}>
<button
className="titlebar-link"
onClick={() => setOpen(!open)}
onKeyDown={(event) => {
if (event.key === "F1") {
event.preventDefault();
event.stopPropagation();
openHelp();
}
}}
title="Help (F1)"
>
<HelpCircle size={17} /> Help
</button>
{open && (
<div className="dropdown-menu">
<button className="dropdown-item" onClick={openHelp}>
<HelpCircle size={16} /> Help <small>F1</small>
</button>
<hr />
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> User docs</a>
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> Admin docs</a>
<hr />
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> GitLab</a>
<button className="dropdown-item" onClick={() => { setAboutOpen(true); setOpen(false); }}><Info size={16} /> About</button>
</div>
)}
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
</div>
);
}
function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" data-help-context={context.id}>
<div className="modal-panel">
<header className="modal-header">
<h2>Context help</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="help-panel-section">
<h3>{context.title}</h3>
<p className="mono-small">Help context: {context.id}</p>
<p className="muted">This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter <span className="kbd">{helpQueryForContext(context)}</span>, to open the right page or section.</p>
</div>
<div className="help-panel-section">
<h3>Next actions</h3>
<p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p>
</div>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}
function AboutModal({ onClose }: { onClose: () => void }) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel">
<header className="modal-header">
<h2>About MultiMailer</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="about-logo" />
<p><strong>MultiMailer WebUI</strong></p>
<p className="muted">Version 0.1.0 early development build.</p>
<p>MultiMailer is a local-first / server-assisted campaign mailer for structured, personalized bulk messages with attachment resolution, review workflows and auditable sending.</p>
<p><a href="https://add-ideas.de" target="_blank" rel="noreferrer">add-ideas.de</a></p>
<p className="muted">License: project license pending / to be finalized. Backend components are currently development prototypes.</p>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}

35
src/layout/IconRail.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Form, FileText, Folder, LayoutDashboard, MailCheck, Settings, Shield, Users } from "lucide-react";
import { NavLink } from "react-router-dom";
const items = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/campaigns", label: "Campaigns", icon: MailCheck },
{ to: "/templates", label: "Templates", icon: Form },
{ to: "/files", label: "Files", icon: Folder },
{ to: "/reports", label: "Reports", icon: FileText },
{ to: "/settings", label: "Settings", icon: Settings },
{ to: "/admin", label: "Admin", icon: Shield }
];
export default function IconRail({ compact = false }: { compact?: boolean }) {
return (
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
<div className="brand-mark" title="MultiMailer">MSM</div>
{!compact && (
<>
<nav className="icon-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={label}>
<Icon size={20} />
</NavLink>
))}
</nav>
<div className="icon-rail-bottom">
<Users size={18} />
</div>
</>
)}
</aside>
);
}

View File

@@ -0,0 +1,49 @@
import type { CampaignWorkspaceSection } from "../types";
const campaignItems: { id: CampaignWorkspaceSection; label: string }[] = [
{ id: "campaign", label: "General" },
{ id: "fields", label: "Fields" },
{ id: "recipients", label: "Recipients" },
{ id: "template", label: "Template" },
{ id: "files", label: "Attachments" }
];
const sendItems: { id: CampaignWorkspaceSection; label: string }[] = [
{ id: "mail-settings", label: "Server settings" },
{ id: "global-settings", label: "Global settings" },
{ id: "review", label: "Review" },
{ id: "send", label: "Send" },
{ id: "report", label: "Report" },
{ id: "audit", label: "Audit log" }
];
export default function SectionSidebar({
active,
onSelect
}: {
active: CampaignWorkspaceSection;
onSelect: (section: CampaignWorkspaceSection) => void;
}) {
return (
<aside className="section-sidebar">
<button className={`section-link section-link-primary ${active === "overview" ? "active" : ""}`} onClick={() => onSelect("overview")}>Overview</button>
<div className="section-title section-title-lower">CAMPAIGN</div>
{campaignItems.map((item) => (
<button key={item.id} className={`section-link ${active === item.id ? "active" : ""}`} onClick={() => onSelect(item.id)}>
{item.label}
</button>
))}
<div className="section-title section-title-lower">SEND CAMPAIGN</div>
{sendItems.map((item) => (
<button key={item.id} className={`section-link ${active === item.id ? "active" : ""}`} onClick={() => onSelect(item.id)}>
{item.label}
</button>
))}
<div className="section-title section-title-lower">ADVANCED</div>
<button className={`section-link subtle ${active === "json" ? "active" : ""}`} onClick={() => onSelect("json")}>JSON</button>
</aside>
);
}

138
src/layout/Titlebar.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { useRef, useState, useEffect } from "react";
import { Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types";
import HelpMenu from "./HelpMenu";
import LoginModal from "../features/auth/LoginModal";
import { logout } from "../api/auth";
type Props = {
settings: ApiSettings;
auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
};
export default function Titlebar({ settings, auth, onSettingsChange, onAuthChange }: Props) {
const [accountOpen, setAccountOpen] = useState(false);
const [tenantOpen, setTenantOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const accountRef = useRef<HTMLDivElement>(null);
const tenantRef = useRef<HTMLDivElement>(null);
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
const canSwitchTenant = tenants.length > 1;
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (accountRef.current && !accountRef.current.contains(target)) {
setAccountOpen(false);
}
if (tenantRef.current && !tenantRef.current.contains(target)) {
setTenantOpen(false);
}
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
function handleLogin(response: LoginResponse) {
const active = response.active_tenant ?? response.tenant;
onAuthChange(
{
user: response.user,
tenant: active,
active_tenant: active,
tenants: response.tenants ?? [active],
scopes: response.scopes,
roles: response.roles,
groups: response.groups
},
response.access_token
);
}
async function handleLogout() {
try {
await logout(settings);
} catch {
// Logout is best effort; clear local session either way.
}
onAuthChange(null, "");
setAccountOpen(false);
}
function handleTenantSelect(tenant: AuthTenantMembership) {
// Backend-side active tenant switching will be wired later. For now this is a UI placeholder.
onAuthChange(auth ? { ...auth, tenant, active_tenant: tenant } : null);
setTenantOpen(false);
}
return (
<header className="titlebar">
{auth && activeTenant && (
<div className="tenant-selector" ref={tenantRef}>
<span className="tenant-label">tenant:</span>
{canSwitchTenant ? (
<>
<button className="tenant-name-button" onClick={() => setTenantOpen(!tenantOpen)}>
<strong>{activeTenant.name}</strong>
<span className="tenant-caret"></span>
</button>
{tenantOpen && (
<div className="dropdown-menu tenant-menu">
{tenants.map((tenant) => {
const active = tenant.id === activeTenant.id;
return (
<button
key={tenant.id}
className={`dropdown-item ${active ? "active" : ""}`}
onClick={() => handleTenantSelect(tenant)}
>
<span>{tenant.name}</span>
{active && <Check size={16} />}
</button>
);
})}
</div>
)}
</>
) : (
<strong>{activeTenant.name}</strong>
)}
</div>
)}
<div className="titlebar-spacer" />
<HelpMenu />
<div className="context-menu-wrap" ref={accountRef}>
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
<UserCircle size={22} />
<span>{auth?.user.display_name || auth?.user.email || "Sign in"}</span>
<span className="tenant-caret"></span>
</button>
{accountOpen && (
<div className="dropdown-menu account-menu">
{auth ? (
<>
<div className="account-menu-header">
<strong>{auth.user.display_name || auth.user.email}</strong>
<span>{auth.user.email}</span>
</div>
<button className="dropdown-item" onClick={() => setAccountOpen(false)}><Settings size={16} /> Account settings</button>
<button className="dropdown-item" onClick={handleLogout}><LogOut size={16} /> Sign out</button>
</>
) : (
<button className="dropdown-item" onClick={() => { setLoginOpen(true); setAccountOpen(false); }}><UserCircle size={16} /> Sign in</button>
)}
</div>
)}
</div>
{loginOpen && <LoginModal settings={settings} onClose={() => setLoginOpen(false)} onLogin={handleLogin} />}
</header>
);
}