first wokring prototype
This commit is contained in:
48
src/layout/AppShell.tsx
Normal file
48
src/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/layout/BreadcrumbBar.tsx
Normal file
69
src/layout/BreadcrumbBar.tsx
Normal 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
122
src/layout/HelpMenu.tsx
Normal 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
35
src/layout/IconRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/layout/SectionSidebar.tsx
Normal file
49
src/layout/SectionSidebar.tsx
Normal 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
138
src/layout/Titlebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user