feat(ThemeProvider): add some magic
This commit is contained in:
parent
de3141e1f8
commit
213b9ed482
19 changed files with 2094 additions and 18 deletions
|
@ -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",
|
||||
|
|
|
@ -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());
|
||||
|
|
1974
pnpm-lock.yaml
1974
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
BIN
public/tipi-christmas.png
Normal file
BIN
public/tipi-christmas.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
|
@ -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={{
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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" />
|
||||
) : (
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
38
src/lib/themes.ts
Normal 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';
|
||||
}
|
||||
};
|
|
@ -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() });
|
||||
|
|
Loading…
Reference in a new issue