feat: uploading & rendering

This commit is contained in:
diced 2023-07-03 23:11:13 -07:00
parent c0fbbbb904
commit 01e7b1f473
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
32 changed files with 1594 additions and 150 deletions

212
code.json Normal file
View file

@ -0,0 +1,212 @@
[
{
"ext": "html",
"mime": "text/html",
"name": "HTML"
},
{
"ext": "css",
"mime": "text/css",
"name": "CSS"
},
{
"ext": "cpp",
"mime": "text/x-c++src",
"name": "C++"
},
{
"ext": "js",
"mime": "application/javascript",
"name": "JavaScript"
},
{
"ext": "py",
"mime": "text/x-python",
"name": "Python"
},
{
"ext": "rb",
"mime": "text/x-ruby",
"name": "Ruby"
},
{
"ext": "java",
"mime": "text/x-java",
"name": "Java"
},
{
"ext": "md",
"mime": "text/markdown",
"name": "Markdown"
},
{
"ext": "c",
"mime": "text/x-csrc",
"name": "C"
},
{
"ext": "php",
"mime": "application/x-httpd-php",
"name": "PHP"
},
{
"ext": "sass",
"mime": "text/x-sass",
"name": "Sass"
},
{
"ext": "scss",
"mime": "text/x-scss",
"name": "SCSS"
},
{
"ext": "swift",
"mime": "text/x-swift",
"name": "Swift"
},
{
"ext": "ts",
"mime": "application/typescript",
"name": "TypeScript"
},
{
"ext": "go",
"mime": "text/x-go",
"name": "Go"
},
{
"ext": "rs",
"mime": "text/x-rustsrc",
"name": "Rust"
},
{
"ext": "sh",
"mime": "application/x-sh",
"name": "Bash"
},
{
"ext": "json",
"mime": "application/json",
"name": "JSON"
},
{
"ext": "ps1",
"mime": "application/x-powershell",
"name": "PowerShell"
},
{
"ext": "sql",
"mime": "application/sql",
"name": "SQL"
},
{
"ext": "yaml",
"mime": "text/yaml",
"name": "YAML"
},
{
"ext": "dockerfile",
"mime": "text/x-dockerfile",
"name": "Dockerfile"
},
{
"ext": "lua",
"mime": "text/x-lua",
"name": "Lua"
},
{
"ext": "conf",
"mime": "text/x-nginx-conf",
"name": "NGINX Config File"
},
{
"ext": "pl",
"mime": "text/x-perl",
"name": "Perl"
},
{
"ext": "r",
"mime": "text/x-rsrc",
"name": "R"
},
{
"ext": "scala",
"mime": "text/x-scala",
"name": "Scala"
},
{
"ext": "groovy",
"mime": "text/x-groovy",
"name": "Groovy"
},
{
"ext": "kt",
"mime": "text/x-kotlin",
"name": "Kotlin"
},
{
"ext": "hs",
"mime": "text/x-haskell",
"name": "Haskell"
},
{
"ext": "ex",
"mime": "text/x-elixir",
"name": "Elixir"
},
{
"ext": "vim",
"mime": "text/x-vim",
"name": "Vim"
},
{
"ext": "m",
"mime": "text/x-matlab",
"name": "MATLAB"
},
{
"ext": "dart",
"mime": "application/dart",
"name": "Dart"
},
{
"ext": "hbs",
"mime": "text/x-handlebars-template",
"name": "Handlebars"
},
{
"ext": "hcl",
"mime": "text/x-hcl",
"name": "HCL"
},
{
"ext": "http",
"mime": "text/http",
"name": "HTTP"
},
{
"ext": "ini",
"mime": "text/x-ini",
"name": "INI"
},
{
"ext": "jsx",
"mime": "text/jsx",
"name": "JSX"
},
{
"ext": "coffee",
"mime": "text/x-coffeescript",
"name": "CoffeeScript"
},
{
"ext": "tex",
"mime": "text/x-latex",
"name": "LaTeX (KaTeX)"
},
{
"name": "Plain Text",
"mime": "text/plain",
"ext": "txt"
}
]

View file

@ -3,4 +3,4 @@ const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
module.exports = nextConfig

View file

