feat: user avatars
This commit is contained in:
parent
8f835eec4e
commit
d41f6058f7
10 changed files with 115 additions and 14 deletions
|
@ -1,3 +1,6 @@
|
||||||
|
/**
|
||||||
|
* @type {import('next').NextConfig}
|
||||||
|
**/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
|
|
2
prisma/migrations/20220816212407_avatar/migration.sql
Normal file
2
prisma/migrations/20220816212407_avatar/migration.sql
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "avatar" TEXT;
|
|
@ -11,6 +11,7 @@ model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String
|
username String
|
||||||
password String
|
password String
|
||||||
|
avatar String?
|
||||||
token String
|
token String
|
||||||
administrator Boolean @default(false)
|
administrator Boolean @default(false)
|
||||||
systemTheme String @default("system")
|
systemTheme String @default("system")
|
||||||
|
@ -79,11 +80,11 @@ model Stats {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Invite {
|
model Invite {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
code String @unique
|
code String @unique
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
expires_at DateTime?
|
expires_at DateTime?
|
||||||
used Boolean @default(false)
|
used Boolean @default(false)
|
||||||
|
|
||||||
createdBy User @relation(fields: [createdById], references: [id])
|
createdBy User @relation(fields: [createdById], references: [id])
|
||||||
createdById Int
|
createdById Int
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { AppShell, Box, Burger, Button, Divider, Header, MediaQuery, Navbar, NavLink, Paper, Popover, ScrollArea, Select, Stack, Text, Title, UnstyledButton, useMantineTheme, Group } from '@mantine/core';
|
import { AppShell, Box, Burger, Button, Divider, Header, MediaQuery, Navbar, NavLink, Paper, Popover, ScrollArea, Select, Stack, Text, Title, UnstyledButton, useMantineTheme, Group, Image } from '@mantine/core';
|
||||||
import { useClipboard } from '@mantine/hooks';
|
import { useClipboard } from '@mantine/hooks';
|
||||||
import { useModals } from '@mantine/modals';
|
import { useModals } from '@mantine/modals';
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
|
@ -111,8 +111,10 @@ const admin_items = [
|
||||||
export default function Layout({ children, user, title }) {
|
export default function Layout({ children, user, title }) {
|
||||||
const [token, setToken] = useState(user?.token);
|
const [token, setToken] = useState(user?.token);
|
||||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
||||||
|
const [avatar, setAvatar] = useState(user.avatar ?? null);
|
||||||
const [opened, setOpened] = useState(false); // navigation open
|
const [opened, setOpened] = useState(false); // navigation open
|
||||||
const [open, setOpen] = useState(false); // manage acc dropdown
|
const [open, setOpen] = useState(false); // manage acc dropdown
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useStoreDispatch();
|
const dispatch = useStoreDispatch();
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
@ -258,7 +260,7 @@ export default function Layout({ children, user, title }) {
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<SettingsIcon />}
|
leftIcon={avatar ? <Image src={avatar} height={32} radius='md' /> : <SettingsIcon />}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
sx={t => ({
|
sx={t => ({
|
||||||
backgroundColor: '#00000000',
|
backgroundColor: '#00000000',
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Box, Button, Card, ColorInput, Group, MultiSelect, Space, Text, TextInput, PasswordInput, Title, Tooltip } from '@mantine/core';
|
import { Box, Button, Card, ColorInput, Group, MultiSelect, Space, Text, TextInput, PasswordInput, Title, Tooltip, FileInput, Image } from '@mantine/core';
|
||||||
import { randomId, useInterval } from '@mantine/hooks';
|
import { randomId, useInterval } from '@mantine/hooks';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useModals } from '@mantine/modals';
|
import { useModals } from '@mantine/modals';
|
||||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||||
import { CrossIcon, DeleteIcon } from 'components/icons';
|
import { CrossIcon, DeleteIcon, SettingsIcon } from 'components/icons';
|
||||||
import DownloadIcon from 'components/icons/DownloadIcon';
|
import DownloadIcon from 'components/icons/DownloadIcon';
|
||||||
import Link from 'components/Link';
|
import Link from 'components/Link';
|
||||||
import { SmallTable } from 'components/SmallTable';
|
import { SmallTable } from 'components/SmallTable';
|
||||||
|
@ -25,6 +25,63 @@ export default function Manage() {
|
||||||
|
|
||||||
const [exports, setExports] = useState([]);
|
const [exports, setExports] = useState([]);
|
||||||
const [domains, setDomains] = useState(user.domains ?? []);
|
const [domains, setDomains] = useState(user.domains ?? []);
|
||||||
|
const [file, setFile] = useState<File>(null);
|
||||||
|
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
|
||||||
|
|
||||||
|
const getDataURL = (f: File): Promise<string> => {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.addEventListener('load', () => {
|
||||||
|
res(reader.result as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
reader.addEventListener('error', () => {
|
||||||
|
rej(reader.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
reader.readAsDataURL(f);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarChange = async (file: File) => {
|
||||||
|
setFile(file);
|
||||||
|
|
||||||
|
setFileDataURL(await getDataURL(file));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAvatar = async () => {
|
||||||
|
const dataURL = await getDataURL(file);
|
||||||
|
|
||||||
|
showNotification({
|
||||||
|
id: 'update-user',
|
||||||
|
title: 'Updating user...',
|
||||||
|
message: '',
|
||||||
|
loading: true,
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||||
|
avatar: dataURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newUser.error) {
|
||||||
|
updateNotification({
|
||||||
|
id: 'update-user',
|
||||||
|
title: 'Couldn\'t save user',
|
||||||
|
message: newUser.error,
|
||||||
|
color: 'red',
|
||||||
|
icon: <CrossIcon />,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch(updateUser(newUser));
|
||||||
|
updateNotification({
|
||||||
|
id: 'update-user',
|
||||||
|
title: 'Saved User',
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
|
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
|
||||||
const config = {
|
const config = {
|
||||||
|
@ -223,6 +280,32 @@ export default function Manage() {
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<Box mb='md'>
|
||||||
|
<Title>Avatar</Title>
|
||||||
|
<FileInput id='file' description='Add a custom avatar or leave blank for none' accept='image/png,image/jpeg,image/gif' value={file} onChange={handleAvatarChange} />
|
||||||
|
<Card mt='md'>
|
||||||
|
<Text>Preview:</Text>
|
||||||
|
<Button
|
||||||
|
leftIcon={fileDataURL ? <Image src={fileDataURL} height={32} radius='md' /> : <SettingsIcon />}
|
||||||
|
sx={t => ({
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: t.other.hover,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
size='xl'
|
||||||
|
p='sm'
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Group position='right' mt='md'>
|
||||||
|
<Button onClick={() => { setFile(null); setFileDataURL(null); }}>Reset</Button>
|
||||||
|
<Button onClick={saveAvatar} >Save Avatar</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box mb='md'>
|
<Box mb='md'>
|
||||||
<Title>Manage Data</Title>
|
<Title>Manage Data</Title>
|
||||||
<MutedText size='md'>Delete, or export your data into a zip file.</MutedText>
|
<MutedText size='md'>Delete, or export your data into a zip file.</MutedText>
|
||||||
|
|
|
@ -157,7 +157,7 @@ export default function Users() {
|
||||||
<Card key={user.id} sx={{ maxWidth: '100%' }}>
|
<Card key={user.id} sx={{ maxWidth: '100%' }}>
|
||||||
<Group position='apart'>
|
<Group position='apart'>
|
||||||
<Group position='left'>
|
<Group position='left'>
|
||||||
<Avatar size='lg' color={user.administrator ? 'primary' : 'dark'}>{user.username[0]}</Avatar>
|
<Avatar size='lg' color={user.administrator ? 'primary' : 'dark'} src={user.avatar ?? null}>{user.username[0]}</Avatar>
|
||||||
<Stack spacing={0}>
|
<Stack spacing={0}>
|
||||||
<Title>{user.username}</Title>
|
<Title>{user.username}</Title>
|
||||||
<MutedText size='sm'>ID: {user.id}</MutedText>
|
<MutedText size='sm'>ID: {user.id}</MutedText>
|
||||||
|
|
|
@ -26,6 +26,7 @@ export type NextApiReq = NextApiRequest & {
|
||||||
id: number;
|
id: number;
|
||||||
password: string;
|
password: string;
|
||||||
domains: string[];
|
domains: string[];
|
||||||
|
avatar?: string;
|
||||||
} | null | void>;
|
} | null | void>;
|
||||||
getCookie: (name: string) => string | null;
|
getCookie: (name: string) => string | null;
|
||||||
cleanCookie: (name: string) => void;
|
cleanCookie: (name: string) => void;
|
||||||
|
@ -114,6 +115,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||||
token: true,
|
token: true,
|
||||||
username: true,
|
username: true,
|
||||||
domains: true,
|
domains: true,
|
||||||
|
avatar: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ export interface User {
|
||||||
embedSiteName: string;
|
embedSiteName: string;
|
||||||
systemTheme: string;
|
systemTheme: string;
|
||||||
domains: string[];
|
domains: string[];
|
||||||
|
avatar?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: User = null;
|
const initialState: User = null;
|
||||||
|
|
|
@ -23,7 +23,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
username: req.body.username,
|
username: req.body.username,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (existing && user.username !== req.body.username) {
|
if (existing && user.username !== req.body.username) {
|
||||||
return res.forbid('Username is already taken');
|
return res.forbid('Username is already taken');
|
||||||
}
|
}
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
|
@ -32,6 +32,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.body.avatar) await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { avatar: req.body.avatar },
|
||||||
|
});
|
||||||
|
|
||||||
if (req.body.embedTitle) await prisma.user.update({
|
if (req.body.embedTitle) await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { embedTitle: req.body.embedTitle },
|
data: { embedTitle: req.body.embedTitle },
|
||||||
|
@ -57,7 +62,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { domains: [] },
|
data: { domains: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
const invalidDomains = [];
|
const invalidDomains = [];
|
||||||
|
|
||||||
for (const domain of req.body.domains) {
|
for (const domain of req.body.domains) {
|
||||||
|
@ -98,6 +103,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
token: true,
|
token: true,
|
||||||
username: true,
|
username: true,
|
||||||
domains: true,
|
domains: true,
|
||||||
|
avatar: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -111,4 +117,4 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withZipline(handler);
|
export default withZipline(handler);
|
|
@ -10,7 +10,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
where: { code },
|
where: { code },
|
||||||
});
|
});
|
||||||
if (!invite) return res.bad('invalid invite code');
|
if (!invite) return res.bad('invalid invite code');
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: { username },
|
where: { username },
|
||||||
});
|
});
|
||||||
|
@ -25,7 +25,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
|
||||||
if (req.method === 'DELETE') {
|
if (req.method === 'DELETE') {
|
||||||
if (req.body.id === user.id) return res.forbid('you can\'t delete your own account');
|
if (req.body.id === user.id) return res.forbid('you can\'t delete your own account');
|
||||||
|
|
||||||
const deleteUser = await prisma.user.findFirst({
|
const deleteUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: req.body.id,
|
id: req.body.id,
|
||||||
|
@ -70,6 +70,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
embedColor: true,
|
embedColor: true,
|
||||||
embedTitle: true,
|
embedTitle: true,
|
||||||
systemTheme: true,
|
systemTheme: true,
|
||||||
|
avatar: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return res.json(users);
|
return res.json(users);
|
||||||
|
|
Loading…
Add table
Reference in a new issue