feat(ThemeProvider): add some magic

This commit is contained in:
Nicolas Meienberger 2023-11-26 09:13:06 +01:00
parent de3141e1f8
commit 213b9ed482
19 changed files with 2094 additions and 18 deletions

View file

@ -52,6 +52,7 @@
"connect-redis": "^7.1.0",
"drizzle-orm": "^0.28.6",
"fs-extra": "^11.1.1",
"let-it-go": "^1.0.0",
"lodash.merge": "^4.6.2",
"next": "14.0.1",
"next-client-cookies": "^1.0.6",

View file

@ -58,9 +58,17 @@ export const envSchema = z.object({
if (typeof value === 'boolean') return value;
return value === 'true';
}),
allowAutoThemes: z
.string()
.or(z.boolean())
.optional()
.transform((value) => {
if (typeof value === 'boolean') return value;
return value === 'true';
}),
});
export const settingsSchema = envSchema
.partial()
.pick({ dnsIp: true, internalIp: true, postgresPort: true, appsRepoUrl: true, domain: true, storagePath: true, localDomain: true, demoMode: true, guestDashboard: true })
.pick({ dnsIp: true, internalIp: true, postgresPort: true, appsRepoUrl: true, domain: true, storagePath: true, localDomain: true, demoMode: true, guestDashboard: true, allowAutoThemes: true })
.and(z.object({ port: z.number(), sslPort: z.number(), listenIp: z.string().ip().trim() }).partial());

File diff suppressed because it is too large Load diff