@ -5,8 +5,8 @@
"sideEffects": false,
"version": "4.0.0-dev.1",
"scripts": {
"build": "run-s build:*",
"build:remix": "next build",
"build": "run-p \"build:*\"",
"build:next": "next build",
"build:server": "tsup",
"dev": "run-s dev:build dev:server",
"dev:build": "cross-env NODE_ENV=development run-s build:server",
@ -37,11 +37,15 @@
"colorette": "^2.0.20",
"dayjs": "^1.11.8",
"express": "^4.18.2",
"highlight.js": "^11.8.0",
"katex": "^0.16.8",
"ms": "^2.1.3",
"multer": "^1.4.5-lts.1",
"next": "^13.4.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.7",
"remark-gfm": "^3.0.1",
"swr": "^2.2.0",
"znv": "^0.3.2",
"zod": "^3.21.4",
@ -52,6 +56,7 @@
"@remix-run/eslint-config": "^1.16.1",
"@types/bytes": "^3.1.1",
"@types/express": "^4.17.17",
"@types/katex": "^0.16.0",
"@types/multer": "^1.4.7",
"@types/node": "^20.3.1",
"@types/react": "^18.2.7",

View file

@ -1,14 +0,0 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

View file

@ -58,6 +58,7 @@ enum OAuthProviderType {
DISCORD
GOOGLE
GITHUB
AUTHENTIK
}
model UserLimit {
@ -95,9 +96,8 @@ model File {
updatedAt DateTime @updatedAt
deletesAt DateTime?
name String // name shown on dashboard
originalName String // original name of file when uploaded
path String // path it's stored on the server
name String // name & file saved on datasource
originalName String? // original name of file when uploaded
size Int
type String
views Int @default(0)
@ -169,6 +169,7 @@ model Url {
vanity String?
destination String
name String @unique
views Int @default(0)
zeroWidthSpace String?

View file

@ -291,7 +291,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Menu.Item>
<Menu.Divider />
<Menu.Item icon={<IconLogout size='1rem' />}>Logout</Menu.Item>
<Menu.Item icon={<IconLogout size='1rem' />} component={Link} href='/auth/logout'>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>

View file

@ -1,6 +1,10 @@
import type { File } from '@/lib/db/models/file';
import type { File as DbFile } from '@/lib/db/models/file';
import { Box, Center, Group, Image, LoadingOverlay, Text, UnstyledButton } from '@mantine/core';
import { IconFileText } from '@tabler/icons-react';
import { Icon, IconFileUnknown, IconPhotoCancel, IconPlayerPlay } from '@tabler/icons-react';
import Render from '../render/Render';
import { renderMode } from '../pages/upload/renderMode';
import { useEffect, useState } from 'react';
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return (
@ -30,16 +34,32 @@ function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onCli
);
}
export default function DashboardFileType({ file, show }: { file: File; show?: boolean }) {
export default function DashboardFileType({ file, show }: { file: DbFile | File; show?: boolean }) {
const type = file.type.split('/')[0];
const dbFile = 'id' in file;
const renderIn = renderMode(file.name.split('.').pop() || '');
const [fileContent, setFileContent] = useState('');
// const shouldRenderMarkdown = file.name.endsWith('.md');
// const shouldRenderTex = file.name.endsWith('.tex');
useEffect(() => {
(async () => {
const res = await fetch(`/raw/${file.name}`);
const text = await res.text();
setFileContent(text);
})();
}, []);
switch (type) {
case 'video':
return show ? (
<video width='100%' autoPlay muted controls src={`/raw/${file.name}`} />
<video
width='100%'
autoPlay
muted
controls
src={dbFile ? `/raw/${file.name}` : URL.createObjectURL(file)}
/>
) : (
<Placeholder text={`Click to play video ${file.name}`} Icon={IconPlayerPlay} />
);
@ -51,38 +71,35 @@ export default function DashboardFileType({ file, show }: { file: File; show?: b
position: 'inherit',
},
image: {
maxHeight: 340,
maxHeight: dbFile ? '100vh' : 100,
},
}}
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}
src={`/raw/${file.name}`}
src={dbFile ? `/raw/${file.name}` : URL.createObjectURL(file)}
alt={file.name}
// width={!show ? 'auto' : undefined}
width={show ? 'auto' : undefined}
/>
);
case 'audio':
return show ? (
<audio autoPlay muted controls style={{ width: '100%' }} src={`/raw/${file.name}`} />
<audio
autoPlay
muted
controls
style={{ width: '100%' }}
src={dbFile ? `/raw/${file.name}` : URL.createObjectURL(file)}
/>
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={IconPlayerPlay} />
);
case 'text':
return (
<>
{/* {loading ? ( */}
{/* <LoadingOverlay visible={loading} /> */}
{/* ) : ( */}
{/* <> */}
{/* {(shouldRenderMarkdown || shouldRenderTex) && renderAlert()} */}
{/* render - */}
{/* </> */}
{/* )} */}
<LoadingOverlay visible />
</>
return show ? (
<Render mode={renderIn} language={file.name.split('.').pop() || ''} code={fileContent} />
) : (
<Placeholder text={`Click to view text ${file.name}`} Icon={IconFileText} />
);
default:
return <Placeholder text={`Click to view file ${file.name}`} Icon={IconFileUnknown} />;
if (dbFile) return <Placeholder text={`Click to view file ${file.name}`} Icon={IconFileUnknown} />;
else return <IconFileUnknown size={48} />;
}
}

View file

@ -0,0 +1,96 @@
import DashboardFile from '@/components/file/DashboardFile';
import Stat from '@/components/Stat';
import type { Response } from '@/lib/api/response';
import useLogin from '@/lib/hooks/useLogin';
import { LoadingOverlay, Paper, SimpleGrid, Table, Text, Title } from '@mantine/core';
import { IconDeviceSdCard, IconEyeFilled, IconFiles, IconLink, IconStarFilled } from '@tabler/icons-react';
import bytes from 'bytes';
import useSWR from 'swr';
export default function DashboardHome() {
const { user } = useLogin();
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
return (
<>
<Title order={1}>
Welcome back, <b>{user?.username}</b>
</Title>
<Text size='sm' color='dimmed'>
You have <b>{statsLoading ? '...' : stats?.filesUploaded}</b> files uploaded.
</Text>
<Title order={2} mt='md' mb='xs'>
Recent files
</Title>
{recentLoading ? (
<Paper withBorder p='md' radius='md' pos='relative' h={300}>
<LoadingOverlay visible />
</Paper>
) : (
<SimpleGrid cols={3} spacing='md' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{recent!.map((file) => (
<DashboardFile key={file.id} file={file} />
))}
</SimpleGrid>
)}
<Title order={2} mt='md'>
Stats
</Title>
<Text size='sm' color='dimmed' mb='xs'>
These statistics are based on your uploads only.
</Text>
{statsLoading ? (
<Paper withBorder p='md' radius='md' pos='relative' h={300}>
<LoadingOverlay visible />
</Paper>
) : (
<>
<SimpleGrid cols={4} spacing='md' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
<Stat Icon={IconFiles} title='Files uploaded' value={stats!.filesUploaded} />
<Stat Icon={IconStarFilled} title='Favorite files' value={stats!.favoriteFiles} />
<Stat
Icon={IconDeviceSdCard}
title='Storage used'
value={bytes(stats!.storageUsed, { unitSeparator: ' ' })}
/>
<Stat
Icon={IconDeviceSdCard}
title='Average storage used'
value={bytes(stats!.avgStorageUsed, { unitSeparator: ' ' })}
/>
<Stat Icon={IconEyeFilled} title='File views' value={stats!.views} />
<Stat Icon={IconEyeFilled} title='File average views' value={stats!.avgViews} />
<Stat Icon={IconLink} title='Links created' value={stats!.urlsCreated} />
</SimpleGrid>
<Title order={3} mt='lg' mb='xs'>
File types
</Title>
<Table>
<thead>
<tr>
<th>Type</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{Object.entries(stats!.sortTypeCount).map(([type, count]) => (
<tr key={type}>
<td>{type}</td>
<td>{count}</td>
</tr>
))}
</tbody>
</Table>
</>
)}
</>
);
}

View file

@ -0,0 +1,44 @@
import DashboardFileType from '@/components/file/DashboardFileType';
import { Button, Center, Group, HoverCard, Paper, Stack, Text } from '@mantine/core';
import { IconFileUpload, IconTrashFilled } from '@tabler/icons-react';
import bytes from 'bytes';
export default function ToUploadFile({ file, onDelete }: { file: File; onDelete: () => void }) {
return (
<HoverCard shadow='md' position='top'>
<HoverCard.Target>
<Paper withBorder p='md' radius='md' pos='relative'>
<Center h='100%'>
<Group position='center' spacing='xl'>
<IconFileUpload size={48} />
<Text size='md'>{file.name}</Text>
</Group>
</Center>
</Paper>
</HoverCard.Target>
<HoverCard.Dropdown>
<Group>
<DashboardFileType file={file} show />
<Stack spacing='xs'>
<Text size='sm' color='dimmed'>
<b>{file.name}</b> {file.type || file.type === '' ? `(${file.type})` : ''}
</Text>
<Text size='sm' color='dimmed'>
{bytes(file.size, { unitSeparator: ' ' })}
</Text>
<Button
compact
variant='outline'
color='red'
fullWidth
onClick={onDelete}
leftIcon={<IconTrashFilled size='1rem' />}
>
Remove
</Button>
</Stack>
</Group>
</HoverCard.Dropdown>
</HoverCard>
);
}

View file

@ -0,0 +1,110 @@
import {
ActionIcon,
Button,
Collapse,
Grid,
Group,
Progress,
Text,
Title,
Tooltip,
rem,
useMantineTheme,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { IconFiles, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import Link from 'next/link';
import { useState } from 'react';
import { uploadFiles } from '../uploadFiles';
import ToUploadFile from './ToUploadFile';
export default function UploadFile() {
const theme = useMantineTheme();
const modals = useModals();
const clipboard = useClipboard();
const [files, setFiles] = useState<File[]>([]);
const [progress, setProgress] = useState(0);
const [dropLoading, setLoading] = useState(false);
const upload = () => {
uploadFiles(files, {
setFiles,
setLoading,
setProgress,
modals,
clipboard,
});
};
return (
<>
<Group spacing='sm'>
<Title order={1}>Upload files</Title>
<Tooltip label='View your files'>
<ActionIcon component={Link} href='/dashboard/files' variant='outline' color='gray' radius='sm'>
<IconFiles size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Dropzone onDrop={(f) => setFiles([...f, ...files])} my='sm' loading={dropLoading}>
<Group position='center' spacing='xl' style={{ minHeight: rem(220), pointerEvents: 'none' }}>
<Dropzone.Accept>
<IconUpload
size='3.2rem'
stroke={1.5}
color={theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6]}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX
size='3.2rem'
stroke={1.5}
color={theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6]}
/>
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size='3.2rem' stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size='xl' inline>
Drag images here or click to select files
</Text>
<Text size='sm' color='dimmed' inline mt={7}>
Attach as many files as you like, they will show up below to review before uploading.
</Text>
</div>
</Group>
</Dropzone>
<Collapse in={progress !== 0}>
{progress !== 0 && <Progress my='sm' label={`${Math.floor(progress)}%`} value={progress} animate />}
</Collapse>
<Grid grow my='sm'>
{files.map((file, i) => (
<Grid.Col span={3} key={i}>
<ToUploadFile file={file} onDelete={() => setFiles(files.filter((_, j) => i !== j))} />
</Grid.Col>
))}
</Grid>
<Button
variant='outline'
color='gray'
leftIcon={<IconUpload size={18} />}
disabled={files.length === 0 || dropLoading}
fullWidth
size='xl'
onClick={upload}
>
Upload {files.length} files
</Button>
</>
);
}

View file

@ -0,0 +1,134 @@
import Render from '@/components/render/Render';
import DashboardUploadText from '@/pages/dashboard/upload/text';
import {
ActionIcon,
Button,
Center,
Group,
Select,
Tabs,
Text,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import { IconCursorText, IconEyeFilled, IconFiles, IconUpload } from '@tabler/icons-react';
import Link from 'next/link';
import { useState } from 'react';
import { renderMode } from './renderMode';
import { uploadFiles } from './uploadFiles';
import { useModals } from '@mantine/modals';
import { useClipboard } from '@mantine/hooks';
export default function UploadText({
codeMeta,
}: {
codeMeta: Parameters<typeof DashboardUploadText>[0]['codeMeta'];
}) {
const modals = useModals();
const clipboard = useClipboard();
const [selectedLanguage, setSelectedLanguage] = useState('txt');
const [text, setText] = useState('');
const [loading, setLoading] = useState(false);
const renderIn = renderMode(selectedLanguage);
// when pressing tab in textarea, it should insert 2 spaces instead of switching focus
const handleTab = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault();
const { selectionStart, selectionEnd, value } = e.currentTarget;
const newValue = `${value.substring(0, selectionStart)} ${value.substring(selectionEnd)}`;
setText(newValue);
}
};
const upload = () => {
const blob = new Blob([text]);
const file = new File([blob], `text.${selectedLanguage}`, {
type: codeMeta.find((meta) => meta.ext === selectedLanguage)?.mime,
lastModified: Date.now(),
});
uploadFiles([file], {
clipboard,
modals,
setFiles: () => {},
setLoading,
setProgress: () => {},
});
};
return (
<>
<Group spacing='sm'>
<Title order={1}>Upload text</Title>
<Tooltip label='View your files'>
<ActionIcon component={Link} href='/dashboard/files' variant='outline' color='gray' radius='sm'>
<IconFiles size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Tabs defaultValue='textarea' variant='pills' my='sm'>
<Tabs.List>
<Tabs.Tab value='textarea' icon={<IconCursorText size='1rem' />}>
Text
</Tabs.Tab>
<Tabs.Tab value='preview' icon={<IconEyeFilled size='1rem' />}>
Preview
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value='textarea'>
<Textarea
sx={{
'& textarea': {
fontFamily: 'monospace',
height: '50vh',
},
}}
my='md'
value={text}
onChange={(e) => setText(e.currentTarget.value)}
onKeyDown={handleTab}
disabled={loading}
/>
</Tabs.Panel>
<Tabs.Panel value='preview'>
{text.length === 0 ? (
<Center h='100%'>
<Text size='md' color='red'>
No text to preview!
</Text>
</Center>
) : (
<Render mode={renderIn} code={text} language={selectedLanguage} />
)}
</Tabs.Panel>
</Tabs>
<Group position='right' spacing='sm' my='md'>
<Select
searchable
defaultValue='txt'
data={codeMeta.map((meta) => ({ value: meta.ext, label: meta.name }))}
onChange={(value) => setSelectedLanguage(value as string)}
/>
<Button
variant='outline'
color='gray'
leftIcon={<IconUpload size='1rem' />}
disabled={text.length === 0 || loading}
onClick={upload}
>
Upload
</Button>
</Group>
</>
);
}

View file

@ -0,0 +1,16 @@
export enum RenderMode {
Katex,
Markdown,
Highlight,
}
export function renderMode(extension: string) {
switch (extension) {
case 'tex':
return RenderMode.Katex;
case 'md':
return RenderMode.Markdown;
default:
return RenderMode.Highlight;
}
}

View file

@ -0,0 +1,144 @@
import { Response } from '@/lib/api/response';
import { ErrorBody } from '@/lib/response';
import { ActionIcon, Anchor, Group, Stack, Table, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconClipboardCopy, IconExternalLink, IconFileUpload, IconFileXFilled } from '@tabler/icons-react';
import Link from 'next/link';
export function filesModal(
files: Response['/api/upload']['files'],
{
modals,
clipboard,
}: {
modals: ReturnType<typeof useModals>;
clipboard: ReturnType<typeof useClipboard>;
}
) {
const open = (idx: number) => window.open(files[idx].url, '_blank');
const copy = (idx: number) => {
clipboard.copy(files[idx].url);
notifications.show({
title: 'Copied URL to clipboard',
message: (
<Anchor component={Link} href={files[idx].url} target='_blank'>
{files[idx].url}
</Anchor>
),
color: 'blue',
icon: <IconClipboardCopy size='1rem' />,
});
};
modals.openModal({
title: <Title>Uploaded Files</Title>,
size: 'auto',
children: (
<Table withBorder={false} withColumnBorders={false} highlightOnHover horizontalSpacing={'sm'}>
<Stack>
{files.map((file, idx) => (
<Group key={idx} position='apart'>
<Group position='left'>
<Anchor component={Link} href={file.url}>
{file.url}
</Anchor>
</Group>
<Group position='right'>
<Tooltip label='Open link in a new tab'>
<ActionIcon onClick={() => open(idx)} variant='filled' color='primary'>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy link to clipboard'>
<ActionIcon onClick={() => copy(idx)} variant='filled' color='primary'>
<IconClipboardCopy size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
</Group>
))}
</Stack>
</Table>
),
});
}
export function uploadFiles(
files: File[],
{
setProgress,
setLoading,
setFiles,
modals,
clipboard,
}: {
setProgress: (progress: number) => void;
setLoading: (loading: boolean) => void;
setFiles: (files: File[]) => void;
modals: ReturnType<typeof useModals>;
clipboard: ReturnType<typeof useClipboard>;
}
) {
setLoading(true);
setProgress(0);
const body = new FormData();
for (let i = 0; i !== files.length; ++i) {
body.append('file', files[i]);
}
notifications.show({
id: 'upload',
title: 'Uploading files',
message: `Uploading ${files.length} file${files.length === 1 ? '' : 's'}`,
loading: true,
autoClose: false,
});
const req = new XMLHttpRequest();
req.addEventListener('progress', (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100));
}
});
req.addEventListener(
'load',
(e) => {
const res: Response['/api/upload'] = JSON.parse(req.responseText);
setLoading(false);
setProgress(0);
if ((res as ErrorBody).error) {
notifications.update({
id: 'upload',
title: 'Error uploading files',
message: (res as ErrorBody).error,
color: 'red',
icon: <IconFileXFilled size='1rem' />,
autoClose: true,
});
return;
}
notifications.update({
id: 'upload',
title: 'Uploaded files',
message: `Uploaded ${files.length} file${files.length === 1 ? '' : 's'}`,
color: 'green',
icon: <IconFileUpload size='1rem' />,
autoClose: true,
});
setFiles([]);
filesModal(res.files, { modals, clipboard });
},
false
);
req.open('POST', '/api/upload');
req.send(body);
}

