Rewrite the app's entry and leaf components; drop the empty upsell #122
No files matched your search
+5
-3
@@ -1,15 +1,17 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
|
||||
/** Every route renders inside this: a crash screen, a loading screen while pages load, and toasts. */
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
interface EnterpriseUpsellProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* INBUXA: nothing to sell. There is one edition, every feature is in it, and
|
||||
* the edition is never anything but complete (see accountStore), so this
|
||||
* never opens. It stays as an empty component so the places upstream calls
|
||||
* it from merge without conflicts.
|
||||
*/
|
||||
export function EnterpriseUpsell({ open }: EnterpriseUpsellProps) {
|
||||
void open;
|
||||
return null;
|
||||
}
|
||||
@@ -1,24 +1,23 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** The bouncing cat and "Loading…", in a page's place or filling the screen. */
|
||||
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-3', fullScreen ? 'min-h-screen' : 'p-8')}>
|
||||
<img src={inbuxaMark} alt="" className="h-12 w-auto animate-bounce [animation-duration:1.4s]" />
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<Loader2 className="size-4 animate-spin text-primary" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLogoState, loadLogoOnce, subscribeToLogo } from '@/lib/logoCache';
|
||||
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
import { getLogoState, loadLogoOnce, subscribeToLogo } from '@/lib/logoCache';
|
||||
|
||||
export function DefaultLogo() {
|
||||
const { t } = useTranslation();
|
||||
@@ -58,21 +56,20 @@ export function DefaultLogo() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own logo if an admin set one, else inbuxa's. Until the server
|
||||
* answers, an empty space the logo's size holds the layout still.
|
||||
*/
|
||||
export default function Logo() {
|
||||
const { t } = useTranslation();
|
||||
const logo = useSyncExternalStore(subscribeToLogo, getLogoState, getLogoState);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogoOnce();
|
||||
}, []);
|
||||
|
||||
if (logo.status === 'custom') {
|
||||
return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||
useEffect(loadLogoOnce, []);
|
||||
switch (logo.status) {
|
||||
case 'custom':
|
||||
return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||
case 'loading':
|
||||
return <span aria-hidden="true" className="block h-7 w-[140px]" />;
|
||||
default:
|
||||
return <DefaultLogo />;
|
||||
}
|
||||
|
||||
if (logo.status === 'loading') {
|
||||
return <span className="block h-7 w-[140px]" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return <DefaultLogo />;
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
|
||||
export type EditionValue = 'enterprise' | 'community' | 'oss';
|
||||
|
||||
/** Lets a form show fields as a given edition would (the setup wizard does). */
|
||||
export const FormEditionContext = createContext<EditionValue | null>(null);
|
||||
|
||||
/** The edition a form shows fields for: the nearest FormEditionContext, else the server's. */
|
||||
export function useEffectiveEdition(): EditionValue {
|
||||
const override = useContext(FormEditionContext);
|
||||
const storeEdition = useAccountStore((s) => s.edition);
|
||||
return override ?? storeEdition;
|
||||
const fromForm = useContext(FormEditionContext);
|
||||
const fromServer = useAccountStore((s) => s.edition);
|
||||
return fromForm ?? fromServer;
|
||||
}
|
||||
@@ -1,49 +1,47 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bug } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { openInSievepad } from '@/lib/sievepad';
|
||||
import type { SieveInterpreter } from '@/lib/sieveLimits';
|
||||
import { openInSievepad } from '@/lib/sievepad';
|
||||
|
||||
interface SievepadButtonProps {
|
||||
/**
|
||||
* Debug: opens a copy of the script in the Sieve playground that ships with
|
||||
* the console, under the limits of the interpreter that runs it. Nothing
|
||||
* leaves the console, so there is nothing to warn about first.
|
||||
*/
|
||||
export function SievepadButton({
|
||||
scriptName,
|
||||
source,
|
||||
interpreter,
|
||||
}: {
|
||||
scriptName: string;
|
||||
source: string;
|
||||
interpreter: SieveInterpreter;
|
||||
}
|
||||
|
||||
// inbuxa: the playground ships with the console, so the script goes nowhere
|
||||
// else and there is nothing to warn about first. It runs under the limits
|
||||
// of the interpreter that runs it on the server.
|
||||
export function SievepadButton({ scriptName, source, interpreter }: SievepadButtonProps) {
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const open = () => {
|
||||
openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source, interpreter).catch(() => {
|
||||
toast({ title: t('sievepad.failed', "Couldn't open the Sieve playground."), variant: 'destructive' });
|
||||
});
|
||||
};
|
||||
|
||||
const debug = () =>
|
||||
openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source, interpreter).catch(() =>
|
||||
toast({ title: t('sievepad.failed', "Couldn't open the Sieve playground."), variant: 'destructive' }),
|
||||
);
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={open}
|
||||
disabled={!source.trim()}
|
||||
title={t('sievepad.debugHint', 'Test a copy of this script in the Sieve playground')}
|
||||
onClick={debug}
|
||||
>
|
||||
<Bug className="h-4 w-4" />
|
||||
<Bug className="size-4" />
|
||||
{t('sievepad.debug', 'Debug')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const authenticated = useAuthStore((s) => s.isAuthenticated());
|
||||
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||
const location = useLocation();
|
||||
if (!authenticated && !bypassToken) {
|
||||
const originalPath = location.pathname + location.search;
|
||||
return <Navigate to="/login" replace state={{ from: originalPath }} />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
/**
|
||||
* Shows its children to a signed-in admin; anyone else goes to the sign-in
|
||||
* page, which brings them back here afterwards. A development build with
|
||||
* VITE_ACCESS_TOKEN set counts as signed in.
|
||||
*/
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const signedIn = useAuthStore((s) => s.isAuthenticated()) || !!import.meta.env.VITE_ACCESS_TOKEN;
|
||||
const { pathname, search } = useLocation();
|
||||
if (signedIn) return children;
|
||||
return <Navigate to="/login" replace state={{ from: pathname + search }} />;
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
const { ChevronDown, Lock, MoreHorizontal } = LucideIcons;
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -216,7 +215,6 @@ export function SectionNav({ layout }: { layout: Layout }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||
|
||||
const items = useMemo(() => layout.items.filter((item) => topItemVisible(item, edition)), [layout, edition]);
|
||||
|
||||
@@ -265,10 +263,7 @@ export function SectionNav({ layout }: { layout: Layout }) {
|
||||
|
||||
const onPick = useCallback(
|
||||
(viewName: string, locked: boolean) => {
|
||||
if (locked) {
|
||||
setUpsellOpen(true);
|
||||
return;
|
||||
}
|
||||
if (locked) return;
|
||||
navigate(resolveViewPath(layout.name, viewName));
|
||||
},
|
||||
[navigate, layout.name],
|
||||
@@ -358,8 +353,6 @@ export function SectionNav({ layout }: { layout: Layout }) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import * as LucideIcons from 'lucide-react';
|
||||
const { ChevronDown, Lock, PanelLeftClose, PanelLeftOpen } = LucideIcons;
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { IconTile } from '@/components/common/IconTile';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
@@ -67,7 +66,6 @@ interface SidebarSubItemProps {
|
||||
currentPath: string;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
edition: string;
|
||||
onUpsell: () => void;
|
||||
activeItemRef: ActiveItemRef;
|
||||
}
|
||||
|
||||
@@ -78,7 +76,6 @@ function SidebarSubItem({
|
||||
currentPath,
|
||||
navigate,
|
||||
edition,
|
||||
onUpsell,
|
||||
activeItemRef,
|
||||
}: SidebarSubItemProps) {
|
||||
if (item.type === 'link') {
|
||||
@@ -102,11 +99,7 @@ function SidebarSubItem({
|
||||
)}
|
||||
style={depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined}
|
||||
onClick={() => {
|
||||
if (isLocked) {
|
||||
onUpsell();
|
||||
} else {
|
||||
navigate(path);
|
||||
}
|
||||
if (!isLocked) navigate(path);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{item.name || 'Overview'}</span>
|
||||
@@ -141,7 +134,6 @@ function SidebarSubItem({
|
||||
currentPath={currentPath}
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
onUpsell={onUpsell}
|
||||
activeItemRef={activeItemRef}
|
||||
/>
|
||||
))}
|
||||
@@ -159,19 +151,10 @@ interface SidebarTopItemProps {
|
||||
currentPath: string;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
edition: string;
|
||||
onUpsell: () => void;
|
||||
activeItemRef: ActiveItemRef;
|
||||
}
|
||||
|
||||
function SidebarTopItem({
|
||||
item,
|
||||
sectionName,
|
||||
currentPath,
|
||||
navigate,
|
||||
edition,
|
||||
onUpsell,
|
||||
activeItemRef,
|
||||
}: SidebarTopItemProps) {
|
||||
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, activeItemRef }: SidebarTopItemProps) {
|
||||
if ('link' in item) {
|
||||
const { name, icon, viewName } = item.link;
|
||||
|
||||
@@ -194,11 +177,7 @@ function SidebarTopItem({
|
||||
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isLocked) {
|
||||
onUpsell();
|
||||
} else {
|
||||
navigate(path);
|
||||
}
|
||||
if (!isLocked) navigate(path);
|
||||
}}
|
||||
>
|
||||
<IconTile name={icon} />
|
||||
@@ -239,7 +218,6 @@ function SidebarTopItem({
|
||||
currentPath={currentPath}
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
onUpsell={onUpsell}
|
||||
activeItemRef={activeItemRef}
|
||||
/>
|
||||
))}
|
||||
@@ -343,7 +321,6 @@ export function Sidebar({ mobileOnly = false }: SidebarProps = {}) {
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const permissions = useAccountStore((s) => s.permissions);
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||
const activeItem = useRef<HTMLButtonElement | null>(null);
|
||||
const activeItemRef = useCallback<ActiveItemRef>((el) => {
|
||||
activeItem.current = el;
|
||||
@@ -452,14 +429,11 @@ export function Sidebar({ mobileOnly = false }: SidebarProps = {}) {
|
||||
currentPath={location.pathname}
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
onUpsell={() => setUpsellOpen(true)}
|
||||
activeItemRef={activeItemRef}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
import { isPaletteId, PALETTES } from '@/lib/palettes';
|
||||
import Logo from '@/components/common/Logo';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { SOURCE_URL } from '@/lib/sourceDownload';
|
||||
import { visibleLayouts } from '@/lib/layout';
|
||||
import { sectionLandingLink } from '@/lib/lastVisited';
|
||||
@@ -78,7 +77,6 @@ export function TopBar() {
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,8 +136,6 @@ export function TopBar() {
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
||||
{edition !== 'enterprise' && <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -59,7 +59,6 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { ObjectPicker } from '@/components/common/ObjectPicker';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { deleteWithUndo } from '@/components/common/undoableDelete';
|
||||
import { useShortcut } from '@/lib/shortcuts';
|
||||
@@ -397,7 +396,6 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const explainAvailable = useExplainStore((s) => s.available);
|
||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
if (!schema) return null;
|
||||
@@ -1199,7 +1197,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (locked) {
|
||||
setUpsellOpen(true);
|
||||
return;
|
||||
} else if (needsConfirmation) {
|
||||
setConfirmAction({
|
||||
label: action.label,
|
||||
@@ -1617,7 +1615,6 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||
{directoryLocks.dialogs}
|
||||
</div>
|
||||
);
|
||||
|
||||
+20
-10
@@ -1,27 +1,37 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* The console's strings. It ships in English only (en.json); a browser asking
|
||||
* for another language gets English. The admin's own locale still sets the
|
||||
* reading direction.
|
||||
*/
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import en from './en.json';
|
||||
|
||||
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur'];
|
||||
const RIGHT_TO_LEFT = new Set(['ar', 'fa', 'he', 'ur']);
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
/** "pt_BR" or "pt-BR" → "pt". */
|
||||
const languageOf = (locale: string) => locale.split(/[-_]/)[0];
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: { en: { translation: en } },
|
||||
lng: navigator.language?.split('-')[0] || 'en',
|
||||
lng: languageOf(navigator.language ?? '') || 'en',
|
||||
fallbackLng: 'en',
|
||||
// React escapes what it renders.
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export function setLocale(locale: string) {
|
||||
const lang = locale.split('_')[0].split('-')[0];
|
||||
i18n.changeLanguage(lang);
|
||||
const isRtl = RTL_LANGUAGES.includes(lang);
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
/** Switches to the signed-in account's locale. */
|
||||
export function setLocale(locale: string): void {
|
||||
const language = languageOf(locale);
|
||||
void i18n.changeLanguage(language);
|
||||
document.documentElement.dir = RIGHT_TO_LEFT.has(language) ? 'rtl' : 'ltr';
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
+23
-22
@@ -1,10 +1,14 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/*
|
||||
* The console's colors, as custom properties for light and dark (`.dark` on
|
||||
* <html>), handed to Tailwind as its color, radius, font and shadow tokens.
|
||||
* Other palettes (palettes.css) and high contrast (a11y.css) override the
|
||||
* same properties.
|
||||
*/
|
||||
|
||||
@import 'tailwindcss';
|
||||
@@ -101,62 +105,63 @@
|
||||
--chart-5: #fbbf24;
|
||||
}
|
||||
|
||||
/* Tailwind's tokens, read from the properties above so a palette or the dark theme changes them all. */
|
||||
@theme inline {
|
||||
/* Surfaces */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-content-background: var(--content-background);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
/* Emphasis */
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-highlight: var(--highlight);
|
||||
--color-highlight-soft: var(--highlight-soft);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
/* Lines */
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Charts */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Shape and type: radii step 2px around --radius. */
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-soft: var(--shadow-soft);
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Space Grotesk Variable', 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--shadow-soft: var(--shadow-soft);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklab, var(--muted-foreground) 35%, transparent) transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
/* Inter's single-storey a and open digits. */
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
}
|
||||
|
||||
@@ -170,13 +175,9 @@
|
||||
::selection {
|
||||
background: color-mix(in oklab, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklab, var(--muted-foreground) 35%, transparent) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark spinner arrows on number fields. */
|
||||
.dark input[type='number'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
+36
-46
@@ -1,53 +1,57 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
|
||||
import './i18n';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/space-grotesk';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import OAuthCallback from './pages/OAuthCallback';
|
||||
import NotFound from './pages/NotFound';
|
||||
import { AdminPanel } from './pages/AdminPanel.lazy';
|
||||
import { ProtectedRoute } from './components/layout/ProtectedRoute';
|
||||
import { getBasePath } from './lib/basePath';
|
||||
import { loadLogoOnce } from './lib/logoCache';
|
||||
import { AdminPanel } from './pages/AdminPanel.lazy';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import NotFound from './pages/NotFound';
|
||||
import OAuthCallback from './pages/OAuthCallback';
|
||||
|
||||
(() => {
|
||||
/**
|
||||
* Applies the saved theme and palette (uiStore, persisted as `inbuxa-ui`)
|
||||
* before React draws anything, so the page doesn't flash the wrong colors.
|
||||
* With no saved theme, or "system", the system's preference decides.
|
||||
*/
|
||||
function applySavedLook() {
|
||||
const root = document.documentElement;
|
||||
let saved: { theme?: unknown; palette?: unknown } = {};
|
||||
try {
|
||||
const persisted = localStorage.getItem('inbuxa-ui');
|
||||
if (persisted) {
|
||||
const parsed = JSON.parse(persisted);
|
||||
const theme = parsed?.state?.theme;
|
||||
// INBUXA: the palette too, before the first paint, so it doesn't flash.
|
||||
const palette = parsed?.state?.palette;
|
||||
if (typeof palette === 'string' && palette !== 'ihasmail' && /^[a-z-]+$/.test(palette)) {
|
||||
document.documentElement.dataset.palette = palette;
|
||||
}
|
||||
if (theme === 'dark' || theme === 'light') {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.toggle('dark', !!prefersDark);
|
||||
})();
|
||||
saved = JSON.parse(localStorage.getItem('inbuxa-ui') ?? '{}')?.state ?? {};
|
||||
} catch {
|
||||
// Unreadable: use the defaults.
|
||||
}
|
||||
if (typeof saved.palette === 'string' && saved.palette !== 'ihasmail' && /^[a-z-]+$/.test(saved.palette)) {
|
||||
root.dataset.palette = saved.palette;
|
||||
}
|
||||
const dark =
|
||||
saved.theme === 'dark' || saved.theme === 'light'
|
||||
? saved.theme === 'dark'
|
||||
: !!window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
||||
root.classList.toggle('dark', dark);
|
||||
}
|
||||
|
||||
applySavedLook();
|
||||
loadLogoOnce();
|
||||
|
||||
const basePath = getBasePath();
|
||||
const signedIn = (
|
||||
<ProtectedRoute>
|
||||
<AdminPanel />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
@@ -58,26 +62,12 @@ const router = createBrowserRouter(
|
||||
children: [
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'oauth/callback', element: <OAuthCallback /> },
|
||||
{
|
||||
path: ':section/*',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<AdminPanel />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: true,
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<AdminPanel />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{ index: true, element: signedIn },
|
||||
{ path: ':section/*', element: signedIn },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ basename: basePath },
|
||||
{ basename: getBasePath() },
|
||||
);
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { lazy } from 'react';
|
||||
|
||||
/** The signed-in console, loaded only once someone is signed in. */
|
||||
export const AdminPanel = lazy(() => import('./AdminPanel'));
|
||||
+14
-15
@@ -1,27 +1,26 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||
|
||||
/** A path no route matches. */
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useDocumentTitle(t('errors.notFound'));
|
||||
|
||||
const message = t('errors.notFound');
|
||||
useDocumentTitle(message);
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">404</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t('errors.notFound')}</p>
|
||||
<Link to="/" className="mt-4 inline-block text-primary underline hover:no-underline">
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<main className="flex min-h-screen flex-col items-center justify-center text-center">
|
||||
<h1 className="text-4xl font-bold">404</h1>
|
||||
<p className="mt-2 text-muted-foreground">{message}</p>
|
||||
<Link to="/" className="mt-4 text-primary underline hover:no-underline">
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Vendored
+6
-6
@@ -1,18 +1,18 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** Where the server's API is, when the console isn't served by the server itself. */
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
/** Development only: a token that skips signing in. */
|
||||
readonly VITE_ACCESS_TOKEN: string;
|
||||
readonly VITE_OAUTH_SCOPES: string;
|
||||
/** Development only: log JMAP traffic and form changes (lib/debug.ts). */
|
||||
readonly VITE_DEBUG_JMAP?: string;
|
||||
readonly VITE_DEBUG_FORMS?: string;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
/** The console's version, set at build time. */
|
||||
declare const __APP_VERSION__: string;
|
||||
// inbuxa: the Sieve playground's page, relative to the console
|
||||
// (sieve-playground.ts)
|
||||
/** The Sieve playground's page, relative to the console (sieve-playground.ts). */
|
||||
declare const __SIEVE_PLAYGROUND__: string;
|
||||
Reference in new issue
Block a user