feat: v3.2.0 - custom themes & curated themes

This commit is contained in:
diced 2021-08-26 12:32:51 -07:00
parent 47db6cf1bd
commit c5cef56e2a
No known key found for this signature in database
GPG key ID: 85AB64C74535D76E
17 changed files with 372 additions and 33 deletions

View file

@ -17,9 +17,8 @@ Fast & lightweight file uploading.
- Configurable
- Fast
- Built with Next.js & React
- Support for **multible database types**
- Token protected uploading
- Easy setup instructions on [docs](https://zipline.diced.me)
- Easy setup instructions on [docs](https://zipline.diced.me) (One command install `docker-compose up`)
# Installing

View file

@ -1,6 +1,6 @@
{
"name": "zip3",
"version": "3.1.0",
"version": "3.2.0",
"scripts": {
"prepare": "husky install",
"dev": "NODE_ENV=development node server",

View file

@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "systemTheme" TEXT NOT NULL DEFAULT E'dark_blue';
-- CreateTable
CREATE TABLE "Theme" (
"id" SERIAL NOT NULL,
"type" TEXT NOT NULL,
"primary" TEXT NOT NULL,
"secondary" TEXT NOT NULL,
"error" TEXT NOT NULL,
"warning" TEXT NOT NULL,
"info" TEXT NOT NULL,
"border" TEXT NOT NULL,
"mainBackground" TEXT NOT NULL,
"paperBackground" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Theme_userId_unique" ON "Theme"("userId");
-- AddForeignKey
ALTER TABLE "Theme" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -13,12 +13,29 @@ model User {
password String
token String
administrator Boolean @default(false)
systemTheme String @default("dark_blue")
customTheme Theme?
embedTitle String?
embedColor String @default("#2f3136")
images Image[]
urls Url[]
}
model Theme {
id Int @id @default(autoincrement())
type String
primary String
secondary String
error String
warning String
info String
border String
mainBackground String
paperBackground String
user User @relation(fields: [userId], references: [id])
userId Int
}
model Image {
id Int @id @default(autoincrement())
file String
@ -31,25 +48,25 @@ model Image {
}
model InvisibleImage {
id Int
image Image @relation(fields: [id], references: [id])
id Int
image Image @relation(fields: [id], references: [id])
invis String @unique
invis String @unique
}
model Url {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
to String
created_at DateTime @default(now())
views Int @default(0)
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleUrl?
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleUrl {
id Int
url Url @relation(fields: [id], references: [id])
id Int
url Url @relation(fields: [id], references: [id])
invis String @unique
invis String @unique
}

View file

@ -3,9 +3,8 @@ const prismaRun = require('./prisma-run');
module.exports = async (config) => {
try {
await prismaRun(config.database.url, ['migrate', 'deploy', '--schema=prisma/schema.prisma']);
await prismaRun(config.database.url, ['generate', '--schema=prisma/schema.prisma']);
await prismaRun(config.database.url, ['db', 'seed', '--preview-feature', '--schema=prisma/schema.prisma']);
await prismaRun(config.database.url, ['migrate', 'deploy']);
await prismaRun(config.database.url, ['generate']);
} catch (e) {
console.log(e);
Logger.get('db').error('there was an error.. exiting..');

View file

@ -23,6 +23,7 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
Select,
} from '@material-ui/core';
import {
Menu as MenuIcon,
@ -33,10 +34,15 @@ import {
ContentCopy as CopyIcon,
Autorenew as ResetIcon,
Logout as LogoutIcon,
PeopleAlt as UsersIcon
PeopleAlt as UsersIcon,
Brush as BrushIcon
} from '@material-ui/icons';
import copy from 'copy-to-clipboard';
import Backdrop from './Backdrop';
import { friendlyThemeName, themes } from './Theming';
import { useRouter } from 'next/router';
import { useStoreDispatch } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user';
const items = [
{
@ -122,11 +128,14 @@ function ResetTokenDialog({ open, setOpen, setToken }) {
}
export default function Layout({ children, user, loading, noPaper }) {
const [systemTheme, setSystemTheme] = useState(user.systemTheme || 'dark_blue');
const [mobileOpen, setMobileOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const [copyOpen, setCopyOpen] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const [token, setToken] = useState(user?.token);
const router = useRouter();
const dispatch = useStoreDispatch();
const open = Boolean(anchorEl);
const handleClick = e => setAnchorEl(e.currentTarget);
@ -142,6 +151,17 @@ export default function Layout({ children, user, loading, noPaper }) {
setAnchorEl(null);
};
const handleUpdateTheme = async (event: React.ChangeEvent<{ value: string }>) => {
const newUser = await useFetch('/api/user', 'PATCH', {
systemTheme: event.target.value || 'dark_blue'
});
setSystemTheme(newUser.systemTheme);
dispatch(updateUser(newUser));
router.replace(router.pathname);
};
const drawer = (
<div>
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} />
@ -221,6 +241,22 @@ export default function Layout({ children, user, loading, noPaper }) {
</MenuItem>
</a>
</Link>
<MenuItem>
<BrushIcon sx={{ mr: 2 }} />
<Select
variant='standard'
label='Theme'
value={systemTheme}
onChange={handleUpdateTheme}
fullWidth
>
{Object.keys(themes).map(t => (
<MenuItem value={t} key={t}>
{friendlyThemeName[t]}
</MenuItem>
))}
</Select>
</MenuItem>
</Menu>
</Box>
)}

View file

@ -0,0 +1,53 @@
import React from 'react';
import { ThemeProvider } from '@emotion/react';
import { CssBaseline } from '@material-ui/core';
import dark_blue from 'lib/themes/dark_blue';
import dark from 'lib/themes/dark';
import ayu_dark from 'lib/themes/ayu_dark';
import { useStoreSelector } from 'lib/redux/store';
import createTheme from 'lib/themes';
export const themes = {
'dark_blue': dark_blue,
'dark': dark,
'ayu_dark': ayu_dark
};
export const friendlyThemeName = {
'dark_blue': 'Dark Blue',
'dark': 'Very Dark',
'ayu_dark': 'Ayu Dark'
};
export default function ZiplineTheming({ Component, pageProps }) {
let t;
const user = useStoreSelector(state => state.user);
if (!user) t = themes.dark_blue;
else {
if (user.customTheme) {
t = createTheme({
type: 'dark',
primary: user.customTheme.primary,
secondary: user.customTheme.secondary,
error: user.customTheme.error,
warning: user.customTheme.warning,
info: user.customTheme.info,
border: user.customTheme.border,
background: {
main: user.customTheme.mainBackground,
paper: user.customTheme.paperBackground
}
});
} else {
t = themes[user.systemTheme] ?? themes.dark_blue;
}
}
return (
<ThemeProvider theme={t}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
);
}

View file

@ -24,6 +24,7 @@ type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
export function bytesToRead(bytes: number) {
if (isNaN(bytes)) return '0.0 B';
if (bytes === Infinity) return '0.0 B';
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
let num = 0;
@ -96,7 +97,7 @@ export default function Dashboard() {
const imgs = await useFetch('/api/user/images');
const stts = await useFetch('/api/stats');
setImages(imgs);
setStats(stts);
setStats(stts);console.log(stts);
setApiLoading(false);
};

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Typography } from '@material-ui/core';
import { TextField, Button, Box, Typography, Select, MenuItem } from '@material-ui/core';
import { useFormik } from 'formik';
import * as yup from 'yup';
@ -8,6 +8,7 @@ import Backdrop from 'components/Backdrop';
import Alert from 'components/Alert';
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user';
import { useRouter } from 'next/router';
const validationSchema = yup.object({
username: yup
@ -15,6 +16,45 @@ const validationSchema = yup.object({
.required('Username is required')
});
const themeValidationSchema = yup.object({
type: yup
.string()
.required('Type (dark, light) is required is required'),
primary: yup
.string()
.required('Primary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
secondary: yup
.string()
.required('Secondary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
error: yup
.string()
.required('Error color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
warning: yup
.string()
.required('Warning color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
info: yup
.string()
.required('Info color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
border: yup
.string()
.required('Border color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
mainBackground: yup
.string()
.required('Main Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
paperBackground: yup
.string()
.required('Paper Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
});
function TextInput({ id, label, formik, ...other }) {
return (
<TextField
@ -36,6 +76,7 @@ function TextInput({ id, label, formik, ...other }) {
export default function Manage() {
const user = useStoreSelector(state => state.user);
const dispatch = useStoreDispatch();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
@ -84,6 +125,41 @@ export default function Manage() {
}
});
const customThemeFormik = useFormik({
initialValues: {
type: user.customTheme?.type || 'dark',
primary: user.customTheme?.primary || '',
secondary: user.customTheme?.secondary || '',
error: user.customTheme?.error || '',
warning: user.customTheme?.warning || '',
info: user.customTheme?.info || '',
border: user.customTheme?.border || '',
mainBackground: user.customTheme?.mainBackground || '',
paperBackground: user.customTheme?.paperBackground || '',
},
validationSchema: themeValidationSchema,
onSubmit: async values => {
setLoading(true);
const newUser = await useFetch('/api/user', 'PATCH', { customTheme: values });
console.log(newUser);
if (newUser.error) {
setLoading(false);
setMessage('An error occured');
setSeverity('error');
setOpen(true);
} else {
dispatch(updateUser(newUser));
router.replace(router.pathname);
setLoading(false);
setMessage('Saved theme');
setSeverity('success');
setOpen(true);
}
}
});
return (
<>
<Backdrop open={loading}/>
@ -104,7 +180,42 @@ export default function Manage() {
<Button
variant='contained'
type='submit'
>Save</Button>
>Save User</Button>
</Box>
</form>
<Typography variant='h4' py={2}>Manage Theme</Typography>
<form onSubmit={customThemeFormik.handleSubmit}>
<Select
id='type'
name='type'
label='Type'
value={customThemeFormik.values['type']}
onChange={customThemeFormik.handleChange}
error={customThemeFormik.touched['type'] && Boolean(customThemeFormik.errors['type'])}
variant='standard'
fullWidth
>
<MenuItem value='dark'>Dark Theme</MenuItem>
<MenuItem value='light'>Light Theme</MenuItem>
</Select>
<TextInput id='primary' label='Primary Color' formik={customThemeFormik} />
<TextInput id='secondary' label='Secondary Color' formik={customThemeFormik} />
<TextInput id='error' label='Error Color' formik={customThemeFormik} />
<TextInput id='warning' label='Warning Color' formik={customThemeFormik} />
<TextInput id='info' label='Info Color' formik={customThemeFormik} />
<TextInput id='border' label='Border Color' formik={customThemeFormik} />
<TextInput id='mainBackground' label='Main Background' formik={customThemeFormik} />
<TextInput id='paperBackground' label='Paper Background' formik={customThemeFormik} />
<Box
display='flex'
justifyContent='right'
alignItems='right'
pt={2}
>
<Button
variant='contained'
type='submit'
>Save Theme</Button>
</Box>
</form>
</>

View file

@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { CookieSerializeOptions } from 'cookie';
import type { User } from '@prisma/client';
import type { Image, Theme, User } from '@prisma/client';
import { serialize } from 'cookie';
import { sign64, unsign64 } from '../util';
@ -17,7 +17,18 @@ export interface NextApiFile {
}
export type NextApiReq = NextApiRequest & {
user: () => Promise<User | null | void>;
user: () => Promise<{
username: string;
token: string;
embedTitle: string;
embedColor: string;
systemTheme: string;
customTheme?: Theme;
administrator: boolean;
id: number;
images: Image[];
password: string;
} | null | void>;
getCookie: (name: string) => string | null;
cleanCookie: (name: string) => void;
file?: NextApiFile;
@ -83,6 +94,18 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
const user = await prisma.user.findFirst({
where: {
id: Number(userId)
},
select: {
administrator: true,
embedColor: true,
embedTitle: true,
id: true,
images: true,
password: true,
systemTheme: true,
customTheme: true,
token: true,
username: true
}
});

View file

@ -1,3 +1,4 @@
import { Theme } from '@prisma/client';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface User {
@ -5,6 +6,8 @@ export interface User {
token: string;
embedTitle: string;
embedColor: string;
systemTheme: string;
customTheme?: Theme;
}
const initialState: User = null;

View file

@ -0,0 +1,17 @@
// https://github.com/AlphaNecron/
// https://github.com/ayu-theme/ayu-colors
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#E6B450',
secondary: '#FFEE99',
error: '#FF3333',
warning: '#F29668',
info: '#95E6CB',
border: '#0A0E14',
background: {
main: '#0D1016',
paper: '#0A0E14'
}
});

15
src/lib/themes/dark.ts Normal file
View file

@ -0,0 +1,15 @@
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#2c39a6',
secondary: '#7344e2',
error: '#ff4141',
warning: '#ff9800',
info: '#2f6fb9',
border: '#2b2b2b',
background: {
main: '#000000',
paper: '#060606'
}
});

View file

@ -2,9 +2,7 @@ import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import Head from 'next/head';
import { ThemeProvider } from '@material-ui/core/styles';
import CssBaseline from '@material-ui/core/CssBaseline';
import theme from 'lib/themes/dark_blue';
import Theming from 'components/Theming';
import { useStore } from 'lib/redux/store';
export default function MyApp({ Component, pageProps }) {
@ -22,10 +20,10 @@ export default function MyApp({ Component, pageProps }) {
<meta name='description' content='Zipline' />
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
</Head>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
<Theming
Component={Component}
pageProps={pageProps}
/>
</Provider>
);
}

View file

@ -2,11 +2,18 @@ import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import { checkPassword } from 'lib/util';
import Logger from 'lib/logger';
import prismaRun from '../../../../scripts/prisma-run';
import config from 'lib/config';
async function handler(req: NextApiReq, res: NextApiRes) {
if (req.method !== 'POST') return res.status(405).end();
const { username, password } = req.body as { username: string, password: string };
const users = await prisma.user.findMany();
if (users.length === 0) {
await prismaRun(config.database.url, ['db', 'seed', '--preview-feature']);
}
const user = await prisma.user.findFirst({
where: {
username

View file

@ -27,7 +27,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
} else {
const images = await prisma.image.findMany({
where: {
user
userId: user.id
},
select: {
created_at: true,

View file

@ -31,14 +31,49 @@ async function handler(req: NextApiReq, res: NextApiRes) {
data: { embedColor: req.body.embedColor }
});
if (req.body.systemTheme) await prisma.user.update({
where: { id: user.id },
data: { systemTheme: req.body.systemTheme }
});
if (req.body.customTheme) {
if (user.customTheme) await prisma.user.update({
where: { id: user.id },
data: {
customTheme: {
update: {
...req.body.customTheme
}
}
}
}); else await prisma.theme.create({
data: {
userId: user.id,
...req.body.customTheme
}
});
}
const newUser = await prisma.user.findFirst({
where: { id: user.id }
where: {
id: Number(user.id)
},
select: {
administrator: true,
embedColor: true,
embedTitle: true,
id: true,
images: false,
password: false,
systemTheme: true,
customTheme: true,
token: true,
username: true
}
});
Logger.get('user').info(`User ${user.username} (${newUser.username}) (${newUser.id}) was updated`);
delete newUser.password;
return res.json(newUser);
} else {
delete user.password;