Floating dismissable alert - initial commit
This commit is contained in:
8
.env
8
.env
@@ -1,11 +1,13 @@
|
|||||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
# API origin only. Do not append /api or /api/v1; API helpers add /api/v1.
|
||||||
|
# Leave empty for same-origin requests (recommended for Vite development and the bundled WebUI).
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
# Web UI
|
# Web UI
|
||||||
WEBUI_PUBLISHED_PORT=5173
|
WEBUI_PUBLISHED_PORT=5173
|
||||||
# API base url without /api/v1 or similar parts
|
# For local Vite development outside Docker:
|
||||||
VITE_API_BASE_URL=
|
|
||||||
# For local Vite development outside Docker:
|
# For local Vite development outside Docker:
|
||||||
VITE_DEV_API_PROXY_TARGET=http://127.0.0.1:8000
|
VITE_DEV_API_PROXY_TARGET=http://127.0.0.1:8000
|
||||||
|
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080
|
||||||
MULTIMAILER_HOST=msm.localhost
|
MULTIMAILER_HOST=msm.localhost
|
||||||
TRAEFIK_API_ROUTER_NAME=msm-api
|
TRAEFIK_API_ROUTER_NAME=msm-api
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
# API origin only. Do not append /api or /api/v1; API helpers add /api/v1.
|
||||||
|
# Leave empty for same-origin requests (recommended for Vite development and the bundled WebUI).
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
# Web UI
|
# Web UI
|
||||||
WEBUI_PUBLISHED_PORT=5173
|
WEBUI_PUBLISHED_PORT=5173
|
||||||
# API base url without /api/v1 or similar parts
|
|
||||||
VITE_API_BASE_URL=
|
|
||||||
# For local Vite development outside Docker:
|
# For local Vite development outside Docker:
|
||||||
# VITE_DEV_API_PROXY_TARGET=http://127.0.0.1:8000
|
# VITE_API_PROXY_TARGET=http://127.0.0.1:8000
|
||||||
|
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080
|
||||||
MULTIMAILER_HOST=multimailer.localhost
|
MULTIMAILER_HOST=multimailer.localhost
|
||||||
TRAEFIK_API_ROUTER_NAME=multimailer-api
|
TRAEFIK_API_ROUTER_NAME=multimailer-api
|
||||||
|
|||||||
@@ -2,22 +2,42 @@ import type { ApiSettings } from "../types";
|
|||||||
|
|
||||||
const STORAGE_KEY = "multimailer.apiSettings";
|
const STORAGE_KEY = "multimailer.apiSettings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint helpers already pass paths beginning with /api/v1. The configured
|
||||||
|
* value is therefore an origin only. Normalize legacy values that included the
|
||||||
|
* API prefix so old localStorage settings cannot produce /api/v1/api/v1 URLs.
|
||||||
|
*/
|
||||||
|
export function normalizeApiBaseUrl(value: string): string {
|
||||||
|
const withoutTrailingSlash = value.trim().replace(/\/+$/, "");
|
||||||
|
return withoutTrailingSlash.replace(/\/api(?:\/v1)?$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiUrl(settings: ApiSettings, path: string): string {
|
||||||
|
const baseUrl = normalizeApiBaseUrl(settings.apiBaseUrl);
|
||||||
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadApiSettings(): ApiSettings {
|
export function loadApiSettings(): ApiSettings {
|
||||||
const storedBaseUrl = localStorage.getItem(`${STORAGE_KEY}.baseUrl`);
|
const storedBaseUrl = localStorage.getItem(`${STORAGE_KEY}.baseUrl`);
|
||||||
|
const configuredBaseUrl = storedBaseUrl !== null ? storedBaseUrl : import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
const apiBaseUrl = normalizeApiBaseUrl(configuredBaseUrl);
|
||||||
|
|
||||||
|
// Repair legacy values once loaded, while keeping an empty value for same-origin use.
|
||||||
|
if (storedBaseUrl !== null && storedBaseUrl !== apiBaseUrl) {
|
||||||
|
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, apiBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Empty base URL means "same origin". In Vite dev, /api is proxied to FastAPI.
|
// Empty base URL means "same origin". In Vite dev, /api is proxied to FastAPI.
|
||||||
apiBaseUrl:
|
apiBaseUrl,
|
||||||
storedBaseUrl !== null
|
|
||||||
? storedBaseUrl
|
|
||||||
: import.meta.env.VITE_API_BASE_URL ?? "",
|
|
||||||
apiKey: localStorage.getItem(`${STORAGE_KEY}.apiKey`) || "",
|
apiKey: localStorage.getItem(`${STORAGE_KEY}.apiKey`) || "",
|
||||||
accessToken: localStorage.getItem(`${STORAGE_KEY}.accessToken`) || ""
|
accessToken: localStorage.getItem(`${STORAGE_KEY}.accessToken`) || ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveApiSettings(settings: ApiSettings): void {
|
export function saveApiSettings(settings: ApiSettings): void {
|
||||||
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, settings.apiBaseUrl);
|
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl));
|
||||||
localStorage.setItem(`${STORAGE_KEY}.apiKey`, settings.apiKey);
|
localStorage.setItem(`${STORAGE_KEY}.apiKey`, settings.apiKey);
|
||||||
localStorage.setItem(`${STORAGE_KEY}.accessToken`, settings.accessToken);
|
localStorage.setItem(`${STORAGE_KEY}.accessToken`, settings.accessToken);
|
||||||
}
|
}
|
||||||
@@ -27,9 +47,6 @@ export function clearAccessToken(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
||||||
const baseUrl = settings.apiBaseUrl.trim().replace(/\/$/, "");
|
|
||||||
const url = baseUrl ? `${baseUrl}${path}` : path;
|
|
||||||
|
|
||||||
const headers = new Headers(init?.headers || {});
|
const headers = new Headers(init?.headers || {});
|
||||||
|
|
||||||
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
|
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
|
||||||
@@ -42,7 +59,7 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
|||||||
headers.set("X-API-Key", settings.apiKey);
|
headers.set("X-API-Key", settings.apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, { ...init, headers });
|
const response = await fetch(apiUrl(settings, path), { ...init, headers });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiFetch } from "./client";
|
import { apiFetch, apiUrl } from "./client";
|
||||||
import type { ApiSettings } from "../types";
|
import type { ApiSettings } from "../types";
|
||||||
|
|
||||||
export type FileSpace = {
|
export type FileSpace = {
|
||||||
@@ -71,11 +71,6 @@ function authHeaders(settings: ApiSettings): Headers {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiUrl(settings: ApiSettings, path: string): string {
|
|
||||||
const baseUrl = settings.apiBaseUrl.trim().replace(/\/$/, "");
|
|
||||||
return baseUrl ? `${baseUrl}${path}` : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesResponse> {
|
export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesResponse> {
|
||||||
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces");
|
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type DismissibleAlertProps = {
|
|||||||
dismissible?: boolean;
|
dismissible?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
floating?: boolean;
|
||||||
resetKey?: string | number;
|
resetKey?: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export default function DismissibleAlert({
|
|||||||
dismissible = true,
|
dismissible = true,
|
||||||
className = "",
|
className = "",
|
||||||
compact = false,
|
compact = false,
|
||||||
|
floating = false,
|
||||||
resetKey
|
resetKey
|
||||||
}: DismissibleAlertProps) {
|
}: DismissibleAlertProps) {
|
||||||
const [visible, setVisible] = useState(true);
|
const [visible, setVisible] = useState(true);
|
||||||
@@ -28,8 +30,14 @@ export default function DismissibleAlert({
|
|||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const role = tone === "danger" || tone === "warning" ? "alert" : "status";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`alert ${tone}${compact ? " compact-alert" : ""} alert-dismissible ${className}`.trim()}>
|
<div
|
||||||
|
className={`alert ${tone}${compact ? " compact-alert" : ""}${floating ? " alert-floating" : ""} alert-dismissible ${className}`.trim()}
|
||||||
|
role={role}
|
||||||
|
aria-live={role === "alert" ? "assertive" : "polite"}
|
||||||
|
>
|
||||||
<div className="alert-message">{children}</div>
|
<div className="alert-message">{children}</div>
|
||||||
{dismissible && (
|
{dismissible && (
|
||||||
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={() => setVisible(false)}>
|
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={() => setVisible(false)}>
|
||||||
|
|||||||
@@ -1117,9 +1117,7 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
|
|||||||
return (
|
return (
|
||||||
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen">
|
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="file-manager-alerts">
|
<DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`file-manager-shell ${busy ? "is-loading" : ""}`}>
|
<div className={`file-manager-shell ${busy ? "is-loading" : ""}`}>
|
||||||
@@ -1325,6 +1323,19 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
|
|||||||
<FileDialog title={`Upload to ${activeDialogSpace?.label || "Files"} / ${activeDialogTarget?.folderPath || "Root"}`} onClose={closeDialog}>
|
<FileDialog title={`Upload to ${activeDialogSpace?.label || "Files"} / ${activeDialogTarget?.folderPath || "Root"}`} onClose={closeDialog}>
|
||||||
<div
|
<div
|
||||||
className={`file-upload-drop-zone ${dragActive ? "is-active" : ""}`}
|
className={`file-upload-drop-zone ${dragActive ? "is-active" : ""}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={busy || !activeDialogSpace ? -1 : 0}
|
||||||
|
aria-label="Drop files here or click to select files"
|
||||||
|
aria-disabled={busy || !activeDialogSpace}
|
||||||
|
onClick={() => {
|
||||||
|
if (!busy && activeDialogSpace) fileInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if ((event.key === "Enter" || event.key === " ") && !busy && activeDialogSpace) {
|
||||||
|
event.preventDefault();
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDragOver={(event) => { event.preventDefault(); setDragActive(true); }}
|
onDragOver={(event) => { event.preventDefault(); setDragActive(true); }}
|
||||||
onDragLeave={() => setDragActive(false)}
|
onDragLeave={() => setDragActive(false)}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
@@ -1335,6 +1346,7 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
|
|||||||
>
|
>
|
||||||
<UploadCloud size={28} aria-hidden="true" />
|
<UploadCloud size={28} aria-hidden="true" />
|
||||||
<strong>Drop files here</strong>
|
<strong>Drop files here</strong>
|
||||||
|
<span>or click to select files</span>
|
||||||
<span className="muted small-text">Files are uploaded into {activeDialogTarget?.folderPath || "Root"}.</span>
|
<span className="muted small-text">Files are uploaded into {activeDialogTarget?.folderPath || "Root"}.</span>
|
||||||
</div>
|
</div>
|
||||||
<ToggleSwitch label="Unpack ZIP uploads" checked={unpackZip} onChange={setUnpackZip} disabled={busy} />
|
<ToggleSwitch label="Unpack ZIP uploads" checked={unpackZip} onChange={setUnpackZip} disabled={busy} />
|
||||||
|
|||||||
@@ -704,6 +704,19 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert-floating {
|
||||||
|
position: fixed;
|
||||||
|
top: 131px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 1200;
|
||||||
|
width: min(640px, calc(100vw - 32px));
|
||||||
|
margin: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.14);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.alert-dismiss {
|
.alert-dismiss {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -8,20 +8,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-manager-alerts {
|
|
||||||
position: absolute;
|
|
||||||
top: 14px;
|
|
||||||
left: 18px;
|
|
||||||
right: 18px;
|
|
||||||
z-index: 40;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-manager-alerts .alert {
|
|
||||||
pointer-events: auto;
|
|
||||||
box-shadow: var(--shadow-popover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-manager-toolbar {
|
.file-manager-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -487,13 +473,27 @@
|
|||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-upload-drop-zone:hover,
|
||||||
|
.file-upload-drop-zone:focus-visible,
|
||||||
.file-upload-drop-zone.is-active {
|
.file-upload-drop-zone.is-active {
|
||||||
border-color: #0d6efd;
|
border-color: #0d6efd;
|
||||||
background: rgba(13, 110, 253, .08);
|
background: rgba(13, 110, 253, .08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-upload-drop-zone:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(13, 110, 253, .18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-drop-zone[aria-disabled="true"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: .65;
|
||||||
|
}
|
||||||
|
|
||||||
.field-error {
|
.field-error {
|
||||||
color: var(--danger-text);
|
color: var(--danger-text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
Reference in New Issue
Block a user