BIN
public/tipi-christmas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -1,10 +1,13 @@
import React from 'react';
import Image from 'next/image';
import { getCurrentLocale } from 'src/utils/getCurrentLocale';
import { getLogo } from '@/lib/themes';
import { getConfig } from '@/server/core/TipiConfig';
import { LanguageSelector } from '../components/LanguageSelector';
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
const locale = getCurrentLocale();
const { allowAutoThemes } = getConfig();
return (
<div className="page page-center">
@ -15,7 +18,7 @@ export default async function AuthLayout({ children }: { children: React.ReactNo
<div className="text-center mb-4">
<Image
alt="Tipi logo"
src="/tipi.png"
src={getLogo(allowAutoThemes)}
height={50}
width={50}
style={{

View file

@ -26,7 +26,7 @@ export default async function Page() {
if (app.info?.available)
return (
<Link href={`/apps/${app.id}`} className={clsx('col-sm-6 col-lg-4', styles.link)} passHref>
<Link key={app.id} href={`/apps/${app.id}`} className={clsx('col-sm-6 col-lg-4', styles.link)} passHref>
<AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />
</Link>
);

View file

@ -13,14 +13,16 @@ import { useAction } from 'next-safe-action/hook';
import { logoutAction } from '@/actions/logout/logout-action';
import Script from 'next/script';
import { useRouter } from 'next/navigation';
import { getLogo } from '@/lib/themes';
import { NavBar } from '../NavBar';
interface IProps {
isUpdateAvailable?: boolean;
authenticated?: boolean;
autoTheme: boolean;
}
export const Header: React.FC<IProps> = ({ isUpdateAvailable, authenticated = true }) => {
export const Header: React.FC<IProps> = ({ isUpdateAvailable, authenticated = true, autoTheme }) => {
const { setDarkMode } = useUIStore();
const t = useTranslations('header');
@ -55,7 +57,7 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable, authenticated = tr
className={clsx('navbar-brand-image me-3')}
width={100}
height={100}
src="/tipi.png"
src={getLogo(autoTheme)}
style={{
width: '30px',
maxWidth: '30px',

View file

@ -5,6 +5,7 @@ import { SystemServiceClass } from '@/server/services/system';
import semver from 'semver';
import clsx from 'clsx';
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { getConfig } from '@/server/core/TipiConfig';
import { Header } from './components/Header';
import { PageTitle } from './components/PageTitle';
import styles from './layout.module.scss';
@ -12,6 +13,7 @@ import { LayoutActions } from './components/LayoutActions/LayoutActions';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = await getUserFromCookie();
const { allowAutoThemes } = getConfig();
const { apps } = await AppServiceClass.listApps();
@ -25,7 +27,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
return (
<div className="page">
<Header isUpdateAvailable={!isLatest} />
<Header isUpdateAvailable={!isLatest} autoTheme={allowAutoThemes} />
<div className="page-wrapper">
<div className="page-header d-print-none">
<div className="container-xl">

View file

@ -6,6 +6,7 @@ import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hook';
import { updateSettingsAction } from '@/actions/settings/update-settings';
import { Locale } from '@/shared/internationalization/locales';
import { useRouter } from 'next/navigation';
import { SettingsForm, SettingsFormValues } from '../SettingsForm';
type Props = {
@ -16,12 +17,15 @@ type Props = {
export const SettingsContainer = ({ initialValues, currentLocale }: Props) => {
const t = useTranslations();
const router = useRouter();
const updateSettingsMutation = useAction(updateSettingsAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
toast.success(t('settings.settings.settings-updated'));
router.refresh();
}
},
});

View file

@ -19,6 +19,7 @@ export type SettingsFormValues = {
storagePath?: string;
localDomain?: string;
guestDashboard?: boolean;
allowAutoThemes?: boolean;
};
interface IProps {
@ -139,6 +140,29 @@ export const SettingsForm = (props: IProps) => {
)}
/>
</div>
<div className="mb-3">
<Controller
control={control}
name="allowAutoThemes"
defaultValue={false}
render={({ field: { onChange, value, ref, ...rest } }) => (
<Switch
className="mb-3"
ref={ref}
checked={value}
onCheckedChange={onChange}
{...rest}
label={
<>
{t('allow-auto-themes')}
<Tooltip anchorSelect=".allow-auto-themes-hint">{t('allow-auto-themes-hint')}</Tooltip>
<span className={clsx('ms-1 form-help allow-auto-themes-hint')}>?</span>
</>
}
/>
)}
/>
</div>
<div className="mb-3">
<Input
{...register('domain')}

View file

@ -8,12 +8,15 @@ type Props = {
children: React.ReactNode;
cookies: ComponentProps<typeof CookiesProvider>['value'];
initialTheme?: string;
allowAutoThemes: boolean;
};
export const ClientProviders = ({ children, initialTheme, cookies }: Props) => {
export const ClientProviders = ({ children, initialTheme, cookies, allowAutoThemes }: Props) => {
return (
<CookiesProvider value={cookies}>
<ThemeProvider initialTheme={initialTheme}>{children}</ThemeProvider>
<ThemeProvider allowAutoThemes={allowAutoThemes} initialTheme={initialTheme}>
{children}
</ThemeProvider>
</CookiesProvider>
);
};

View file

@ -3,14 +3,22 @@
import { useUIStore } from '@/client/state/uiStore';
import React, { useEffect } from 'react';
import { useCookies } from 'next-client-cookies';
import { getAutoTheme } from '@/lib/themes';
type Props = {
children: React.ReactNode;
allowAutoThemes: boolean;
initialTheme?: string;
};
const loadChristmasTheme = async () => {
const { default: LetItGo } = await import('let-it-go');
const snow = new LetItGo({ number: 50 });
snow.letItGoAgain();
};
export const ThemeProvider = (props: Props) => {
const { children, initialTheme } = props;
const { children, initialTheme, allowAutoThemes } = props;
const cookies = useCookies();
const { theme, setDarkMode } = useUIStore();
@ -30,5 +38,12 @@ export const ThemeProvider = (props: Props) => {
setDarkMode(cookieTheme === 'dark');
}, [cookies, initialTheme, setDarkMode, theme]);
useEffect(() => {
const autoTheme = getAutoTheme();
if (autoTheme === 'christmas' && allowAutoThemes && typeof window !== 'undefined') {
loadChristmasTheme();
}
}, [allowAutoThemes]);
return children;
};

View file

@ -5,6 +5,7 @@ import { cookies } from 'next/headers';
import { Inter } from 'next/font/google';
import merge from 'lodash.merge';
import { NextIntlClientProvider } from 'next-intl';
import { getConfig } from '@/server/core/TipiConfig';
import './global.css';
import clsx from 'clsx';
@ -31,10 +32,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const theme = cookies().get('theme');
const { allowAutoThemes } = getConfig();
return (
<html lang={locale} className={clsx(inter.className, 'border-top-wide border-primary')}>
<NextIntlClientProvider locale={locale} messages={mergedMessages}>
<ClientProviders initialTheme={theme?.value} cookies={cookies().getAll()}>
<ClientProviders initialTheme={theme?.value} cookies={cookies().getAll()} allowAutoThemes={allowAutoThemes}>
<body data-bs-theme={theme?.value}>
{children}
<Toaster />

View file

@ -14,7 +14,7 @@ export const dynamic = 'force-dynamic';
export default async function RootPage() {
const appService = new AppServiceClass(db);
const { guestDashboard } = getConfig();
const { guestDashboard, allowAutoThemes } = getConfig();
const headersList = headers();
const host = headersList.get('host');
@ -24,7 +24,7 @@ export default async function RootPage() {
const apps = await appService.getGuestDashboardApps();
return (
<UnauthenticatedPage title="guest-dashboard" subtitle="runtipi">
<UnauthenticatedPage autoTheme={allowAutoThemes} title="guest-dashboard" subtitle="runtipi">
{apps.length === 0 ? (
<EmptyPage title="guest-dashboard-no-apps" subtitle="guest-dashboard-no-apps-subtitle" />
) : (

View file

@ -1,5 +1,6 @@
import Image from 'next/image';
import React from 'react';
import { getLogo } from '@/lib/themes';
import { Button } from '../ui/Button';
interface IProps {
@ -16,7 +17,7 @@ export const StatusScreen: React.FC<IProps> = ({ title, subtitle, onAction, acti
<Image
alt="Tipi log"
className="mb-3"
src="/tipi.png"
src={getLogo(false)}
height={50}
width={50}
style={{

View file

@ -10,15 +10,16 @@ type Props = {
children: React.ReactNode;
title: MessageKey;
subtitle?: MessageKey;
autoTheme: boolean;
};
export const UnauthenticatedPage = (props: Props) => {
const { children, title, subtitle } = props;
const { children, title, subtitle, autoTheme } = props;
const t = useTranslations();
return (
<div className="page">
<Header authenticated={false} />
<Header authenticated={false} autoTheme={autoTheme} />
<div className="page-wrapper">
<div className="page-header d-print-none">
<div className="container-xl">

View file

@ -251,6 +251,8 @@
"invalid-domain": "Invalid domain",
"guest-dashboard": "Enable guest dashboard",
"guest-dashboard-hint": "This will allow non-authenticated users to see a limited dashboard and easily access the running apps on your instance.",
"allow-auto-themes": "Allow auto themes",
"allow-auto-themes-hint": "Be surprised by themes that change automatically based on the time of the year.",
"domain-name": "Domain name",
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
"dns-ip": "DNS IP",

38
src/lib/themes.ts Normal file
View file

@ -0,0 +1,38 @@
export const THEMES = {
christmas: {
name: 'christmas',
month: 11,
day: 1,
durationInDays: 26,
},
};
export type Theme = keyof typeof THEMES | 'default';
export const getAutoTheme = (): Theme => {
const date = new Date();
const theme = Object.entries(THEMES).find(([, { month, day, durationInDays }]) => {
const startDate = new Date(date.getFullYear(), month, day);
const endDate = new Date(date.getFullYear(), month, day + durationInDays);
return startDate <= date && date <= endDate;
});
return theme ? (theme[0] as Theme) : 'default';
};
export const getLogo = (autoTheme: boolean) => {
if (!autoTheme) {
return '/tipi.png';
}
const theme = getAutoTheme();
switch (theme) {
case 'christmas':
return '/tipi-christmas.png';
default:
return '/tipi.png';
}
};

View file

@ -52,6 +52,7 @@ export class TipiConfig {
demoMode: conf.DEMO_MODE,
guestDashboard: conf.GUEST_DASHBOARD,
seePreReleaseVersions: false,
allowAutoThemes: true,
};
const parsedConfig = envSchema.safeParse({ ...envConfig, ...this.getFileConfig() });