View file

@ -0,0 +1,35 @@
import type { ParseError } from 'katex';
import { useEffect, useState } from 'react';
import 'katex/dist/katex.min.css';
import { Alert } from '@mantine/core';
const sanitize = (str: string) => {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
};
export default function KaTeX({ tex }: { tex: string }) {
const [html, setHtml] = useState('');
const [error, setError] = useState<any>(null);
useEffect(() => {
import('katex').then(({ default: { renderToString } }) => {
try {
const html = renderToString(tex, { throwOnError: true });
setHtml(html);
} catch (err) {
setError(err);
}
});
}, [tex]);
if (error) {
return (
<Alert color='red' title='KaTeX error'>
{error.toString()}
</Alert>
);
}
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

View file

@ -0,0 +1,30 @@
import { Code, Image, Paper } from '@mantine/core';
import ReactMarkdown from 'react-markdown';
import HighlightCode from './code/HighlightCode';
import remarkGfm from 'remark-gfm';
export default function Markdown({ md }: { md: string }) {
return (
<Paper withBorder p='md'>
<ReactMarkdown
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<HighlightCode language={match[1]} code={String(children).replace(/\n$/, '')} />
) : (
<Code className={className} {...props}>
{children}
</Code>
);
},
img({ node, ...props }) {
return <Image {...props} />;
},
}}
remarkPlugins={[remarkGfm]}
children={md}
/>
</Paper>
);
}

View file

