mirror of
https://github.com/diced/zipline.git
synced 2025-05-11 10:26:05 +02:00
feat: themes
This commit is contained in:
parent
453a66b1b4
commit
7093c5f11e
40 changed files with 497 additions and 129 deletions
|
@ -286,7 +286,6 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
|||
<Menu.Target>
|
||||
<Button
|
||||
variant='subtle'
|
||||
color='gray'
|
||||
leftIcon={
|
||||
avatar ? (
|
||||
<Avatar src={avatar} radius='sm' size='sm' alt={user?.username ?? 'User avatar'} />
|
||||
|
@ -340,7 +339,11 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
|||
}
|
||||
>
|
||||
<ConfigProvider config={config}>
|
||||
<Paper m={2} withBorder p={'xs'}>
|
||||
<Paper
|
||||
m={2}
|
||||
withBorder
|
||||
p='xs'
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
</ConfigProvider>
|
||||
|
|
54
src/components/ThemeProvider.tsx
Normal file
54
src/components/ThemeProvider.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { ZiplineTheme, themeComponents } from '@/lib/theme';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
import { createContext, useContext, useEffect } from 'react';
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
themes: ZiplineTheme[];
|
||||
}>({
|
||||
themes: [],
|
||||
});
|
||||
|
||||
export function useThemes() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useThemes must be used within a ThemeProvider');
|
||||
|
||||
return ctx.themes;
|
||||
}
|
||||
|
||||
export default function Theming({ themes, children }: { themes: ZiplineTheme[]; children: React.ReactNode }) {
|
||||
const [currentTheme, preferredDark, preferredLight] = useSettingsStore((state) => [
|
||||
state.settings.theme,
|
||||
state.settings.themeDark,
|
||||
state.settings.themeLight,
|
||||
]);
|
||||
const systemTheme = useColorScheme();
|
||||
|
||||
let theme = themes.find((theme) => theme.id === currentTheme);
|
||||
if (currentTheme === 'system') {
|
||||
theme =
|
||||
systemTheme === 'dark'
|
||||
? themes.find((theme) => theme.id === preferredDark)
|
||||
: themes.find((theme) => theme.id === preferredLight);
|
||||
}
|
||||
|
||||
if (!theme) {
|
||||
theme =
|
||||
themes.find((theme) => theme.id === 'builtin:dark_gray') ??
|
||||
({
|
||||
id: 'builtin:dark_gray',
|
||||
name: 'Dark Gray',
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'gray',
|
||||
} as ZiplineTheme); // back up theme if all else fails lol
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ themes }}>
|
||||
<MantineProvider withGlobalStyles withNormalizeCSS withCSSVariables theme={themeComponents(theme)}>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
|
@ -61,7 +61,7 @@ export default function DashboardHome() {
|
|||
<Stat Icon={IconDeviceSdCard} title='Storage used' value={bytes(stats!.storageUsed)} />
|
||||
<Stat Icon={IconDeviceSdCard} title='Average storage used' value={bytes(stats!.avgStorageUsed)} />
|
||||
<Stat Icon={IconEyeFilled} title='File views' value={stats!.views} />
|
||||
<Stat Icon={IconEyeFilled} title='File average views' value={stats!.avgViews} />
|
||||
<Stat Icon={IconEyeFilled} title='File average views' value={Math.round(stats!.avgViews)} />
|
||||
|
||||
<Stat Icon={IconLink} title='Links created' value={stats!.urlsCreated} />
|
||||
</SimpleGrid>
|
||||
|
|
|
@ -78,7 +78,6 @@ export default function FavoriteFiles() {
|
|||
</Group>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='gray'
|
||||
compact
|
||||
leftIcon={<IconFileUpload size='1rem' />}
|
||||
component={Link}
|
||||
|
|
|
@ -211,7 +211,6 @@ export default function FileTable({ id }: { id?: string }) {
|
|||
|
||||
<Button
|
||||
variant='outline'
|
||||
color='gray'
|
||||
onClick={() => {
|
||||
setSelectedFiles([]);
|
||||
}}
|
||||
|
@ -295,7 +294,7 @@ export default function FileTable({ id }: { id?: string }) {
|
|||
render: (file) => (
|
||||
<Group spacing='sm'>
|
||||
<Tooltip label='More details'>
|
||||
<ActionIcon variant='outline' color='gray'>
|
||||
<ActionIcon variant='outline'>
|
||||
<IconFile size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
@ -303,7 +302,6 @@ export default function FileTable({ id }: { id?: string }) {
|
|||
<Tooltip label='View file in new tab'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
color='gray'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
viewFile(file);
|
||||
|
@ -316,7 +314,6 @@ export default function FileTable({ id }: { id?: string }) {
|
|||
<Tooltip label='Copy file link to clipboard'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
color='gray'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFile(file, clipboard);
|
||||
|
|
|
@ -68,7 +68,6 @@ export default function Files({ id }: { id?: string }) {
|
|||
</Group>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='gray'
|
||||
compact
|
||||
leftIcon={<IconFileUpload size='1rem' />}
|
||||
component={Link}
|
||||
|
|
|
@ -78,7 +78,6 @@ export default function FavoriteFiles() {
|
|||
</Group>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='gray'
|
||||
compact
|
||||
leftIcon={<IconFileUpload size='1rem' />}
|
||||
component={Link}
|
||||
|
|
|
@ -74,13 +74,7 @@ export default function DashboardFolders() {
|
|||
{...form.getInputProps('isPublic', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
variant='outline'
|
||||
color='gray'
|
||||
radius='sm'
|
||||
leftIcon={<IconFolderPlus size='1rem' />}
|
||||
>
|
||||
<Button type='submit' variant='outline' radius='sm' leftIcon={<IconFolderPlus size='1rem' />}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
|
@ -91,7 +85,7 @@ export default function DashboardFolders() {
|
|||
<Title>Folders</Title>
|
||||
|
||||
<Tooltip label='Create a new folder'>
|
||||
<ActionIcon variant='outline' color='gray' onClick={() => setOpen(true)}>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
|
|
@ -86,7 +86,6 @@ export default function FolderTableView() {
|
|||
<Tooltip label='View files'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
color='gray'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFolder(folder);
|
||||
|
@ -98,7 +97,6 @@ export default function FolderTableView() {
|
|||
<Tooltip label='Copy folder link'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
color='gray'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFolderUrl(folder, clipboard);
|
||||
|
|
|
@ -5,7 +5,7 @@ import { readToDataURL } from '@/lib/base64';
|
|||
import { useUserStore } from '@/lib/store/user';
|
||||
import { Avatar, Button, Card, FileInput, Group, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconChevronDown, IconPhoto, IconPhotoCancel, IconSettingsFilled } from '@tabler/icons-react';
|
||||
import { IconChevronDown, IconPhoto, IconPhotoCancel, IconPhotoUp, IconSettingsFilled } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
@ -94,6 +94,7 @@ export default function SettingsAvatar() {
|
|||
placeholder='Upload new avatar...'
|
||||
value={avatar}
|
||||
onChange={(file) => setAvatar(file)}
|
||||
icon={<IconPhotoUp size='1rem' />}
|
||||
/>
|
||||
|
||||
<Card withBorder shadow='sm'>
|
||||
|
@ -103,7 +104,6 @@ export default function SettingsAvatar() {
|
|||
|
||||
<Button
|
||||
variant='subtle'
|
||||
color='gray'
|
||||
leftIcon={
|
||||
avatarSrc ? (
|
||||
<Avatar src={avatarSrc} radius='sm' size='sm' alt={user?.username ?? 'Proposed avatar'} />
|
||||
|
@ -138,7 +138,7 @@ export default function SettingsAvatar() {
|
|||
Remove Avatar
|
||||
</Button>
|
||||
)}
|
||||
<Button variant='outline' color='gray' disabled={!avatar} onClick={saveAvatar}>
|
||||
<Button variant='outline' disabled={!avatar} onClick={saveAvatar}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
|
|
|
@ -1,8 +1,52 @@
|
|||
import { useThemes } from '@/components/ThemeProvider';
|
||||
import { mergeTheme } from '@/lib/mergeTheme';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { NumberInput, Paper, Stack, Switch, Text, Title } from '@mantine/core';
|
||||
import { ZiplineTheme } from '@/lib/theme';
|
||||
import {
|
||||
ColorSwatch,
|
||||
DEFAULT_THEME,
|
||||
Group,
|
||||
MantineProvider,
|
||||
MantineThemeOverride,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconFile,
|
||||
IconMoonFilled,
|
||||
IconPaintFilled,
|
||||
IconPercentage,
|
||||
IconSunFilled,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
function ThemeSelectItem({ value, label, ...others }: { value: string; label: string }) {
|
||||
const themes = useThemes();
|
||||
const theme: ZiplineTheme | undefined = themes.find((theme) => theme.id === value);
|
||||
|
||||
const mergedTheme = mergeTheme(DEFAULT_THEME, theme as MantineThemeOverride);
|
||||
|
||||
return (
|
||||
<Group {...others}>
|
||||
<div>{label}</div>
|
||||
{value !== 'system' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{mergedTheme.colors[mergedTheme?.primaryColor!]?.map((color) => (
|
||||
<ColorSwatch key={color} color={color} size={18} style={{ marginRight: '0.5rem' }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsDashboard() {
|
||||
const [settings, update] = useSettingsStore((state) => [state.settings, state.update]);
|
||||
const themes = useThemes();
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
|
@ -12,18 +56,20 @@ export default function SettingsDashboard() {
|
|||
</Text>
|
||||
|
||||
<Stack spacing='sm' my='xs'>
|
||||
<Switch
|
||||
label='Disable Media Preview'
|
||||
description='Disable previews of files in the dashboard. This is useful to save data as Zipline, by default, will load previews of files.'
|
||||
checked={settings.disableMediaPreview}
|
||||
onChange={(event) => update('disableMediaPreview', event.currentTarget.checked)}
|
||||
/>
|
||||
<Switch
|
||||
label='Warn on deletion'
|
||||
description='Show a warning when deleting files. This is useful to prevent accidental deletion of files.'
|
||||
checked={settings.warnDeletion}
|
||||
onChange={(event) => update('warnDeletion', event.currentTarget.checked)}
|
||||
/>
|
||||
<Group grow>
|
||||
<Switch
|
||||
label='Disable Media Preview'
|
||||
description='Disable previews of files in the dashboard. This is useful to save data as Zipline, by default, will load previews of files.'
|
||||
checked={settings.disableMediaPreview}
|
||||
onChange={(event) => update('disableMediaPreview', event.currentTarget.checked)}
|
||||
/>
|
||||
<Switch
|
||||
label='Warn on deletion'
|
||||
description='Show a warning when deleting files. This is useful to prevent accidental deletion of files.'
|
||||
checked={settings.warnDeletion}
|
||||
onChange={(event) => update('warnDeletion', event.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<NumberInput
|
||||
label='Search Treshold'
|
||||
|
@ -34,7 +80,51 @@ export default function SettingsDashboard() {
|
|||
onChange={(value) => update('searchTreshold', value === '' ? 0 : value)}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
icon={<IconPercentage size='1rem' />}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Theme'
|
||||
description='The theme to use for the dashboard. This is only a visual change on your browser and does not change the theme for other users.'
|
||||
data={[
|
||||
{ value: 'system', label: 'System' },
|
||||
...themes.map((theme) => ({ value: theme.id, label: theme.name })),
|
||||
]}
|
||||
value={settings.theme}
|
||||
onChange={(value) => update('theme', value ?? 'builtin:dark_gray')}
|
||||
itemComponent={ThemeSelectItem}
|
||||
icon={<IconPaintFilled size='1rem' />}
|
||||
/>
|
||||
|
||||
{settings.theme === 'system' && (
|
||||
<Group grow>
|
||||
<Select
|
||||
label='Dark Theme'
|
||||
description='The theme to use for the dashboard when your system is in dark mode.'
|
||||
data={themes
|
||||
.filter((theme) => theme.colorScheme === 'dark')
|
||||
.map((theme) => ({ value: theme.id, label: theme.name }))}
|
||||
value={settings.themeDark}
|
||||
onChange={(value) => update('themeDark', value ?? 'builtin:dark_gray')}
|
||||
disabled={settings.theme !== 'system'}
|
||||
itemComponent={ThemeSelectItem}
|
||||
icon={<IconMoonFilled size='1rem' />}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Light Theme'
|
||||
description='The theme to use for the dashboard when your system is in light mode.'
|
||||
data={themes
|
||||
.filter((theme) => theme.colorScheme === 'light')
|
||||
.map((theme) => ({ value: theme.id, label: theme.name }))}
|
||||
value={settings.themeLight}
|
||||
onChange={(value) => update('themeLight', value ?? 'builtin:light_gray')}
|
||||
disabled={settings.theme !== 'system'}
|
||||
itemComponent={ThemeSelectItem}
|
||||
icon={<IconSunFilled size='1rem' />}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
|
|
@ -162,7 +162,7 @@ export default function SettingsFileView() {
|
|||
</SimpleGrid>
|
||||
|
||||
<Group position='left' mt='sm'>
|
||||
<Button variant='outline' color='gray' type='submit'>
|
||||
<Button variant='outline' type='submit'>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { hasLength, useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck, IconCopy, IconUserCancel } from '@tabler/icons-react';
|
||||
import { IconAsteriskSimple, IconCheck, IconCopy, IconUser, IconUserCancel } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
|
@ -103,19 +103,21 @@ export default function SettingsUser() {
|
|||
component='span'
|
||||
label='Token'
|
||||
onClick={() => setTokenShown(true)}
|
||||
icon={<IconAsteriskSimple size='1rem' />}
|
||||
>
|
||||
<ScrollArea scrollbarSize={5}>{tokenShown ? token : '[click to reveal]'}</ScrollArea>
|
||||
</TextInput>
|
||||
|
||||
<TextInput label='Username' {...form.getInputProps('username')} />
|
||||
<TextInput label='Username' {...form.getInputProps('username')} icon={<IconUser size='1rem' />} />
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='Leave blank to keep the same password'
|
||||
{...form.getInputProps('password')}
|
||||
icon={<IconAsteriskSimple size='1rem' />}
|
||||
/>
|
||||
|
||||
<Group position='left' mt='sm'>
|
||||
<Button variant='outline' color='gray' type='submit'>
|
||||
<Button variant='outline' type='submit'>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
|
|
|
@ -51,8 +51,7 @@ export default function UploadFile() {
|
|||
message: (
|
||||
<>
|
||||
The upload may fail because the total size of the files you are trying to upload is{' '}
|
||||
<b>{bytes(size)}</b>, which is larger than the limit of{' '}
|
||||
<b>{bytes(config.files.maxFileSize)}</b>
|
||||
<b>{bytes(size)}</b>, which is larger than the limit of <b>{bytes(config.files.maxFileSize)}</b>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
@ -75,7 +74,7 @@ export default function UploadFile() {
|
|||
<Title order={1}>Upload files</Title>
|
||||
|
||||
<Tooltip label='View your files'>
|
||||
<ActionIcon component={Link} href='/dashboard/files' variant='outline' color='gray' radius='sm'>
|
||||
<ActionIcon component={Link} href='/dashboard/files' variant='outline' radius='sm'>
|
||||
<IconFiles size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
@ -132,7 +131,6 @@ export default function UploadFile() {
|
|||
|
||||
<Button
|
||||
variant='outline'
|
||||
color='gray'
|
||||
leftIcon={<IconUpload size={18} />}
|
||||
disabled={files.length === 0 || dropLoading}
|
||||
onClick={upload}
|
||||
|
|
|
@ -74,7 +74,7 @@ export default function UploadText({
|
|||
<Title order={1}>Upload text</Title>
|
||||
|
||||
<Tooltip label='View your files'>
|
||||
<ActionIcon component={Link} href='/dashboard/files' variant='outline' color='gray' radius='sm'>
|
||||
<ActionIcon component={Link} href='/dashboard/files' variant='outline' radius='sm'>
|
||||
<IconFiles size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
@ -129,7 +129,6 @@ export default function UploadText({
|
|||
<UploadOptionsButton numFiles={1} />
|
||||
<Button
|
||||
variant='outline'
|
||||
color='gray'
|
||||
leftIcon={<IconUpload size='1rem' />}
|
||||
disabled={text.length === 0 || loading}
|
||||
onClick={upload}
|
||||
|
|
|
@ -265,7 +265,6 @@ export default function UploadOptionsButton({ numFiles }: { numFiles: number })
|
|||
|
||||
<Button
|
||||
variant='outline'
|
||||
color='gray'
|
||||
leftIcon={<IconArrowsMinimize size='1rem' />}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
|
@ -276,15 +275,8 @@ export default function UploadOptionsButton({ numFiles }: { numFiles: number })
|
|||
|
||||
<Button
|
||||
variant={changes() !== 0 ? 'filled' : 'outline'}
|
||||
rightIcon={
|
||||
changes() !== 0 ? (
|
||||
<Badge variant='outline' color='gray'>
|
||||
{changes()}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
rightIcon={changes() !== 0 ? <Badge variant='outline'>{changes()}</Badge> : null}
|
||||
onClick={() => setOpen(true)}
|
||||
color='gray'
|
||||
>
|
||||
Options
|
||||
</Button>
|
||||
|
|
|
@ -139,13 +139,7 @@ export default function DashboardURLs() {
|
|||
{...form.getInputProps('maxViews')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
variant='outline'
|
||||
color='gray'
|
||||
radius='sm'
|
||||
leftIcon={<IconLink size='1rem' />}
|
||||
>
|
||||
<Button type='submit' variant='outline' radius='sm' leftIcon={<IconLink size='1rem' />}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
|
@ -156,7 +150,7 @@ export default function DashboardURLs() {
|
|||
<Title>URLs</Title>
|
||||
|
||||
<Tooltip label='Shorten a URL'>
|
||||
<ActionIcon variant='outline' color='gray' onClick={() => setOpen(true)}>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconLink size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
|
|
@ -90,7 +90,6 @@ export default function UrlTableView() {
|
|||
<Tooltip label='Copy URL'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
color='gray'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyUrl(url, config, clipboard);
|
||||
|
|
|
@ -118,7 +118,6 @@ export default function EditUserModal({
|
|||
<Tooltip label='Clear avatar'>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
color='gray'
|
||||
disabled={!form.values.avatar}
|
||||
onClick={() => form.setFieldValue('avatar', null)}
|
||||
>
|
||||
|
|
|
@ -17,7 +17,7 @@ export default function ViewFiles({ user }: { user: User }) {
|
|||
<Group>
|
||||
<Title>{user.username}'s files</Title>
|
||||
<Tooltip label='Back to users'>
|
||||
<ActionIcon variant='outline' color='gray' component={Link} href='/dashboard/admin/users'>
|
||||
<ActionIcon variant='outline' component={Link} href='/dashboard/admin/users'>
|
||||
<IconArrowBackUp size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
|
|
@ -116,7 +116,6 @@ export default function DashboardUsers() {
|
|||
<Tooltip label='Clear avatar'>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
color='gray'
|
||||
disabled={!form.values.avatar}
|
||||
onClick={() => form.setFieldValue('avatar', null)}
|
||||
>
|
||||
|
@ -141,13 +140,7 @@ export default function DashboardUsers() {
|
|||
{...form.getInputProps('role')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
variant='outline'
|
||||
color='gray'
|
||||
radius='sm'
|
||||
leftIcon={<IconUserPlus size='1rem' />}
|
||||
>
|
||||
<Button type='submit' variant='outline' radius='sm' leftIcon={<IconUserPlus size='1rem' />}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
|
@ -158,7 +151,7 @@ export default function DashboardUsers() {
|
|||
<Title>Users</Title>
|
||||
|
||||
<Tooltip label='Create a new user'>
|
||||
<ActionIcon variant='outline' color='gray' onClick={() => setOpen(true)}>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconUserPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
|
|
@ -44,11 +44,7 @@ export default function UserTableView() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<EditUserModal
|
||||
opened={!!selectedUser}
|
||||
onClose={() => setSelectedUser(null)}
|
||||
user={selectedUser}
|
||||
/>
|
||||
<EditUserModal opened={!!selectedUser} onClose={() => setSelectedUser(null)} user={selectedUser} />
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
|
@ -87,7 +83,6 @@ export default function UserTableView() {
|
|||
<Tooltip label="View user's files">
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
color='gray'
|
||||
component={Link}
|
||||
href={`/dashboard/admin/users/${user.id}/files`}
|
||||
disabled={!canInteract(currentUser?.role, user?.role)}
|
||||
|
@ -99,7 +94,6 @@ export default function UserTableView() {
|
|||
<Tooltip label='Edit user'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
color='gray'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedUser(user);
|
||||
|
|
|
@ -16,17 +16,9 @@ export function RenderAlert({
|
|||
change: (s: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Alert color='gray' icon={<IconEyeFilled size='1rem' />} variant='outline' mb='sm'>
|
||||
<Alert icon={<IconEyeFilled size='1rem' />} variant='outline' mb='sm'>
|
||||
{!state ? `This file is rendered through ${renderer}` : `This file can be rendered through ${renderer}`}
|
||||
<Button
|
||||
mx='sm'
|
||||
variant='outline'
|
||||
compact
|
||||
onClick={() => change(!state)}
|
||||
color='gray'
|
||||
pos='absolute'
|
||||
right={0}
|
||||
>
|
||||
<Button mx='sm' variant='outline' compact onClick={() => change(!state)} pos='absolute' right={0}>
|
||||
{state ? 'Show' : 'Hide'} rendered version
|
||||
</Button>
|
||||
</Alert>
|
||||
|
|
|
@ -7,7 +7,7 @@ export type SafeConfig = Omit<Config, 'oauth' | 'datasource' | 'core'> & {
|
|||
oauth: {
|
||||
bypassLocalLogin: boolean;
|
||||
loginOnly: boolean;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function safeConfig(): SafeConfig {
|
||||
|
|
11
src/lib/fs.ts
Normal file
11
src/lib/fs.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { PathLike } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
|
||||
export async function exists(path: PathLike): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
72
src/lib/mergeTheme.ts
Normal file
72
src/lib/mergeTheme.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
// @ts-nocheck
|
||||
// from mantine https://github.com/mantinedev/mantine/blob/master/src/mantine-styles/src/theme/utils/merge-theme/merge-theme.ts
|
||||
// was not included in the dist so we are using it here instead
|
||||
|
||||
import { MantineThemeBase, MantineThemeOverride, getBreakpointValue } from '@mantine/core';
|
||||
|
||||
export function mergeTheme(
|
||||
currentTheme: MantineThemeBase,
|
||||
themeOverride?: MantineThemeOverride
|
||||
): MantineThemeBase {
|
||||
if (!themeOverride) {
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
const result: MantineThemeBase = Object.keys(currentTheme).reduce((acc, key) => {
|
||||
if (key === 'headings' && themeOverride.headings) {
|
||||
const sizes = themeOverride.headings.sizes
|
||||
? Object.keys(currentTheme.headings.sizes).reduce((headingsAcc, h) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
headingsAcc[h] = {
|
||||
...currentTheme.headings.sizes[h],
|
||||
...themeOverride.headings.sizes[h],
|
||||
};
|
||||
return headingsAcc;
|
||||
}, {} as MantineThemeBase['headings']['sizes'])
|
||||
: currentTheme.headings.sizes;
|
||||
return {
|
||||
...acc,
|
||||
headings: {
|
||||
...currentTheme.headings,
|
||||
...themeOverride.headings,
|
||||
sizes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (key === 'breakpoints' && themeOverride.breakpoints) {
|
||||
const mergedBreakpoints = { ...currentTheme.breakpoints, ...themeOverride.breakpoints };
|
||||
|
||||
return {
|
||||
...acc,
|
||||
breakpoints: Object.fromEntries(
|
||||
Object.entries(mergedBreakpoints).sort(
|
||||
(a, b) => getBreakpointValue(a[1]) - getBreakpointValue(b[1])
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
acc[key] =
|
||||
typeof themeOverride[key] === 'object'
|
||||
? { ...currentTheme[key], ...themeOverride[key] }
|
||||
: typeof themeOverride[key] === 'number' ||
|
||||
typeof themeOverride[key] === 'boolean' ||
|
||||
typeof themeOverride[key] === 'function'
|
||||
? themeOverride[key]
|
||||
: themeOverride[key] || currentTheme[key];
|
||||
return acc;
|
||||
}, {} as MantineThemeBase);
|
||||
|
||||
if (themeOverride?.fontFamily && !themeOverride?.headings?.fontFamily) {
|
||||
result.headings.fontFamily = themeOverride.fontFamily as string;
|
||||
}
|
||||
|
||||
if (!(result.primaryColor in result.colors)) {
|
||||
throw new Error(
|
||||
'MantineProvider: Invalid theme.primaryColor, it accepts only key of theme.colors, learn more – https://mantine.dev/theming/colors/#primary-color'
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import { SafeConfig, safeConfig } from '@/lib/config/safe';
|
||||
import { ZiplineTheme } from '@/lib/theme';
|
||||
import { readThemes } from '@/lib/theme/file';
|
||||
import { GetServerSideProps, GetServerSidePropsContext } from 'next';
|
||||
|
||||
export function withSafeConfig<T = {}>(
|
||||
|
@ -6,11 +8,13 @@ export function withSafeConfig<T = {}>(
|
|||
): GetServerSideProps<
|
||||
T & {
|
||||
config: SafeConfig;
|
||||
themes: ZiplineTheme[];
|
||||
notFound?: boolean;
|
||||
}
|
||||
> {
|
||||
return async (ctx) => {
|
||||
const config = safeConfig();
|
||||
|
||||
const data = await fn(ctx);
|
||||
|
||||
if ((data as any) && (data as any).notFound)
|
||||
|
@ -26,6 +30,7 @@ export function withSafeConfig<T = {}>(
|
|||
return {
|
||||
props: {
|
||||
config,
|
||||
themes: await readThemes(),
|
||||
...data,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -6,6 +6,9 @@ export type SettingsStore = {
|
|||
disableMediaPreview: boolean;
|
||||
warnDeletion: boolean;
|
||||
searchTreshold: number;
|
||||
theme: string;
|
||||
themeDark: string;
|
||||
themeLight: string;
|
||||
};
|
||||
|
||||
update: <K extends keyof SettingsStore['settings']>(key: K, value: SettingsStore['settings'][K]) => void;
|
||||
|
@ -18,6 +21,9 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
disableMediaPreview: false,
|
||||
warnDeletion: true,
|
||||
searchTreshold: 0.1,
|
||||
theme: 'builtin:dark_gray',
|
||||
themeDark: 'builtin:dark_gray',
|
||||
themeLight: 'builtin:light_gray',
|
||||
},
|
||||
|
||||
update: (key, value) =>
|
||||
|
|
6
src/lib/theme/builtins/dark_gray.theme.json
Normal file
6
src/lib/theme/builtins/dark_gray.theme.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "Dark Gray",
|
||||
"id": "builtin:dark_gray",
|
||||
"colorScheme": "dark",
|
||||
"primaryColor": "gray"
|
||||
}
|
6
src/lib/theme/builtins/light_gray.theme.json
Normal file
6
src/lib/theme/builtins/light_gray.theme.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "Light Gray",
|
||||
"id": "builtin:light_gray",
|
||||
"colorScheme": "light",
|
||||
"primaryColor": "gray"
|
||||
}
|
64
src/lib/theme/file.ts
Normal file
64
src/lib/theme/file.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { readFile, readdir } from 'fs/promises';
|
||||
import { basename, join } from 'path';
|
||||
import { ZiplineTheme } from '.';
|
||||
import { exists } from '../fs';
|
||||
|
||||
import dark_gray from './builtins/dark_gray.theme.json';
|
||||
import light_gray from './builtins/light_gray.theme.json';
|
||||
|
||||
const THEMES_DIR = './themes';
|
||||
|
||||
export async function readThemes(includeBuiltins: boolean = true): Promise<ZiplineTheme[]> {
|
||||
const themes = await readThemesDir();
|
||||
const parsedThemes = await parseThemes(themes);
|
||||
|
||||
for (let i = 0; i !== parsedThemes.length; ++i) {
|
||||
parsedThemes[i] = await handleOverrideColors(parsedThemes[i]);
|
||||
}
|
||||
|
||||
if (includeBuiltins) {
|
||||
parsedThemes.push(
|
||||
await handleOverrideColors(dark_gray as ZiplineTheme),
|
||||
await handleOverrideColors(light_gray as ZiplineTheme)
|
||||
);
|
||||
}
|
||||
|
||||
return parsedThemes;
|
||||
}
|
||||
|
||||
export async function readThemesDir(): Promise<string[]> {
|
||||
const absDir = join(process.cwd(), THEMES_DIR);
|
||||
if (!(await exists(absDir))) return [];
|
||||
|
||||
const files = await readdir(absDir);
|
||||
const themes = files.filter((file) => file.endsWith('.theme.json')).map((file) => join(absDir, file));
|
||||
|
||||
return themes;
|
||||
}
|
||||
|
||||
export async function parseThemes(themes: string[]): Promise<ZiplineTheme[]> {
|
||||
const parsedThemes = [];
|
||||
|
||||
for (const theme of themes) {
|
||||
const themeData: any = await readFile(theme, 'utf-8');
|
||||
const themeS = JSON.parse(themeData);
|
||||
themeS.id = basename(theme, '.theme.json');
|
||||
|
||||
parsedThemes.push(themeS);
|
||||
}
|
||||
|
||||
return parsedThemes;
|
||||
}
|
||||
|
||||
export async function handleOverrideColors(theme: ZiplineTheme) {
|
||||
return {
|
||||
...theme,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
google: theme.colors?.google || ['#4285F4'],
|
||||
github: theme.colors?.github || ['#24292E'],
|
||||
authentik: theme.colors?.authentik || ['#FD4B2D'],
|
||||
discord: theme.colors?.discord || ['#5865F2'],
|
||||
},
|
||||
} as ZiplineTheme;
|
||||
}
|
46
src/lib/theme/index.ts
Normal file
46
src/lib/theme/index.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { MantineTheme, MantineThemeOverride, rem } from '@mantine/core';
|
||||
|
||||
export type ZiplineTheme = MantineTheme & { id: string; name: string };
|
||||
|
||||
export function themeComponents(theme: ZiplineTheme): MantineThemeOverride {
|
||||
return {
|
||||
...theme,
|
||||
components: {
|
||||
Paper: {
|
||||
styles: (theme) => ({
|
||||
root: {
|
||||
'&[data-with-border]': {
|
||||
border: `${rem(1)} solid ${
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[3]
|
||||
}`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
AppShell: {
|
||||
styles: (theme) => ({
|
||||
main: {
|
||||
background: `${
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[0]
|
||||
} !important`,
|
||||
},
|
||||
}),
|
||||
},
|
||||
LoadingOverlay: {
|
||||
defaultProps: {
|
||||
// overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
overlayOpacity: 0.3,
|
||||
},
|
||||
},
|
||||
Modal: {
|
||||
defaultProps: {
|
||||
closeButtonProps: { size: 'lg' },
|
||||
centered: true,
|
||||
overlayProps: {
|
||||
blur: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -5,6 +5,8 @@ import { SWRConfig } from 'swr';
|
|||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import '@/components/render/code/HighlightCode.css';
|
||||
import { ZiplineTheme, themeComponents } from '@/lib/theme';
|
||||
import Theming from '@/components/ThemeProvider';
|
||||
|
||||
const fetcher = async (url: RequestInfo | URL) => {
|
||||
const res = await fetch(url);
|
||||
|
@ -18,8 +20,8 @@ const fetcher = async (url: RequestInfo | URL) => {
|
|||
return res.json();
|
||||
};
|
||||
|
||||
export default function App(props: AppProps) {
|
||||
const { Component, pageProps } = props;
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
const themes: ZiplineTheme[] = pageProps.themes;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -33,20 +35,7 @@ export default function App(props: AppProps) {
|
|||
fetcher,
|
||||
}}
|
||||
>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
withCSSVariables
|
||||
theme={{
|
||||
colorScheme: 'dark',
|
||||
colors: {
|
||||
discord: ['#5865F2'],
|
||||
google: ['#4285F4'],
|
||||
authentik: ['#FD4B2D'],
|
||||
github: ['#24292E'],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Theming themes={themes}>
|
||||
<ModalsProvider
|
||||
modalProps={{
|
||||
overlayProps: {
|
||||
|
@ -59,7 +48,7 @@ export default function App(props: AppProps) {
|
|||
<Notifications />
|
||||
<Component {...pageProps} />
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</Theming>
|
||||
</SWRConfig>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -150,7 +150,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
|||
type='submit'
|
||||
loading={isLoading}
|
||||
variant={config.website.loginBackground ? 'outline' : 'filled'}
|
||||
color='gray'
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
|
@ -167,7 +166,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
|||
)}
|
||||
|
||||
{config.features.userRegistration && (
|
||||
<Button size='lg' fullWidth variant='outline' color='gray'>
|
||||
<Button size='lg' fullWidth variant='outline'>
|
||||
Sign up
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
@ -1,21 +1,9 @@
|
|||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { Group, LoadingOverlay, Text } from '@mantine/core';
|
||||
import { Icon as TIcon } from '@tabler/icons-react';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
function IconText({ Icon, text }: { Icon: TIcon; text: string }) {
|
||||
return (
|
||||
<Group spacing='xs' align='center'>
|
||||
<Icon />
|
||||
<Text>{text}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const [setUser, setToken] = useUserStore((state) => [state.setUser, state.setToken]);
|
||||
|
|
|
@ -8,6 +8,8 @@ import { fileSelect, type File } from '@/lib/db/models/file';
|
|||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { parseString } from '@/lib/parser';
|
||||
import { ZiplineTheme } from '@/lib/theme';
|
||||
import { readThemes } from '@/lib/theme/file';
|
||||
import { formatRootUrl } from '@/lib/url';
|
||||
import {
|
||||
Button,
|
||||
|
@ -174,7 +176,6 @@ export default function ViewFile({
|
|||
<Button
|
||||
fullWidth
|
||||
variant='outline'
|
||||
color='gray'
|
||||
my='sm'
|
||||
onClick={() => verifyPassword()}
|
||||
disabled={passwordValue.trim().length === 0}
|
||||
|
@ -189,7 +190,7 @@ export default function ViewFile({
|
|||
<Group position='apart' py={5} px='xs'>
|
||||
<Text color='dimmed'>{file.name}</Text>
|
||||
|
||||
<Button compact size='sm' variant='outline' color='gray' onClick={() => setDetailsOpen((o) => !o)}>
|
||||
<Button compact size='sm' variant='outline' onClick={() => setDetailsOpen((o) => !o)}>
|
||||
Toggle Details
|
||||
</Button>
|
||||
</Group>
|
||||
|
@ -247,7 +248,6 @@ export default function ViewFile({
|
|||
href={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
|
||||
target='_blank'
|
||||
compact
|
||||
color='gray'
|
||||
leftIcon={<IconFileDownload size='1rem' />}
|
||||
>
|
||||
Download
|
||||
|
@ -290,6 +290,7 @@ export const getServerSideProps: GetServerSideProps<{
|
|||
user?: Omit<User, 'oauthProviders'>;
|
||||
config?: SafeConfig;
|
||||
host: string;
|
||||
themes: ZiplineTheme[];
|
||||
}> = async (context) => {
|
||||
const { id, pw } = context.query;
|
||||
if (!id) return { notFound: true };
|
||||
|
@ -343,11 +344,13 @@ export const getServerSideProps: GetServerSideProps<{
|
|||
|
||||
const code = await isCode(file.name);
|
||||
|
||||
const themes = await readThemes();
|
||||
|
||||
if (pw) {
|
||||
const verified = await verifyPassword(pw as string, file.password!);
|
||||
|
||||
delete (file as any).password;
|
||||
if (verified) return { props: { file, pw: pw as string, code, host } };
|
||||
if (verified) return { props: { file, pw: pw as string, code, host, themes } };
|
||||
}
|
||||
|
||||
const password = !!file.password;
|
||||
|
@ -374,6 +377,7 @@ export const getServerSideProps: GetServerSideProps<{
|
|||
user,
|
||||
config,
|
||||
host,
|
||||
themes,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -16,6 +16,7 @@ import { urlsRoute } from './routes/urls';
|
|||
import { Scheduler } from '@/lib/scheduler';
|
||||
import deleteJob from '@/lib/scheduler/jobs/delete';
|
||||
import maxViewsJob from '@/lib/scheduler/jobs/maxViews';
|
||||
import { handleOverrideColors, parseThemes, readThemesDir } from '@/lib/theme/file';
|
||||
|
||||
const MODE = process.env.NODE_ENV || 'production';
|
||||
|
||||
|
@ -27,6 +28,13 @@ async function main() {
|
|||
|
||||
const server = express();
|
||||
|
||||
const themes = await readThemesDir();
|
||||
console.log('themes', themes)
|
||||
const parsedThemes = await parseThemes(themes);
|
||||
console.log('parsedThemes', parsedThemes)
|
||||
const overriden = await handleOverrideColors(parsedThemes[0]);
|
||||
console.log('overriden', overriden)
|
||||
|
||||
logger.info('reading environment for configuration');
|
||||
const config = validateEnv(readEnv());
|
||||
|
||||
|
|
19
themes/black_dark.theme..json
Normal file
19
themes/black_dark.theme..json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "Black Dark",
|
||||
"colorScheme": "dark",
|
||||
"colors": {
|
||||
"dark": [
|
||||
"#ffffff",
|
||||
"#A7A9AD",
|
||||
"#7B7E84",
|
||||
"#61646A",
|
||||
"#54575D",
|
||||
"#303236",
|
||||
"#282a2e",
|
||||
"#060606",
|
||||
"#141517",
|
||||
"#000000"
|
||||
]
|
||||
},
|
||||
"primaryColor": "gray"
|
||||
}
|
31
themes/dark_blue.theme.json
Normal file
31
themes/dark_blue.theme.json
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "Dark Blue",
|
||||
"colorScheme": "dark",
|
||||
"colors": {
|
||||
"blue": [
|
||||
"#FFFFFF",
|
||||
"#7C7DC2",
|
||||
"#7778C0",
|
||||
"#6C6FBC",
|
||||
"#575DB5",
|
||||
"#4D54B2",
|
||||
"#424BAE",
|
||||
"#3742AA",
|
||||
"#323EA8",
|
||||
"#2C39A6"
|
||||
],
|
||||
"dark": [
|
||||
"#FFFFFF",
|
||||
"#293747",
|
||||
"#6C7A8D",
|
||||
"#232F41",
|
||||
"#41566e",
|
||||
"#171F35",
|
||||
"#181c28",
|
||||
"#0c101c",
|
||||
"#060824",
|
||||
"#00001E"
|
||||
]
|
||||
},
|
||||
"primaryColor": "blue"
|
||||
}
|
19
themes/light_blue.theme.json
Normal file
19
themes/light_blue.theme.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "Light Blue",
|
||||
"colorScheme": "light",
|
||||
"colors": {
|
||||
"blue": [
|
||||
"#FFFFFF",
|
||||
"#7C7DC2",
|
||||
"#7778C0",
|
||||
"#6C6FBC",
|
||||
"#575DB5",
|
||||
"#4D54B2",
|
||||
"#424BAE",
|
||||
"#3742AA",
|
||||
"#323EA8",
|
||||
"#2C39A6"
|
||||
]
|
||||
},
|
||||
"primaryColor": "blue"
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue