Initialize configurable Quick Access module
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-06 19:02:54 +02:00
parent f3cf91b898
commit 3fbbb84267
34 changed files with 3086 additions and 1 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/quick-access-webui",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/quick-access.css": "./src/styles/quick-access.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+116
View File
@@ -0,0 +1,116 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type QuickAccessPreference = {
enabled?: boolean | null;
forced?: boolean;
order?: number | null;
};
export type QuickAccessProfile = {
scope_type: "system" | "tenant" | "user";
tenant_id?: string | null;
scope_id?: string | null;
revision: number;
etag: string;
category_preferences: Record<string, QuickAccessPreference>;
tool_preferences: Record<string, QuickAccessPreference>;
stale_category_ids: string[];
stale_tool_ids: string[];
};
export type QuickAccessCategory = {
id: string;
label: string;
description: string;
icon: string;
order: number;
};
export type QuickAccessTool = {
id: string;
module_id: string;
category_id: string;
label: string;
description?: string | null;
icon: string;
surface_id: string;
full_page_path?: string | null;
required_all: string[];
required_any: string[];
order: number;
default_enabled: boolean;
modes: string[];
};
export type QuickAccessCatalogue = {
categories: QuickAccessCategory[];
tools: QuickAccessTool[];
};
export type EffectiveQuickAccessTool = QuickAccessTool & {
enabled: boolean;
forced: boolean;
locked_by?: string | null;
};
export type EffectiveQuickAccessCategory = QuickAccessCategory & {
enabled: boolean;
forced: boolean;
locked_by?: string | null;
tools: EffectiveQuickAccessTool[];
};
export type EffectiveQuickAccess = {
categories: EffectiveQuickAccessCategory[];
diagnostics: string[];
};
const profileEndpoints = {
system: "/api/v1/quick-access/profiles/system",
tenant: "/api/v1/quick-access/profiles/tenant",
me: "/api/v1/quick-access/profiles/me"
} as const;
export function loadQuickAccessCatalogue(
settings: ApiSettings,
includeAll = false
): Promise<QuickAccessCatalogue> {
const query = includeAll ? "?include_all=true" : "";
return apiFetch(settings, `/api/v1/quick-access/catalogue${query}`);
}
export function loadEffectiveQuickAccess(
settings: ApiSettings,
includeAll = false
): Promise<EffectiveQuickAccess> {
const query = includeAll ? "?include_all=true" : "";
return apiFetch(settings, `/api/v1/quick-access/effective${query}`);
}
export function loadQuickAccessProfile(
settings: ApiSettings,
scope: "system" | "tenant" | "me"
): Promise<QuickAccessProfile> {
return apiFetch(settings, profileEndpoints[scope]);
}
export function saveQuickAccessProfile(
settings: ApiSettings,
scope: "system" | "tenant" | "me",
profile: QuickAccessProfile,
categoryPreferences: Record<string, QuickAccessPreference>,
toolPreferences: Record<string, QuickAccessPreference>
): Promise<QuickAccessProfile> {
return apiFetch(settings, profileEndpoints[scope], {
method: "PUT",
headers: {
"Content-Type": "application/json",
"If-Match": profile.etag
},
body: JSON.stringify({
base_revision: profile.revision,
category_preferences: categoryPreferences,
tool_preferences: toolPreferences
})
});
}
+204
View File
@@ -0,0 +1,204 @@
import {
CalendarDays,
Files,
ListChecks,
MessagesSquare,
Settings2,
X,
type LucideIcon
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { Link, useLocation } from "react-router";
import {
DismissibleAlert,
IconButton,
LoadingFrame,
usePlatformLanguage,
usePlatformUiCapabilities,
type QuickAccessRailProps,
type QuickAccessToolsUiCapability
} from "@govoplan/core-webui";
import {
loadEffectiveQuickAccess,
type EffectiveQuickAccess,
type EffectiveQuickAccessCategory
} from "../api/quickAccess";
const iconByCategory: Record<string, LucideIcon> = {
work: ListChecks,
calendar: CalendarDays,
messages: MessagesSquare,
files: Files
};
export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRailProps) {
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const drawerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const location = useLocation();
const contributions = usePlatformUiCapabilities<QuickAccessToolsUiCapability>("quickAccess.tools");
const { translateText } = usePlatformLanguage();
const renderers = useMemo(
() => new Map(contributions.flatMap((entry) => entry.tools).map((tool) => [tool.id, tool])),
[contributions]
);
const availableToolIds = useMemo(() => new Set(tools.map((tool) => tool.id)), [tools]);
const categories = useMemo(
() => (effective?.categories ?? []).map((category) => ({
...category,
tools: category.tools.filter((tool) => tool.enabled && availableToolIds.has(tool.id))
})).filter((category) => category.enabled && category.tools.length > 0),
[availableToolIds, effective]
);
const activeCategory = categories.find((category) => category.id === activeCategoryId) ?? null;
useEffect(() => {
let active = true;
async function load() {
setLoading(true);
try {
const result = await loadEffectiveQuickAccess(settings);
if (!active) return;
setEffective(result);
setError("");
} catch (reason) {
if (active) setError(reason instanceof Error ? reason.message : "Quick Access could not be loaded.");
} finally {
if (active) setLoading(false);
}
}
void load();
const handleChanged = () => void load();
window.addEventListener("govoplan:quick-access-changed", handleChanged);
return () => {
active = false;
window.removeEventListener("govoplan:quick-access-changed", handleChanged);
};
}, [auth.user.account_id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (activeCategoryId && !categories.some((category) => category.id === activeCategoryId)) {
setActiveCategoryId(null);
}
}, [activeCategoryId, categories]);
useEffect(() => {
if (!activeCategoryId) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") closeDrawer();
}
window.addEventListener("keydown", onKeyDown);
window.requestAnimationFrame(() => {
drawerRef.current
?.querySelector<HTMLElement>("button, a[href], input, select, textarea")
?.focus();
});
return () => window.removeEventListener("keydown", onKeyDown);
}, [activeCategoryId]);
useEffect(() => {
setActiveCategoryId(null);
}, [location.pathname, location.search]);
if (!loading && categories.length === 0 && !error) return null;
function closeDrawer(restoreFocus = true) {
setActiveCategoryId(null);
if (restoreFocus) {
window.requestAnimationFrame(() => triggerRef.current?.focus());
}
}
function toggleCategory(
category: EffectiveQuickAccessCategory,
event: MouseEvent<HTMLButtonElement>
) {
if (activeCategoryId === category.id) {
closeDrawer();
return;
}
triggerRef.current = event.currentTarget;
setActiveCategoryId(category.id);
}
return (
<>
<aside className="quick-access-rail" aria-label="i18n:govoplan-quick-access.quick_access">
<div className="quick-access-rail-tools">
{loading ? <span className="quick-access-rail-loading" aria-label="i18n:govoplan-quick-access.loading" /> : null}
{categories.map((category) => {
const Icon = iconByCategory[category.id] ?? ListChecks;
const label = translateText(category.label);
return (
<button
key={category.id}
type="button"
className={`quick-access-rail-button${activeCategoryId === category.id ? " active" : ""}`}
title={label}
aria-label={label}
aria-expanded={activeCategoryId === category.id}
aria-controls="quick-access-drawer"
onClick={(event) => toggleCategory(category, event)}
>
<Icon size={20} aria-hidden="true" />
</button>
);
})}
</div>
<Link
className="quick-access-rail-button quick-access-settings-link"
to="/settings?section=quick-access"
title={translateText("i18n:govoplan-quick-access.configure")}
aria-label={translateText("i18n:govoplan-quick-access.configure")}
>
<Settings2 size={19} aria-hidden="true" />
</Link>
</aside>
{activeCategory ? (
<div
id="quick-access-drawer"
className="quick-access-drawer"
role="dialog"
aria-modal="false"
aria-label={translateText(activeCategory.label)}
ref={drawerRef}
>
<header className="quick-access-drawer-header">
<div>
<strong>{translateText(activeCategory.label)}</strong>
<small>{translateText(activeCategory.description)}</small>
</div>
<IconButton label="i18n:govoplan-quick-access.close" icon={<X size={18} />} variant="ghost" onClick={() => closeDrawer()} />
</header>
<div className="quick-access-drawer-content">
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
{activeCategory.tools.map((tool) => {
const renderer = renderers.get(tool.id);
return (
<section className="quick-access-tool" key={tool.id} data-tool-id={tool.id}>
<div className="quick-access-tool-heading">
<div>
<strong>{translateText(tool.label)}</strong>
{tool.description ? <small>{translateText(tool.description)}</small> : null}
</div>
{tool.full_page_path ? <Link to={tool.full_page_path} onClick={() => closeDrawer(false)}>i18n:govoplan-quick-access.open_full_page</Link> : null}
</div>
{renderer
? renderer.render({ settings, auth, close: () => closeDrawer(), active: true })
: <p className="quick-access-tool-unavailable">i18n:govoplan-quick-access.compact_view_unavailable</p>}
</section>
);
})}
</LoadingFrame>
</div>
</div>
) : null}
</>
);
}
@@ -0,0 +1,370 @@
import { ArrowDown, ArrowUp, RotateCcw, Save } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
DismissibleAlert,
IconButton,
i18nMessage,
LoadingFrame,
SegmentedControl,
ToggleSwitch,
usePlatformLanguage,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
loadEffectiveQuickAccess,
loadQuickAccessCatalogue,
loadQuickAccessProfile,
saveQuickAccessProfile,
type EffectiveQuickAccess,
type QuickAccessCatalogue,
type QuickAccessPreference,
type QuickAccessProfile
} from "../../api/quickAccess";
type ProfileScope = "system" | "tenant" | "me";
type Draft = {
categories: Record<string, QuickAccessPreference>;
tools: Record<string, QuickAccessPreference>;
};
type AvailabilityMode = "inherit" | "available" | "blocked" | "forced";
const EMPTY_DRAFT: Draft = { categories: {}, tools: {} };
export default function QuickAccessSettingsPanel({
settings,
auth,
scope,
canWrite
}: {
settings: ApiSettings;
auth: AuthInfo;
scope: ProfileScope;
canWrite: boolean;
}) {
const [catalogue, setCatalogue] = useState<QuickAccessCatalogue | null>(null);
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
const [profile, setProfile] = useState<QuickAccessProfile | null>(null);
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [messageTone, setMessageTone] = useState<"success" | "warning">("success");
const { translateText } = usePlatformLanguage();
const isPersonal = scope === "me";
const dirty = useMemo(
() => profile !== null && JSON.stringify(draft) !== JSON.stringify({
categories: profile.category_preferences,
tools: profile.tool_preferences
}),
[draft, profile]
);
useEffect(() => {
void load();
}, [auth.user.account_id, scope, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: reset
});
async function load() {
setLoading(true);
setMessage("");
const includeAll = scope !== "me";
try {
const [nextCatalogue, nextProfile, nextEffective] = await Promise.all([
loadQuickAccessCatalogue(settings, includeAll),
loadQuickAccessProfile(settings, scope),
loadEffectiveQuickAccess(settings, includeAll)
]);
setCatalogue(nextCatalogue);
setProfile(nextProfile);
setEffective(nextEffective);
setDraft({
categories: nextProfile.category_preferences,
tools: nextProfile.tool_preferences
});
} catch (reason) {
setMessageTone("warning");
setMessage(reason instanceof Error ? reason.message : "i18n:govoplan-quick-access.load_failed");
} finally {
setLoading(false);
}
}
async function save(): Promise<boolean> {
if (!profile || !canWrite) return false;
setSaving(true);
setMessage("");
try {
const next = await saveQuickAccessProfile(
settings,
scope,
profile,
draft.categories,
draft.tools
);
setProfile(next);
setDraft({ categories: next.category_preferences, tools: next.tool_preferences });
setMessageTone("success");
setMessage("i18n:govoplan-quick-access.saved");
window.dispatchEvent(new CustomEvent("govoplan:quick-access-changed"));
return true;
} catch (reason) {
setMessageTone("warning");
setMessage(reason instanceof Error ? reason.message : "i18n:govoplan-quick-access.save_failed");
return false;
} finally {
setSaving(false);
}
}
function reset() {
if (!profile) return;
setDraft({
categories: profile.category_preferences,
tools: profile.tool_preferences
});
}
function setPreference(
group: "categories" | "tools",
id: string,
preference: QuickAccessPreference | null
) {
setDraft((current) => {
const nextGroup = { ...current[group] };
if (preference === null) delete nextGroup[id];
else nextGroup[id] = preference;
return { ...current, [group]: nextGroup };
});
}
function move(
group: "categories" | "tools",
ids: string[],
id: string,
direction: -1 | 1
) {
const currentIndex = ids.indexOf(id);
const targetIndex = currentIndex + direction;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= ids.length) return;
const reordered = [...ids];
[reordered[currentIndex], reordered[targetIndex]] = [reordered[targetIndex], reordered[currentIndex]];
setDraft((current) => ({
...current,
[group]: Object.fromEntries(reordered.map((itemId, index) => [
itemId,
{ ...(current[group][itemId] ?? {}), order: (index + 1) * 10 }
]))
}));
}
const categories = useMemo(
() => [...(catalogue?.categories ?? [])].sort((left, right) =>
(draft.categories[left.id]?.order ?? left.order) - (draft.categories[right.id]?.order ?? right.order)
),
[catalogue, draft.categories]
);
const tools = useMemo(
() => [...(catalogue?.tools ?? [])].sort((left, right) => {
if (left.category_id !== right.category_id) return left.category_id.localeCompare(right.category_id);
return (draft.tools[left.id]?.order ?? left.order) - (draft.tools[right.id]?.order ?? right.order);
}),
[catalogue, draft.tools]
);
const lockedCategories = new Map(
(effective?.categories ?? []).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by])
);
const lockedTools = new Map(
(effective?.categories ?? []).flatMap((category) => category.tools).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by])
);
const categoryIds = categories.map((item) => item.id);
return (
<div className="quick-access-settings">
<div className="quick-access-settings-heading">
<div>
<h2>i18n:govoplan-quick-access.settings_title</h2>
<p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p>
</div>
<div className="quick-access-settings-actions">
<Button variant="secondary" icon={<RotateCcw size={16} />} disabled={!dirty || saving} onClick={reset}>i18n:govoplan-quick-access.discard</Button>
<Button variant="primary" icon={<Save size={16} />} disabled={!dirty || saving || !canWrite} onClick={() => void save()}>i18n:govoplan-quick-access.save</Button>
</div>
</div>
{message ? <DismissibleAlert tone={messageTone} resetKey={message}>{message}</DismissibleAlert> : null}
{profile && (profile.stale_category_ids.length || profile.stale_tool_ids.length) ? (
<DismissibleAlert tone="info" resetKey={`${profile.stale_category_ids.join(",")}:${profile.stale_tool_ids.join(",")}`}>
i18n:govoplan-quick-access.stale_preferences_retained
</DismissibleAlert>
) : null}
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
<section className="quick-access-settings-section">
<h3>i18n:govoplan-quick-access.categories</h3>
<div className="quick-access-preference-list">
{categories.map((category, index) => (
<PreferenceRow
key={category.id}
id={category.id}
label={translateText(category.label)}
description={translateText(category.description)}
preference={draft.categories[category.id]}
isPersonal={isPersonal}
lockedBy={editableConstraintSource(scope, lockedCategories.get(category.id))}
disabled={!canWrite || saving}
onChange={(value) => setPreference("categories", category.id, value)}
onMoveUp={() => move("categories", categoryIds, category.id, -1)}
onMoveDown={() => move("categories", categoryIds, category.id, 1)}
first={index === 0}
last={index === categories.length - 1}
/>
))}
</div>
</section>
<section className="quick-access-settings-section">
<h3>i18n:govoplan-quick-access.registered_tools</h3>
<div className="quick-access-preference-list">
{categories.flatMap((category) => {
const categoryTools = tools.filter((tool) => tool.category_id === category.id);
const toolIds = categoryTools.map((tool) => tool.id);
return categoryTools.map((tool, index) => (
<PreferenceRow
key={tool.id}
id={tool.id}
label={translateText(tool.label)}
description={`${translateText(category.label)} · ${tool.module_id}${tool.description ? ` · ${translateText(tool.description)}` : ""}`}
preference={draft.tools[tool.id]}
defaultEnabled={tool.default_enabled}
isPersonal={isPersonal}
lockedBy={editableConstraintSource(scope, lockedTools.get(tool.id))}
disabled={!canWrite || saving}
onChange={(value) => setPreference("tools", tool.id, value)}
onMoveUp={() => move("tools", toolIds, tool.id, -1)}
onMoveDown={() => move("tools", toolIds, tool.id, 1)}
first={index === 0}
last={index === categoryTools.length - 1}
/>
));
})}
{!tools.length ? <p className="quick-access-empty">i18n:govoplan-quick-access.no_registered_tools</p> : null}
</div>
</section>
</LoadingFrame>
</div>
);
}
function PreferenceRow({
id,
label,
description,
preference,
defaultEnabled = true,
isPersonal,
lockedBy,
disabled,
onChange,
onMoveUp,
onMoveDown,
first,
last
}: {
id: string;
label: string;
description: string;
preference?: QuickAccessPreference;
defaultEnabled?: boolean;
isPersonal: boolean;
lockedBy?: string | null;
disabled: boolean;
onChange: (value: QuickAccessPreference | null) => void;
onMoveUp: () => void;
onMoveDown: () => void;
first: boolean;
last: boolean;
}) {
const mode = preferenceMode(preference);
const rowDisabled = disabled || Boolean(lockedBy);
return (
<div className={`quick-access-preference-row${lockedBy ? " is-locked" : ""}`} data-preference-id={id}>
<div className="quick-access-preference-copy">
<strong>{label}</strong>
<small>{description}</small>
{lockedBy ? (
<small>
{i18nMessage("i18n:govoplan-quick-access.locked_by_value", {
value0: lockedBy
})}
</small>
) : null}
</div>
<div className="quick-access-preference-controls">
{isPersonal ? (
<ToggleSwitch
label={label}
inactiveLabel="i18n:govoplan-quick-access.hidden"
activeLabel="i18n:govoplan-quick-access.visible"
checked={preference?.enabled ?? defaultEnabled}
disabled={rowDisabled}
onChange={(enabled) => onChange({ ...preference, enabled })}
/>
) : (
<SegmentedControl
ariaLabel={`${label} availability`}
role="group"
value={mode}
disabled={rowDisabled}
onChange={(value) => onChange(preferenceForMode(value, preference))}
options={[
{ id: "inherit", label: "i18n:govoplan-quick-access.inherit" },
{ id: "available", label: "i18n:govoplan-quick-access.available" },
{ id: "blocked", label: "i18n:govoplan-quick-access.blocked" },
{ id: "forced", label: "i18n:govoplan-quick-access.forced" }
]}
/>
)}
<div className="quick-access-order-buttons">
<IconButton label="i18n:govoplan-quick-access.move_up" icon={<ArrowUp size={16} />} variant="ghost" disabled={disabled || first} onClick={onMoveUp} />
<IconButton label="i18n:govoplan-quick-access.move_down" icon={<ArrowDown size={16} />} variant="ghost" disabled={disabled || last} onClick={onMoveDown} />
</div>
</div>
</div>
);
}
function preferenceMode(preference?: QuickAccessPreference): AvailabilityMode {
if (!preference || preference.enabled === null || preference.enabled === undefined) return "inherit";
if (preference.enabled === false) return "blocked";
return preference.forced ? "forced" : "available";
}
function editableConstraintSource(
scope: ProfileScope,
source: string | null | undefined
): string | null {
if (!source || scope === "system") return null;
if (scope === "tenant") return source === "system" ? source : null;
return source;
}
function preferenceForMode(
mode: AvailabilityMode,
current?: QuickAccessPreference
): QuickAccessPreference | null {
if (mode === "inherit") return current?.order === undefined ? null : { order: current.order };
if (mode === "blocked") return { ...current, enabled: false, forced: false };
if (mode === "forced") return { ...current, enabled: true, forced: true };
return { ...current, enabled: true, forced: false };
}
+78
View File
@@ -0,0 +1,78 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
de: {
"i18n:govoplan-quick-access.quick_access": "Schnellzugriff",
"i18n:govoplan-quick-access.configure": "Schnellzugriff konfigurieren",
"i18n:govoplan-quick-access.close": "Schließen",
"i18n:govoplan-quick-access.loading": "Schnellzugriff wird geladen",
"i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen",
"i18n:govoplan-quick-access.compact_view_unavailable": "Die kompakte Ansicht ist nicht verfügbar. Verwenden Sie die vollständige Seite.",
"i18n:govoplan-quick-access.settings_title": "Schnellzugriff",
"i18n:govoplan-quick-access.personal_help": "Werkzeuge auswählen und ordnen, die neben der aktuellen Arbeit verfügbar bleiben.",
"i18n:govoplan-quick-access.admin_help": "Verfügbarkeit, erzwungene Einträge und Standardreihenfolge für nachgeordnete Ebenen festlegen.",
"i18n:govoplan-quick-access.categories": "Kategorien",
"i18n:govoplan-quick-access.registered_tools": "Registrierte Werkzeuge",
"i18n:govoplan-quick-access.no_registered_tools": "Derzeit registriert kein aktiviertes Modul ein Schnellzugriffswerkzeug.",
"i18n:govoplan-quick-access.inherit": "Erben",
"i18n:govoplan-quick-access.available": "Verfügbar",
"i18n:govoplan-quick-access.blocked": "Gesperrt",
"i18n:govoplan-quick-access.forced": "Erzwungen",
"i18n:govoplan-quick-access.hidden": "Ausgeblendet",
"i18n:govoplan-quick-access.visible": "Sichtbar",
"i18n:govoplan-quick-access.move_up": "Nach oben",
"i18n:govoplan-quick-access.move_down": "Nach unten",
"i18n:govoplan-quick-access.save": "Speichern",
"i18n:govoplan-quick-access.discard": "Verwerfen",
"i18n:govoplan-quick-access.saved": "Die Schnellzugriffseinstellungen wurden gespeichert.",
"i18n:govoplan-quick-access.load_failed": "Die Schnellzugriffseinstellungen konnten nicht geladen werden.",
"i18n:govoplan-quick-access.save_failed": "Die Schnellzugriffseinstellungen konnten nicht gespeichert werden.",
"i18n:govoplan-quick-access.stale_preferences_retained": "Einstellungen für derzeit nicht verfügbare Modulwerkzeuge bleiben erhalten und gelten wieder, sobald die Werkzeuge zurückkehren.",
"i18n:govoplan-quick-access.category.work": "Arbeit",
"i18n:govoplan-quick-access.category.work_description": "Aufgaben, Freigaben, Übergaben und andere zu bearbeitende Arbeit.",
"i18n:govoplan-quick-access.category.calendar": "Kalender",
"i18n:govoplan-quick-access.category.calendar_description": "Termine, Verfügbarkeit und zeitgebundene Verpflichtungen.",
"i18n:govoplan-quick-access.category.messages": "Nachrichten",
"i18n:govoplan-quick-access.category.messages_description": "Mail, Postfach und künftige Gesprächskanäle in einer Einblendung.",
"i18n:govoplan-quick-access.category.files": "Dateien",
"i18n:govoplan-quick-access.category.files_description": "Aktuelle und kontextbezogene Dateien, ohne die laufende Aufgabe zu verlassen.",
"i18n:govoplan-quick-access.locked_by_value": "Durch {value0} festgelegt"
},
en: {
"i18n:govoplan-quick-access.quick_access": "Quick Access",
"i18n:govoplan-quick-access.configure": "Configure Quick Access",
"i18n:govoplan-quick-access.close": "Close",
"i18n:govoplan-quick-access.loading": "Loading Quick Access",
"i18n:govoplan-quick-access.open_full_page": "Open full page",
"i18n:govoplan-quick-access.compact_view_unavailable": "The compact view is unavailable. Use the full page.",
"i18n:govoplan-quick-access.settings_title": "Quick Access",
"i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.",
"i18n:govoplan-quick-access.admin_help": "Set availability, forced items, and default ordering for lower scopes.",
"i18n:govoplan-quick-access.categories": "Categories",
"i18n:govoplan-quick-access.registered_tools": "Registered tools",
"i18n:govoplan-quick-access.no_registered_tools": "No enabled module currently registers a Quick Access tool.",
"i18n:govoplan-quick-access.inherit": "Inherit",
"i18n:govoplan-quick-access.available": "Available",
"i18n:govoplan-quick-access.blocked": "Blocked",
"i18n:govoplan-quick-access.forced": "Forced",
"i18n:govoplan-quick-access.hidden": "Hidden",
"i18n:govoplan-quick-access.visible": "Visible",
"i18n:govoplan-quick-access.move_up": "Move up",
"i18n:govoplan-quick-access.move_down": "Move down",
"i18n:govoplan-quick-access.save": "Save",
"i18n:govoplan-quick-access.discard": "Discard",
"i18n:govoplan-quick-access.saved": "Quick Access settings were saved.",
"i18n:govoplan-quick-access.load_failed": "Quick Access settings could not be loaded.",
"i18n:govoplan-quick-access.save_failed": "Quick Access settings could not be saved.",
"i18n:govoplan-quick-access.stale_preferences_retained": "Preferences for currently unavailable module tools are retained and will apply if those tools return.",
"i18n:govoplan-quick-access.category.work": "Work",
"i18n:govoplan-quick-access.category.work_description": "Tasks, approvals, handoffs, and other work requiring attention.",
"i18n:govoplan-quick-access.category.calendar": "Calendar",
"i18n:govoplan-quick-access.category.calendar_description": "Schedule, availability, and time-bound commitments.",
"i18n:govoplan-quick-access.category.messages": "Messages",
"i18n:govoplan-quick-access.category.messages_description": "Mail, Postbox, and future conversational channels in one overlay.",
"i18n:govoplan-quick-access.category.files": "Files",
"i18n:govoplan-quick-access.category.files_description": "Recent and contextual files without leaving the current task.",
"i18n:govoplan-quick-access.locked_by_value": "Set by {value0}"
}
};
+2
View File
@@ -0,0 +1,2 @@
export { default, quickAccessModule } from "./module";
export * from "./api/quickAccess";
+98
View File
@@ -0,0 +1,98 @@
import { createElement, lazy } from "react";
import type {
AdminSectionsUiCapability,
PlatformWebModule,
QuickAccessRuntimeUiCapability,
SettingsSectionsUiCapability
} from "@govoplan/core-webui";
import QuickAccessRail from "./components/QuickAccessRail";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/quick-access.css";
const QuickAccessSettingsPanel = lazy(
() => import("./features/settings/QuickAccessSettingsPanel")
);
const runtime: QuickAccessRuntimeUiCapability = {
Rail: QuickAccessRail
};
const settingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "quick-access",
label: "i18n:govoplan-quick-access.quick_access",
group: "ui",
order: 30,
surfaceId: "quick_access.settings.personal",
allOf: ["quick_access:profile:read"],
render: ({ settings, auth }) => createElement(QuickAccessSettingsPanel, {
settings,
auth,
scope: "me",
canWrite: auth.scopes.includes("quick_access:profile:write")
})
}
]
};
const adminSections: AdminSectionsUiCapability = {
sections: [
{
id: "system-quick-access",
moduleId: "quick_access",
kind: "settings",
label: "i18n:govoplan-quick-access.quick_access",
group: "SYSTEM",
order: 31,
surfaceId: "quick_access.admin.system",
allOf: ["quick_access:system:admin"],
render: ({ settings, auth }) => createElement(QuickAccessSettingsPanel, {
settings,
auth,
scope: "system",
canWrite: true
})
},
{
id: "tenant-quick-access",
moduleId: "quick_access",
kind: "settings",
label: "i18n:govoplan-quick-access.quick_access",
group: "TENANT",
order: 31,
surfaceId: "quick_access.admin.tenant",
allOf: ["quick_access:profile:admin"],
render: ({ settings, auth }) => createElement(QuickAccessSettingsPanel, {
settings,
auth,
scope: "tenant",
canWrite: true
})
}
]
};
export const quickAccessModule: PlatformWebModule = {
id: "quick_access",
label: "i18n:govoplan-quick-access.quick_access",
version: "0.1.18",
dependencies: ["access"],
optionalDependencies: ["views", "policy"],
translations: generatedTranslations,
viewSurfaces: [
{ id: "quick_access.rail", moduleId: "quick_access", kind: "quick_access", label: "i18n:govoplan-quick-access.quick_access", order: 5, required: true },
{ id: "quick_access.drawer", moduleId: "quick_access", kind: "quick_access", label: "i18n:govoplan-quick-access.quick_access", parentId: "quick_access.rail", order: 10, required: true },
{ id: "quick_access.settings.personal", moduleId: "quick_access", kind: "section", label: "i18n:govoplan-quick-access.quick_access", order: 20 },
{ id: "quick_access.admin.tenant", moduleId: "quick_access", kind: "section", label: "i18n:govoplan-quick-access.quick_access", order: 30 },
{ id: "quick_access.admin.system", moduleId: "quick_access", kind: "section", label: "i18n:govoplan-quick-access.quick_access", order: 40 }
],
uiCapabilities: {
"quickAccess.runtime": runtime,
"settings.sections": settingsSections,
"admin.sections": adminSections
}
};
export default quickAccessModule;
+265
View File
@@ -0,0 +1,265 @@
.quick-access-rail,
.quick-access-drawer,
.quick-access-rail *,
.quick-access-drawer * {
box-sizing: border-box;
}
.quick-access-rail {
position: relative;
z-index: 36;
width: 48px;
min-width: 48px;
height: 100vh;
min-height: 0;
display: flex;
flex-direction: column;
align-items: stretch;
border-left: var(--border-line);
background: var(--panel-header);
}
.quick-access-rail-tools {
min-height: 0;
display: grid;
align-content: start;
gap: 3px;
overflow-y: auto;
padding: 6px 4px;
}
.quick-access-rail-button {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border: 0;
border-radius: 4px;
color: var(--muted);
background: transparent;
cursor: pointer;
text-decoration: none;
}
.quick-access-rail-button:hover {
color: var(--text-strong);
background: var(--sidebar-hover-bg);
}
.quick-access-rail-button.active {
color: var(--accent);
background: var(--accent-soft);
}
.quick-access-settings-link {
margin: auto 4px 6px;
}
.quick-access-rail-loading {
width: 18px;
height: 18px;
margin: 11px auto;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: quick-access-spin 0.8s linear infinite;
}
.quick-access-drawer {
position: fixed;
z-index: 35;
top: 0;
right: 48px;
width: min(420px, calc(100vw - 104px));
height: 100vh;
min-height: 0;
display: flex;
flex-direction: column;
border-left: var(--border-line);
box-shadow: -12px 0 30px rgb(0 0 0 / 18%);
background: var(--panel);
}
.quick-access-drawer-header {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: var(--border-line);
padding: 10px 12px 10px 16px;
background: var(--panel-header);
}
.quick-access-drawer-header > div:first-child,
.quick-access-tool-heading > div:first-child,
.quick-access-preference-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.quick-access-drawer-header small,
.quick-access-tool-heading small,
.quick-access-preference-copy small,
.quick-access-settings-heading p,
.quick-access-tool-unavailable,
.quick-access-empty {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.quick-access-drawer-content {
min-height: 0;
flex: 1;
overflow-y: auto;
}
.quick-access-tool {
display: grid;
gap: 12px;
border-bottom: var(--border-line);
padding: 14px 16px 16px;
}
.quick-access-tool-heading,
.quick-access-settings-heading,
.quick-access-preference-row,
.quick-access-preference-controls,
.quick-access-settings-actions,
.quick-access-order-buttons {
display: flex;
align-items: center;
gap: 8px;
}
.quick-access-tool-heading,
.quick-access-settings-heading,
.quick-access-preference-row {
justify-content: space-between;
}
.quick-access-tool-heading > a {
flex: 0 0 auto;
color: var(--accent);
font-size: 12px;
}
.quick-access-settings {
min-width: 0;
display: grid;
gap: 16px;
}
.quick-access-settings-heading {
align-items: flex-start;
}
.quick-access-settings-heading h2,
.quick-access-settings-section h3 {
margin: 0;
color: var(--text-strong);
}
.quick-access-settings-section {
display: grid;
gap: 8px;
}
.quick-access-settings-section h3 {
font-size: 14px;
}
.quick-access-preference-list {
border: var(--border-line);
background: var(--panel);
}
.quick-access-preference-row {
min-height: 62px;
padding: 8px 10px 8px 12px;
}
.quick-access-preference-row + .quick-access-preference-row {
border-top: var(--border-line);
}
.quick-access-preference-row:hover {
background: var(--sidebar-hover-bg);
}
.quick-access-preference-row.is-locked {
background: var(--panel-soft);
}
.quick-access-preference-controls {
flex: 0 0 auto;
}
.quick-access-preference-controls .segmented-control-option {
min-height: 30px;
padding: 4px 8px;
font-size: 12px;
}
.quick-access-order-buttons {
gap: 2px;
}
.quick-access-empty {
padding: 16px;
}
@keyframes quick-access-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 760px) {
.quick-access-rail {
position: fixed;
z-index: 36;
right: 0;
bottom: 0;
left: 0;
width: 100%;
min-width: 0;
height: 50px;
flex-direction: row;
border-top: var(--border-line);
border-left: 0;
}
.quick-access-rail-tools {
display: flex;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
}
.quick-access-settings-link {
margin: 4px 6px 4px auto;
}
.quick-access-drawer {
right: 0;
bottom: 50px;
top: auto;
width: 100%;
height: min(70vh, 680px);
border-top: var(--border-line);
border-left: 0;
box-shadow: 0 -12px 30px rgb(0 0 0 / 18%);
}
.quick-access-preference-row {
align-items: stretch;
flex-direction: column;
}
.quick-access-preference-controls {
justify-content: space-between;
overflow-x: auto;
}
}