@ -0,0 +1,67 @@
import { RenderMode } from '@/components/pages/upload/renderMode';
import { Alert, Button } from '@mantine/core';
import { IconEyeFilled } from '@tabler/icons-react';
import { useState } from 'react';
import KaTeX from './KaTeX';
import Markdown from './Markdown';
import HighlightCode from './code/HighlightCode';
export function RenderAlert({
renderer,
state,
change,
}: {
renderer: string;
state: boolean;
change: (s: boolean) => void;
}) {
return (
<Alert color='gray' icon={<IconEyeFilled size='1rem' />} variant='outline' my='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}
>
{state ? 'Show' : 'Hide'} rendered version
</Button>
</Alert>
);
}
export default function Render({
mode,
language,
code,
}: {
mode: RenderMode;
language: string;
code: string;
}) {
const [highlight, setHighlight] = useState(false);
switch (mode) {
case RenderMode.Katex:
return (
<>
<RenderAlert renderer='KaTeX' state={highlight} change={(s) => setHighlight(s)} />
{highlight ? <HighlightCode language={language} code={code} /> : <KaTeX tex={code} />}
</>
);
case RenderMode.Markdown:
return (
<>
<RenderAlert renderer='Markdown' state={highlight} change={(s) => setHighlight(s)} />
{highlight ? <HighlightCode language={language} code={code} /> : <Markdown md={code} />}
</>
);
default:
return <HighlightCode language={language} code={code} />;
}
}

View file

@ -0,0 +1,91 @@
/* @mixin light {
--_color: var(--mantine-color-gray-7);
--_background: var(--mantine-color-gray-0);
--code-comment-color: var(--mantine-color-gray-6);
--code-keyword-color: var(--mantine-color-violet-8);
--code-tag-color: var(--mantine-color-red-9);
--code-literal-color: var(--mantine-color-blue-6);
--code-string-color: var(--mantine-color-blue-9);
--code-variable-color: var(--mantine-color-lime-9);
--code-class-color: var(--mantine-color-orange-9);
}
*/
.theme {
--_color: var(--mantine-color-dark-1);
--_background: var(--mantine-color-dark-8);
--code-comment-color: var(--mantine-color-dark-3);
--code-keyword-color: var(--mantine-color-violet-3);
--code-tag-color: var(--mantine-color-yellow-4);
--code-literal-color: var(--mantine-color-blue-4);
--code-string-color: var(--mantine-color-green-6);
--code-variable-color: var(--mantine-color-blue-2);
--code-class-color: var(--mantine-color-orange-5);
.hljs-comment,
.hljs-quote {
font-style: italic;
color: var(--code-comment-color);
}
.hljs-doctag,
.hljs-formula,
.hljs-keyword {
color: var(--code-keyword-color);
}
.hljs-deletion,
.hljs-name,
.hljs-section,
.hljs-selector-tag,
.hljs-subst {
color: var(--code-tag-color);
}
.hljs-literal {
color: var(--code-literal-color);
}
.hljs-addition,
.hljs-attribute,
.hljs-meta .hljs-string,
.hljs-regexp,
.hljs-string {
color: var(--code-string-color);
}
.hljs-attr,
.hljs-number,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-pseudo,
.hljs-template-variable,
.hljs-type,
.hljs-variable {
color: var(--code-variable-color);
}
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id,
.hljs-symbol,
.hljs-title,
.hljs-built_in,
.hljs-class .hljs-title,
.hljs-title.class_ {
color: var(--code-class-color);
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
.hljs-link {
text-decoration: underline;
}
}

View file

@ -0,0 +1,58 @@
import { ActionIcon, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core';
import { IconCheck, IconClipboardCopy } from '@tabler/icons-react';
import hljs from 'highlight.js';
export default function HighlightCode({ language, code }: { language: string; code: string }) {
const theme = useMantineTheme();
const lines = code.split('\n').filter((line) => line !== '');
const lineNumbers = lines.map((_, i) => i + 1);
return (
<Paper className='theme' withBorder p='xs' my='md' pos='relative'>
<CopyButton value={code}>
{({ copied, copy }) => (
<ActionIcon
onClick={copy}
variant='outline'
color={copied ? 'green' : 'gray'}
size='md'
style={{ zIndex: 4, position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{!copied ? (
<IconClipboardCopy size='1rem' />
) : (
<IconCheck color={theme.colors.green[4]} size='1rem' />
)}
</ActionIcon>
)}
</CopyButton>
<ScrollArea type='auto' dir='ltr' offsetScrollbars={false}>
<pre style={{ margin: 0 }}>
<code>
{lines.map((line, i) => (
<div key={i}>
<Text
component='span'
size='sm'
color='dimmed'
mr='md'
style={{ userSelect: 'none', fontFamily: 'monospace' }}
>
{lineNumbers[i]}
</Text>
<span
className='line'
dangerouslySetInnerHTML={{
__html: language === 'none' ? line : hljs.highlight(line, { language }).value,
}}
/>
</div>
))}
</code>
</pre>
</ScrollArea>
</Paper>
);
}

View file

@ -1,7 +1,7 @@
import bytes from 'bytes';
import msFn from 'ms';
type EnvType = 'string' | 'number' | 'boolean' | 'byte' | 'ms';
type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms';
export type ParsedEnv = ReturnType<typeof readEnv>;
@ -10,8 +10,16 @@ export const PROP_TO_ENV: Record<string, string> = {
'core.hostname': 'CORE_HOSTNAME',
'core.secret': 'CORE_SECRET',
'core.databaseUrl': 'CORE_DATABASE_URL',
'core.returnHttpsUrls': 'CORE_RETURN_HTTPS_URLS',
'files.route': 'FILES_ROUTE',
'files.length': 'FILES_LENGTH',
'files.defaultFormat': 'FILES_DEFAULT_FORMAT',
'files.disabledExtensions': 'FILES_DISABLED_EXTENSIONS',
'files.maxFileSize': 'FILES_MAX_FILE_SIZE',
'files.defaultExpiration': 'FILES_DEFAULT_EXPIRATION',
'files.assumeMimetypes': 'FILES_ASSUME_MIMETYPES',
'files.defaultDateFormat': 'FILES_DEFAULT_DATE_FORMAT',
'datasource.type': 'DATASOURCE_TYPE',
@ -25,6 +33,14 @@ export const PROP_TO_ENV: Record<string, string> = {
'datasource.s3.bucket': 'DATASOURCE_S3_BUCKET',
'datasource.local.directory': 'DATASOURCE_LOCAL_DIRECTORY',
'features.thumbnail': 'FEATURES_THUMBNAIL',
'features.imageCompression': 'FEATURES_IMAGE_COMPRESSION',
'features.robotsTxt': 'FEATURES_ROBOTS_TXT',
'features.healthcheck': 'FEATURES_HEALTHCHECK',
'features.invites': 'FEATURES_INVITES',
'features.userRegistration': 'FEATURES_USER_REGISTRATION',
'features.oauthRegistration': 'FEATURES_OAUTH_REGISTRATION',
};
export function readEnv() {
@ -35,6 +51,11 @@ export function readEnv() {
env(PROP_TO_ENV['core.databaseUrl'], 'core.databaseUrl', 'string'),
env(PROP_TO_ENV['files.route'], 'files.route', 'string'),
env(PROP_TO_ENV['files.length'], 'files.length', 'number'),
env(PROP_TO_ENV['files.defaultFormat'], 'files.defaultFormat', 'string'),
env(PROP_TO_ENV['files.disabledExtensions'], 'files.disabledExtensions', 'string[]'),
env(PROP_TO_ENV['files.maxFileSize'], 'files.maxFileSize', 'byte'),
env(PROP_TO_ENV['files.defaultExpiration'], 'files.defaultExpiration', 'ms'),
env(PROP_TO_ENV['datasource.type'], 'datasource.type', 'string'),
@ -44,6 +65,14 @@ export function readEnv() {
env(PROP_TO_ENV['datasource.s3.bucket'], 'datasource.s3.bucket', 'string'),
env(PROP_TO_ENV['datasource.local.directory'], 'datasource.local.directory', 'string'),
env(PROP_TO_ENV['features.thumbnail'], 'features.thumbnail', 'boolean'),
env(PROP_TO_ENV['features.imageCompression'], 'features.imageCompression', 'boolean'),
env(PROP_TO_ENV['features.robotsTxt'], 'features.robotsTxt', 'boolean'),
env(PROP_TO_ENV['features.healthcheck'], 'features.healthcheck', 'boolean'),
env(PROP_TO_ENV['features.invites'], 'features.invites', 'boolean'),
env(PROP_TO_ENV['features.userRegistration'], 'features.userRegistration', 'boolean'),
env(PROP_TO_ENV['features.oauthRegistration'], 'features.oauthRegistration', 'boolean'),
];
const raw: any = {
@ -52,13 +81,30 @@ export function readEnv() {
hostname: undefined,
secret: undefined,
databaseUrl: undefined,
returnHttpsUrls: undefined,
},
files: {
route: undefined,
length: undefined,
defaultFormat: undefined,
disabledExtensions: undefined,
maxFileSize: undefined,
defaultExpiration: undefined,
assumeMimetypes: undefined,
defaultDateFormat: undefined,
},
datasource: {
type: undefined,
},
features: {
thumbnail: undefined,
imageCompression: undefined,
robotsTxt: undefined,
healthcheck: undefined,
invites: undefined,
userRegistration: undefined,
oauthRegistration: undefined,
},
};
for (let i = 0; i !== envs.length; ++i) {
@ -120,6 +166,11 @@ function parse(value: string, type: EnvType) {
switch (type) {
case 'string':
return string(value);
case 'string[]':
return value
.split(',')
.filter((s) => s.length !== 0)
.map((s) => s.trim());
case 'number':
return number(value);
case 'boolean':

View file

@ -77,6 +77,15 @@ const schema = z.object({
});
}
}),
features: z.object({
thumbnails: z.boolean().default(true),
imageCompression: z.boolean().default(true),
robotsTxt: z.boolean().default(false),
healthcheck: z.boolean().default(true),
invites: z.boolean().default(true),
userRegistration: z.boolean().default(false),
oauthRegistration: z.boolean().default(false),
}),
});
export type Config = z.infer<typeof schema>;

View file

@ -7,9 +7,8 @@ export type File = {
deletesAt: Date | null;
favorite: boolean;
id: string;
originalName: string;
originalName: string | null;
name: string;
path: string;
size: number;
type: string;
views: number;
@ -27,7 +26,6 @@ export const fileSelect = {
id: true,
originalName: true,
name: true,
path: true,
size: true,
type: true,
views: true,

View file

@ -4,6 +4,7 @@ import { MantineProvider } from '@mantine/core';
import { SWRConfig } from 'swr';
import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import '@/components/render/code/HighlightCode.css';
const fetcher = async (url: RequestInfo | URL) => {
const res = await fetch(url);
@ -35,6 +36,7 @@ export default function App(props: AppProps) {
<MantineProvider
withGlobalStyles
withNormalizeCSS
withCSSVariables
theme={{
colorScheme: 'dark',
}}

View file

@ -1,3 +1,4 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
@ -8,7 +9,9 @@ export type ApiHealthcheckResponse = {
pass: boolean;
};
export async function handler(req: NextApiReq, res: NextApiRes<ApiHealthcheckResponse>) {
export async function handler(_: NextApiReq, res: NextApiRes<ApiHealthcheckResponse>) {
if (!config.features.healthcheck) return res.notFound();
const logger = log('api').c('healthcheck');
try {

View file

@ -88,8 +88,6 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
const fileUpload = await prisma.file.create({
data: {
name: `${fileName}${extension}`,
path: `${fileName}${extension}`,
originalName: file.originalname,
size: file.size,
type: mimetype,
User: {
@ -101,6 +99,7 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
...(options.password && { password: await hashPassword(options.password) }),
...(options.deletesAt && { deletesAt: options.deletesAt }),
...(options.folder && { Folder: { connect: { id: options.folder } } }),
...(options.addOriginalName && { originalName: file.originalname }),
},
select: {
name: true,
@ -119,7 +118,7 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
logger.info(`${req.user.username} uploaded ${fileUpload.name} (size=${bytes(fileUpload.size)})`);
const responseUrl = `${domain}${
zconfig.files.route === '/' || zconfig.files.route === '' ? '' : `/${zconfig.files.route}`
zconfig.files.route === '/' || zconfig.files.route === '' ? '' : `${zconfig.files.route}`
}/${fileUpload.name}`;
response.files.push({

View file

@ -1,5 +1,4 @@
import { prisma } from '@/lib/db';
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
@ -7,18 +6,26 @@ import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserStatsResponse = {
filesUploaded: number;
favoriteFiles: number;
views: number;
avgViews: number;
storageUsed: number;
avgStorageUsed: number;
urlsCreated: number;
avgUrlViews: number;
sortTypeCount: { [type: string]: number };
};
export async function handler(req: NextApiReq, res: NextApiRes<ApiUserStatsResponse>) {
const agg = await prisma.file.aggregate({
const aggFile = await prisma.file.aggregate({
where: {
userId: req.user.id,
},
_count: true,
_count: {
_all: true,
favorite: true,
},
_sum: {
views: true,
size: true,
@ -29,12 +36,45 @@ export async function handler(req: NextApiReq, res: NextApiRes<ApiUserStatsRespo
},
});
const aggUrl = await prisma.url.aggregate({
where: {
userId: req.user.id,
},
_count: {
_all: true,
},
_avg: {
views: true,
},
});
const sortType = await prisma.file.findMany({
where: {
userId: req.user.id,
},
select: {
type: true,
},
});
const sortTypeCount = sortType.reduce((acc, cur) => {
if (acc[cur.type]) acc[cur.type] += 1;
else acc[cur.type] = 1;
return acc;
}, {} as { [type: string]: number });
return res.ok({
filesUploaded: agg._count,
views: agg._sum.views ?? 0,
avgViews: agg._avg.views ?? 0,
storageUsed: agg._sum.size ?? 0,
avgStorageUsed: agg._avg.size ?? 0,
filesUploaded: aggFile._count._all ?? 0,
favoriteFiles: aggFile._count.favorite ?? 0,
views: aggFile._sum.views ?? 0,
avgViews: aggFile._avg.views ?? 0,
storageUsed: aggFile._sum.size ?? 0,
avgStorageUsed: aggFile._avg.size ?? 0,
urlsCreated: aggUrl._count._all ?? 0,
avgUrlViews: aggUrl._avg.views ?? 0,
sortTypeCount,
});
}

View file

@ -95,7 +95,7 @@ export default function Login() {
{...form.getInputProps('password')}
/>
<Button size='lg' fullWidth type='submit'>
<Button size='lg' fullWidth type='submit' color='gray'>
Login
</Button>
</Stack>
@ -106,19 +106,19 @@ export default function Login() {
</Text>
<Stack my='xs'>
<Button size='lg' fullWidth variant='outline'>
<Button size='lg' fullWidth variant='outline' color='gray'>
Sign up
</Button>
<Button size='lg' fullWidth variant='outline'>
<Button size='lg' fullWidth variant='outline' color='gray'>
<IconText Icon={IconBrandGithubFilled} text='Sign in with GitHub' />
</Button>
<Button size='lg' fullWidth variant='outline'>
<Button size='lg' fullWidth variant='outline' color='gray'>
<IconText Icon={IconBrandGoogle} text='Sign in with Google' />
</Button>
<Button size='lg' fullWidth variant='outline'>
<Button size='lg' fullWidth variant='outline' color='gray'>
<IconText Icon={IconBrandDiscordFilled} text='Sign in with Discord' />
</Button>
<Button size='lg' fullWidth variant='outline'>
<Button size='lg' fullWidth variant='outline' color='gray'>
<IconText Icon={IconCircleKeyFilled} text='Sign in with Authentik' />
</Button>
</Stack>

View file

@ -1,73 +1,15 @@
import DashboardFile from '@/components/DashboardFile';
import Layout from '@/components/Layout';
import Stat from '@/components/Stat';
import { Response } from '@/lib/api/response';
import DashboardHome from '@/components/pages/dashboard';
import useLogin from '@/lib/hooks/useLogin';
import { Card, Group, LoadingOverlay, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { IconFiles } from '@tabler/icons-react';
import bytes from 'bytes';
import useSWR from 'swr';
import { LoadingOverlay } from '@mantine/core';
export default function DashboardIndex() {
const { user, loading } = useLogin();
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
const { loading } = useLogin();
if (loading) return <LoadingOverlay visible />;
return (
<Layout>
<Title order={1}>
Welcome back, <b>{user?.username}</b>
</Title>
<Text size='sm' color='dimmed'>
You have <b>{statsLoading ? '...' : stats?.filesUploaded}</b> files uploaded.
</Text>
<Title order={2} mt='md' mb='xs'>
Recent files
</Title>
{recentLoading ? (
<Paper withBorder p='md' radius='md' pos='relative' h={300}>
<LoadingOverlay visible />
</Paper>
) : (
<SimpleGrid cols={3} spacing='md' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{recent!.map((file) => (
<DashboardFile key={file.id} file={file} />
))}
</SimpleGrid>
)}
<Title order={2} mt='md'>
Stats
</Title>
<Text size='sm' color='dimmed' mb='xs'>
These statistics are based on your uploads only.
</Text>
{statsLoading ? (
<Paper withBorder p='md' radius='md' pos='relative' h={300}>
<LoadingOverlay visible />
</Paper>
) : (
<SimpleGrid cols={4} spacing='md' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
<Stat
Icon={IconFiles}
title='Storage used'
value={bytes(stats!.storageUsed, { unitSeparator: ' ' })}
/>
<Stat
Icon={IconFiles}
title='Average storage used'
value={bytes(stats!.avgStorageUsed, { unitSeparator: ' ' })}
/>
<Stat Icon={IconFiles} title='Views' value={stats!.views} />
<Stat Icon={IconFiles} title='Average views' value={stats!.avgViews} />
</SimpleGrid>
)}
<DashboardHome />
</Layout>
);
}

View file

@ -0,0 +1,16 @@
import Layout from '@/components/Layout';
import UploadFile from '@/components/pages/upload/File';
import useLogin from '@/lib/hooks/useLogin';
import { LoadingOverlay } from '@mantine/core';
export default function DashboardUploadFile() {
const { loading } = useLogin();
if (loading) return <LoadingOverlay visible />;
return (
<Layout>
<UploadFile />
</Layout>
);
}

View file

@ -0,0 +1,38 @@
import Layout from '@/components/Layout';
import UploadText from '@/components/pages/upload/Text';
import useLogin from '@/lib/hooks/useLogin';
import { LoadingOverlay } from '@mantine/core';
import { readFile } from 'fs/promises';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { join } from 'path';
export default function DashboardUploadText({
codeMeta,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { loading } = useLogin();
if (loading) return <LoadingOverlay visible />;
return (
<Layout>
<UploadText codeMeta={codeMeta} />
</Layout>
);
}
export const getServerSideProps: GetServerSideProps<{
codeMeta: {
ext: string;
mime: string;
name: string;
}[];
}> = async () => {
const read = await readFile(join(process.cwd(), 'code.json'));
const codeMeta = JSON.parse(read.toString());
return {
props: {
codeMeta,
},
};
};

View file

@ -1,7 +1,7 @@
import DashboardFileType from '@/components/DashboardFileType';
import DashboardFileType from '@/components/file/DashboardFileType';
import { prisma } from '@/lib/db';
import { fileSelect, type File } from '@/lib/db/models/file';
import { ActionIcon, Button, Center, Group, Paper, Space, Text } from '@mantine/core';
import { Button, Center, Group, Paper, Space, Text } from '@mantine/core';
import { IconFileDownload } from '@tabler/icons-react';
import bytes from 'bytes';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
@ -15,21 +15,6 @@ export default function ViewFile({ file }: InferGetServerSidePropsType<typeof ge
return (
<Center h='100vh'>
<Paper p='md' shadow='md' radius='md' withBorder>
{/* <Text size='lg' weight={700} mb='sm' sx={{ display: 'flex' }}>
{file.name}{' '}
<Text size='sm' color='dimmed' ml='sm' sx={{ alignSelf: 'center' }}>
{file.type}
</Text>
<ActionIcon
ml='sm'
variant='outline'
component={Link}
href={`/raw/${file.name}?download=true`}
target='_blank'
>
<IconFileDownload size='1rem' />
</ActionIcon>
</Text> */}
<Group position='apart' mb='sm'>
<Text size='lg' weight={700} sx={{ display: 'flex' }}>
{file.name}

323
yarn.lock
View file

@ -4483,6 +4483,13 @@ __metadata:
languageName: node
linkType: hard
"@types/katex@npm:^0.16.0":
version: 0.16.0
resolution: "@types/katex@npm:0.16.0"
checksum: f93ceb2496621d18a28252264c0b7f5b0bdf125f9dc92d1adfbd9bf00942cd2918de336fae628d3929e615aaf84b7adb1781711c4e4605664be0827b1013ec14
languageName: node
linkType: hard
"@types/keyv@npm:^3.1.4":
version: 3.1.4
resolution: "@types/keyv@npm:3.1.4"
@ -4573,7 +4580,7 @@ __metadata:
languageName: node
linkType: hard
"@types/prop-types@npm:*":
"@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0":
version: 15.7.5
resolution: "@types/prop-types@npm:15.7.5"
checksum: 5b43b8b15415e1f298243165f1d44390403bb2bd42e662bca3b5b5633fdd39c938e91b7fce3a9483699db0f7a715d08cef220c121f723a634972fdf596aec980
@ -5745,6 +5752,13 @@ __metadata:
languageName: node
linkType: hard
"ccount@npm:^2.0.0":
version: 2.0.1
resolution: "ccount@npm:2.0.1"
checksum: 48193dada54c9e260e0acf57fc16171a225305548f9ad20d5471e0f7a8c026aedd8747091dccb0d900cde7df4e4ddbd235df0d8de4a64c71b12f0d3303eeafd4
languageName: node
linkType: hard
"chalk@npm:^2.0.0, chalk@npm:^2.4.1":
version: 2.4.2
resolution: "chalk@npm:2.4.2"
@ -5997,6 +6011,13 @@ __metadata:
languageName: node
linkType: hard
"commander@npm:^8.3.0":
version: 8.3.0
resolution: "commander@npm:8.3.0"
checksum: 0f82321821fc27b83bd409510bb9deeebcfa799ff0bf5d102128b500b7af22872c0c92cb6a0ebc5a4cf19c6b550fba9cedfa7329d18c6442a625f851377bacf0
languageName: node
linkType: hard
"commander@npm:^9.4.0":
version: 9.5.0
resolution: "commander@npm:9.5.0"
@ -7129,6 +7150,13 @@ __metadata:
languageName: node
linkType: hard
"escape-string-regexp@npm:^5.0.0":
version: 5.0.0
resolution: "escape-string-regexp@npm:5.0.0"
checksum: 20daabe197f3cb198ec28546deebcf24b3dbb1a5a269184381b3116d12f0532e06007f4bc8da25669d6a7f8efb68db0758df4cd981f57bc5b57f521a3e12c59e
languageName: node
linkType: hard
"escodegen@npm:^1.8.1":
version: 1.14.3
resolution: "escodegen@npm:1.14.3"
@ -8576,6 +8604,13 @@ __metadata:
languageName: node
linkType: hard
"highlight.js@npm:^11.8.0":
version: 11.8.0
resolution: "highlight.js@npm:11.8.0"
checksum: d2578a57aee7315946ff19379053fd0a28b127baabf7617ab1d28d62cdc4eaf3d75053569cb8479a5afdc7a68f1ba9a6c1d612d8ae399b4b9aa43093b4fb6831
languageName: node
linkType: hard
"hoist-non-react-statics@npm:^3.3.1":
version: 3.3.2
resolution: "hoist-non-react-statics@npm:3.3.2"
@ -9589,6 +9624,17 @@ __metadata:
languageName: node
linkType: hard
"katex@npm:^0.16.8":
version: 0.16.8
resolution: "katex@npm:0.16.8"
dependencies:
commander: ^8.3.0
bin:
katex: cli.js
checksum: 4e75b4786101cc5eca0404bb814b2985bec506846f9015e9bf00207a3af14215e341ee62b6e7af2455a1032f8244e47a754642f250eea43d7b8007146ac01fae
languageName: node
linkType: hard
"keyv@npm:^4.0.0":
version: 4.5.2
resolution: "keyv@npm:4.5.2"
@ -9967,6 +10013,13 @@ __metadata:
languageName: node
linkType: hard
"markdown-table@npm:^3.0.0":
version: 3.0.3
resolution: "markdown-table@npm:3.0.3"
checksum: 8fcd3d9018311120fbb97115987f8b1665a603f3134c93fbecc5d1463380c8036f789e2a62c19432058829e594fff8db9ff81c88f83690b2f8ed6c074f8d9e10
languageName: node
linkType: hard
"mdast-util-definitions@npm:^5.0.0":
version: 5.1.2
resolution: "mdast-util-definitions@npm:5.1.2"
@ -9978,6 +10031,18 @@ __metadata:
languageName: node
linkType: hard
"mdast-util-find-and-replace@npm:^2.0.0":
version: 2.2.2
resolution: "mdast-util-find-and-replace@npm:2.2.2"
dependencies:
"@types/mdast": ^3.0.0
escape-string-regexp: ^5.0.0
unist-util-is: ^5.0.0
unist-util-visit-parents: ^5.0.0
checksum: b4ce463c43fe6e1c38a53a89703f755c84ab5437f49bff9a0ac751279733332ca11c85ed0262aa6c17481f77b555d26ca6d64e70d6814f5b8d12d34a3e53a60b
languageName: node
linkType: hard
"mdast-util-from-markdown@npm:^1.0.0":
version: 1.3.1
resolution: "mdast-util-from-markdown@npm:1.3.1"
@ -10009,6 +10074,76 @@ __metadata:
languageName: node
linkType: hard
"mdast-util-gfm-autolink-literal@npm:^1.0.0":
version: 1.0.3
resolution: "mdast-util-gfm-autolink-literal@npm:1.0.3"
dependencies:
"@types/mdast": ^3.0.0
ccount: ^2.0.0
mdast-util-find-and-replace: ^2.0.0
micromark-util-character: ^1.0.0
checksum: 1748a8727cfc533bac0c287d6e72d571d165bfa77ae0418be4828177a3ec73c02c3f2ee534d87eb75cbaffa00c0866853bbcc60ae2255babb8210f7636ec2ce2
languageName: node
linkType: hard
"mdast-util-gfm-footnote@npm:^1.0.0":
version: 1.0.2
resolution: "mdast-util-gfm-footnote@npm:1.0.2"
dependencies:
"@types/mdast": ^3.0.0
mdast-util-to-markdown: ^1.3.0
micromark-util-normalize-identifier: ^1.0.0
checksum: 2d77505f9377ed7e14472ef5e6b8366c3fec2cf5f936bb36f9fbe5b97ccb7cce0464d9313c236fa86fb844206fd585db05707e4fcfb755e4fc1864194845f1f6
languageName: node
linkType: hard
"mdast-util-gfm-strikethrough@npm:^1.0.0":
version: 1.0.3
resolution: "mdast-util-gfm-strikethrough@npm:1.0.3"
dependencies:
"@types/mdast": ^3.0.0
mdast-util-to-markdown: ^1.3.0
checksum: 17003340ff1bba643ec4a59fd4370fc6a32885cab2d9750a508afa7225ea71449fb05acaef60faa89c6378b8bcfbd86a9d94b05f3c6651ff27a60e3ddefc2549
languageName: node
linkType: hard
"mdast-util-gfm-table@npm:^1.0.0":
version: 1.0.7
resolution: "mdast-util-gfm-table@npm:1.0.7"
dependencies:
"@types/mdast": ^3.0.0
markdown-table: ^3.0.0
mdast-util-from-markdown: ^1.0.0
mdast-util-to-markdown: ^1.3.0
checksum: 8b8c401bb4162e53f072a2dff8efbca880fd78d55af30601c791315ab6722cb2918176e8585792469a0c530cebb9df9b4e7fede75fdc4d83df2839e238836692
languageName: node
linkType: hard
"mdast-util-gfm-task-list-item@npm:^1.0.0":
version: 1.0.2
resolution: "mdast-util-gfm-task-list-item@npm:1.0.2"
dependencies:
"@types/mdast": ^3.0.0
mdast-util-to-markdown: ^1.3.0
checksum: c9b86037d6953b84f11fb2fc3aa23d5b8e14ca0dfcb0eb2fb289200e172bb9d5647bfceb4f86606dc6d935e8d58f6a458c04d3e55e87ff8513c7d4ade976200b
languageName: node
linkType: hard
"mdast-util-gfm@npm:^2.0.0":
version: 2.0.2
resolution: "mdast-util-gfm@npm:2.0.2"
dependencies:
mdast-util-from-markdown: ^1.0.0
mdast-util-gfm-autolink-literal: ^1.0.0
mdast-util-gfm-footnote: ^1.0.0
mdast-util-gfm-strikethrough: ^1.0.0
mdast-util-gfm-table: ^1.0.0
mdast-util-gfm-task-list-item: ^1.0.0
mdast-util-to-markdown: ^1.0.0
checksum: 7078cb985255208bcbce94a121906417d38353c6b1a9acbe56ee8888010d3500608b5d51c16b0999ac63ca58848fb13012d55f26930ff6c6f3450f053d56514e
languageName: node
linkType: hard
"mdast-util-mdx-expression@npm:^1.0.0":
version: 1.3.2
resolution: "mdast-util-mdx-expression@npm:1.3.2"
@ -10089,6 +10224,22 @@ __metadata:
languageName: node
linkType: hard
"mdast-util-to-hast@npm:^12.1.0":
version: 12.3.0
resolution: "mdast-util-to-hast@npm:12.3.0"
dependencies:
"@types/hast": ^2.0.0
"@types/mdast": ^3.0.0
mdast-util-definitions: ^5.0.0
micromark-util-sanitize-uri: ^1.1.0
trim-lines: ^3.0.0
unist-util-generated: ^2.0.0
unist-util-position: ^4.0.0
unist-util-visit: ^4.0.0
checksum: ea40c9f07dd0b731754434e81c913590c611b1fd753fa02550a1492aadfc30fb3adecaf62345ebb03cea2ddd250c15ab6e578fffde69c19955c9b87b10f2a9bb
languageName: node
linkType: hard
"mdast-util-to-markdown@npm:^1.0.0, mdast-util-to-markdown@npm:^1.3.0":
version: 1.5.0
resolution: "mdast-util-to-markdown@npm:1.5.0"
@ -10215,6 +10366,99 @@ __metadata:
languageName: node
linkType: hard
"micromark-extension-gfm-autolink-literal@npm:^1.0.0":
version: 1.0.5
resolution: "micromark-extension-gfm-autolink-literal@npm:1.0.5"
dependencies:
micromark-util-character: ^1.0.0
micromark-util-sanitize-uri: ^1.0.0
micromark-util-symbol: ^1.0.0
micromark-util-types: ^1.0.0
checksum: ec2f6bc4a3eb238c1b8be9744454ffbc2957e3d8a248697af5a26bb21479862300c0e40e0a92baf17c299ddf70d4bc4470d4eee112cd92322f87d81e45c2e83d
languageName: node
linkType: hard
"micromark-extension-gfm-footnote@npm:^1.0.0":
version: 1.1.2
resolution: "micromark-extension-gfm-footnote@npm:1.1.2"
dependencies:
micromark-core-commonmark: ^1.0.0
micromark-factory-space: ^1.0.0
micromark-util-character: ^1.0.0
micromark-util-normalize-identifier: ^1.0.0
micromark-util-sanitize-uri: ^1.0.0
micromark-util-symbol: ^1.0.0
micromark-util-types: ^1.0.0
uvu: ^0.5.0
checksum: c151a629ee1cd92363c018a50f926a002c944ac481ca72b3720b9529e9c20f1cbef98b0fefdcd2d594af37d0d9743673409cac488af0d2b194210fd16375dcb7
languageName: node
linkType: hard
"micromark-extension-gfm-strikethrough@npm:^1.0.0":
version: 1.0.7
resolution: "micromark-extension-gfm-strikethrough@npm:1.0.7"
dependencies:
micromark-util-chunked: ^1.0.0
micromark-util-classify-character: ^1.0.0
micromark-util-resolve-all: ^1.0.0
micromark-util-symbol: ^1.0.0
micromark-util-types: ^1.0.0
uvu: ^0.5.0
checksum: 169e310a4408feade0df80180f60d48c5cc5b7070e5e75e0bbd914e9100273508162c4bb20b72d53081dc37f1ff5834b3afa137862576f763878552c03389811
languageName: node
linkType: hard
"micromark-extension-gfm-table@npm:^1.0.0":
version: 1.0.7
resolution: "micromark-extension-gfm-table@npm:1.0.7"
dependencies:
micromark-factory-space: ^1.0.0
micromark-util-character: ^1.0.0
micromark-util-symbol: ^1.0.0
micromark-util-types: ^1.0.0
uvu: ^0.5.0
checksum: 4853731285224e409d7e2c94c6ec849165093bff819e701221701aa7b7b34c17702c44f2f831e96b49dc27bb07e445b02b025561b68e62f5c3254415197e7af6
languageName: node
linkType: hard
"micromark-extension-gfm-tagfilter@npm:^1.0.0":
version: 1.0.2
resolution: "micromark-extension-gfm-tagfilter@npm:1.0.2"
dependencies:
micromark-util-types: ^1.0.0
checksum: 7d2441df51f890c86f8e7cf7d331a570b69c8105fa1c2fc5b737cb739502c16c8ee01cf35550a8a78f89497c5dfacc97cf82d55de6274e8320f3aec25e2b0dd2
languageName: node
linkType: hard
"micromark-extension-gfm-task-list-item@npm:^1.0.0":
version: 1.0.5
resolution: "micromark-extension-gfm-task-list-item@npm:1.0.5"
dependencies:
micromark-factory-space: ^1.0.0
micromark-util-character: ^1.0.0
micromark-util-symbol: ^1.0.0
micromark-util-types: ^1.0.0
uvu: ^0.5.0
checksum: 929f05343d272cffb8008899289f4cffe986ef98fc622ebbd1aa4ff11470e6b32ed3e1f18cd294adb69cabb961a400650078f6c12b322cc515b82b5068b31960
languageName: node
linkType: hard
"micromark-extension-gfm@npm:^2.0.0":
version: 2.0.3
resolution: "micromark-extension-gfm@npm:2.0.3"
dependencies:
micromark-extension-gfm-autolink-literal: ^1.0.0
micromark-extension-gfm-footnote: ^1.0.0
micromark-extension-gfm-strikethrough: ^1.0.0
micromark-extension-gfm-table: ^1.0.0
micromark-extension-gfm-tagfilter: ^1.0.0
micromark-extension-gfm-task-list-item: ^1.0.0
micromark-util-combine-extensions: ^1.0.0
micromark-util-types: ^1.0.0
checksum: c4a917c16d7aa5d00d1767b5ce5f3b1a78c0de11dbd5c8f69d2545083568aa6bb13bd9d8e4c7fec5f4da10e7ed8344b15acffc843b33a615c17396a118bc2bc1
languageName: node
linkType: hard
"micromark-extension-mdx-expression@npm:^1.0.0":
version: 1.0.8
resolution: "micromark-extension-mdx-expression@npm:1.0.8"
@ -10473,7 +10717,7 @@ __metadata:
languageName: node
linkType: hard
"micromark-util-sanitize-uri@npm:^1.0.0":
"micromark-util-sanitize-uri@npm:^1.0.0, micromark-util-sanitize-uri@npm:^1.1.0":
version: 1.2.0
resolution: "micromark-util-sanitize-uri@npm:1.2.0"
dependencies:
@ -12167,7 +12411,7 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
"prop-types@npm:^15.0.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
@ -12349,6 +12593,39 @@ __metadata:
languageName: node
linkType: hard
"react-is@npm:^18.0.0":
version: 18.2.0
resolution: "react-is@npm:18.2.0"
checksum: e72d0ba81b5922759e4aff17e0252bd29988f9642ed817f56b25a3e217e13eea8a7f2322af99a06edb779da12d5d636e9fda473d620df9a3da0df2a74141d53e
languageName: node
linkType: hard
"react-markdown@npm:^8.0.7":
version: 8.0.7
resolution: "react-markdown@npm:8.0.7"
dependencies:
"@types/hast": ^2.0.0
"@types/prop-types": ^15.0.0
"@types/unist": ^2.0.0
comma-separated-tokens: ^2.0.0
hast-util-whitespace: ^2.0.0
prop-types: ^15.0.0
property-information: ^6.0.0
react-is: ^18.0.0
remark-parse: ^10.0.0
remark-rehype: ^10.0.0
space-separated-tokens: ^2.0.0
style-to-object: ^0.4.0
unified: ^10.0.0
unist-util-visit: ^4.0.0
vfile: ^5.0.0
peerDependencies:
"@types/react": ">=16"
react: ">=16"
checksum: 0f3e570975134a3382c3fe5189e04e742ae154941463bdfaab2293319da1f1585cb9b75b6f07d99f514c4d728d69cc1af3c96ab37df90003b3bcc210dd0001ba
languageName: node
linkType: hard
"react-property@npm:2.0.0":
version: 2.0.0
resolution: "react-property@npm:2.0.0"
@ -12653,6 +12930,18 @@ __metadata:
languageName: node
linkType: hard
"remark-gfm@npm:^3.0.1":
version: 3.0.1
resolution: "remark-gfm@npm:3.0.1"
dependencies:
"@types/mdast": ^3.0.0
mdast-util-gfm: ^2.0.0
micromark-extension-gfm: ^2.0.0
unified: ^10.0.0
checksum: 02254f74d67b3419c2c9cf62d799ec35f6c6cd74db25c001361751991552a7ce86049a972107bff8122d85d15ae4a8d1a0618f3bc01a7df837af021ae9b2a04e
languageName: node
linkType: hard
"remark-mdx-frontmatter@npm:^1.0.1":
version: 1.1.1
resolution: "remark-mdx-frontmatter@npm:1.1.1"
@ -12676,6 +12965,18 @@ __metadata:
languageName: node
linkType: hard
"remark-rehype@npm:^10.0.0":
version: 10.1.0
resolution: "remark-rehype@npm:10.1.0"
dependencies:
"@types/hast": ^2.0.0
"@types/mdast": ^3.0.0
mdast-util-to-hast: ^12.1.0
unified: ^10.0.0
checksum: b9ac8acff3383b204dfdc2599d0bdf86e6ca7e837033209584af2e6aaa6a9013e519a379afa3201299798cab7298c8f4b388de118c312c67234c133318aec084
languageName: node
linkType: hard
"remark-rehype@npm:^9.0.0":
version: 9.1.0
resolution: "remark-rehype@npm:9.1.0"
@ -13609,7 +13910,7 @@ __metadata:
languageName: node
linkType: hard
"style-to-object@npm:^0.4.1":
"style-to-object@npm:^0.4.0, style-to-object@npm:^0.4.1":
version: 0.4.1
resolution: "style-to-object@npm:0.4.1"
dependencies:
@ -13986,6 +14287,13 @@ __metadata:
languageName: node
linkType: hard
"trim-lines@npm:^3.0.0":
version: 3.0.1
resolution: "trim-lines@npm:3.0.1"
checksum: e241da104682a0e0d807222cc1496b92e716af4db7a002f4aeff33ae6a0024fef93165d49eab11aa07c71e1347c42d46563f91dfaa4d3fb945aa535cdead53ed
languageName: node
linkType: hard
"trough@npm:^2.0.0":
version: 2.1.0
resolution: "trough@npm:2.1.0"
@ -14364,7 +14672,7 @@ __metadata:
languageName: node
linkType: hard
"unist-util-visit-parents@npm:^5.1.1":
"unist-util-visit-parents@npm:^5.0.0, unist-util-visit-parents@npm:^5.1.1":
version: 5.1.3
resolution: "unist-util-visit-parents@npm:5.1.3"
dependencies:
@ -15000,6 +15308,7 @@ __metadata:
"@tabler/icons-react": ^2.23.0
"@types/bytes": ^3.1.1
"@types/express": ^4.17.17
"@types/katex": ^0.16.0
"@types/multer": ^1.4.7
"@types/node": ^20.3.1
"@types/react": ^18.2.7
@ -15013,6 +15322,8 @@ __metadata:
dotenv: ^16.1.3
eslint: ^8.41.0
express: ^4.18.2
highlight.js: ^11.8.0
katex: ^0.16.8
ms: ^2.1.3
multer: ^1.4.5-lts.1
next: ^13.4.7
@ -15020,6 +15331,8 @@ __metadata:
prisma: ^4.16.1
react: ^18.2.0
react-dom: ^18.2.0
react-markdown: ^8.0.7
remark-gfm: ^3.0.1
swr: ^2.2.0
tsup: ^7.0.0
typescript: ^5.1.3