mirror of
https://github.com/diced/zipline.git
synced 2026-06-12 19:01:18 -07:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 754c54542e | |||
| 19b7e6f938 | |||
| cd22a8915e | |||
| 97a75c0f84 | |||
| d5e2bc3ec2 | |||
| 6f90339f17 | |||
| 833f8a30cc | |||
| e6382b3881 | |||
| e3789446c2 | |||
| 6c94abc73b | |||
| 8fb21988a7 | |||
| 72fc8116d4 | |||
| 177febf305 | |||
| a8c65c19b4 | |||
| 3fc3dcd1ed | |||
| 0c52b48c05 | |||
| 5c386a792e | |||
| c4f8aa52a4 | |||
| 5c0097fed5 | |||
| 6ebd8f68f9 | |||
| f6188cf15b | |||
| c9cbc2322f |
@@ -11,7 +11,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [22.x, 24.x]
|
||||
node: [24.x, 26.x]
|
||||
arch: [amd64, arm64]
|
||||
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}
|
||||
|
||||
|
||||
+10
-10
@@ -1,23 +1,25 @@
|
||||
FROM node:22-alpine3.21 AS base
|
||||
FROM node:24-alpine3.22 AS base
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache ffmpeg tzdata
|
||||
RUN corepack enable \
|
||||
&& apk add --no-cache ffmpeg=6.1.2-r2 tzdata=2026b-r0
|
||||
|
||||
WORKDIR /zipline
|
||||
|
||||
COPY prisma ./prisma
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
COPY pnpm-workspace.yaml .
|
||||
|
||||
FROM base AS deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY src ./src
|
||||
COPY .gitignore ./.gitignore
|
||||
@@ -44,10 +46,8 @@ COPY --from=builder /zipline/build ./build
|
||||
COPY --from=builder /zipline/mimes.json ./mimes.json
|
||||
COPY --from=builder /zipline/code.json ./code.json
|
||||
|
||||
RUN pnpm prisma generate
|
||||
|
||||
# clean
|
||||
RUN rm -rf /tmp/* /root/*
|
||||
RUN pnpm prisma generate \
|
||||
&& rm -rf /tmp/* /root/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ZIPLINE_ROOT=/zipline
|
||||
|
||||
@@ -85,6 +85,7 @@ export default defineConfig(
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'jsx-a11y/media-has-caption': 'off',
|
||||
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
+32
-31
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.6.0",
|
||||
"version": "4.6.2",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
|
||||
@@ -22,8 +22,8 @@
|
||||
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1032.0",
|
||||
"@aws-sdk/lib-storage": "3.1032.0",
|
||||
"@aws-sdk/client-s3": "3.1046.0",
|
||||
"@aws-sdk/lib-storage": "3.1046.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -32,17 +32,17 @@
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^9.1.1",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@mantine/charts": "^9.0.2",
|
||||
"@mantine/code-highlight": "^9.0.2",
|
||||
"@mantine/core": "^9.0.2",
|
||||
"@mantine/dates": "^9.0.2",
|
||||
"@mantine/dropzone": "^9.0.2",
|
||||
"@mantine/form": "^9.0.2",
|
||||
"@mantine/hooks": "^9.0.2",
|
||||
"@mantine/modals": "^9.0.2",
|
||||
"@mantine/notifications": "^9.0.2",
|
||||
"@mantine/charts": "^9.2.0",
|
||||
"@mantine/code-highlight": "^9.2.0",
|
||||
"@mantine/core": "^9.2.0",
|
||||
"@mantine/dates": "^9.2.0",
|
||||
"@mantine/dropzone": "^9.2.0",
|
||||
"@mantine/form": "^9.2.0",
|
||||
"@mantine/hooks": "^9.2.0",
|
||||
"@mantine/modals": "^9.2.0",
|
||||
"@mantine/notifications": "^9.2.0",
|
||||
"@prisma/adapter-pg": "6.13.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"@prisma/engines": "6.13.0",
|
||||
@@ -50,9 +50,9 @@
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@smithy/node-http-handler": "^4.5.3",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"archiver": "^7.0.1",
|
||||
"@smithy/node-http-handler": "^4.7.2",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"archiver": "7.0.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.15.1",
|
||||
"bytes": "^3.1.2",
|
||||
@@ -63,7 +63,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"detect-browser": "^5.3.0",
|
||||
"devalue": "^5.7.1",
|
||||
"devalue": "^5.8.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
@@ -72,24 +72,25 @@
|
||||
"he": "^1.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^3.9.0",
|
||||
"katex": "^0.16.45",
|
||||
"mantine-datatable": "^8.3.13",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"katex": "^0.16.46",
|
||||
"mantine-datatable": "^9.2.0",
|
||||
"marked-react": "^4.0.0",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.1.1",
|
||||
"nuqs": "^2.8.9",
|
||||
"otplib": "^13.4.0",
|
||||
"prisma": "6.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"react-virtuoso": "^4.18.5",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"sharp": "^0.34.5",
|
||||
"swr": "^2.4.1",
|
||||
"vite": "^8.0.9",
|
||||
"vite": "^8.0.12",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
@@ -104,7 +105,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
@@ -112,19 +113,19 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"postcss": "^8.5.10",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.98.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsc-alias": "^1.8.17",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.58.2"
|
||||
"typescript-eslint": "^8.59.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499"
|
||||
}
|
||||
|
||||
Generated
+989
-1425
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,11 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
argon2: true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
sharp: true
|
||||
ignoredBuiltDependencies:
|
||||
- unrs-resolver
|
||||
onlyBuiltDependencies:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "filesDisabledTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "filesDisabledTypesDefault" TEXT;
|
||||
@@ -36,6 +36,8 @@ model Zipline {
|
||||
filesRoute String @default("/u")
|
||||
filesLength Int @default(6)
|
||||
filesDefaultFormat String @default("random")
|
||||
filesDisabledTypes String[] @default([])
|
||||
filesDisabledTypesDefault String?
|
||||
filesDisabledExtensions String[]
|
||||
filesMaxFileSize String @default("100mb")
|
||||
filesDefaultExpiration String?
|
||||
|
||||
+5
-1
@@ -6,6 +6,7 @@ import ThemeProvider from '@/components/ThemeProvider';
|
||||
import { type ZiplineTheme } from '@/lib/theme';
|
||||
import { type Config } from '@/lib/config/validate';
|
||||
import { Button, Text } from '@mantine/core';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||
|
||||
const AlertModal = ({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) => (
|
||||
<>
|
||||
@@ -61,7 +62,10 @@ export default function Root({
|
||||
modals={contextModals}
|
||||
>
|
||||
<Notifications position='top-center' zIndex={10000000} />
|
||||
<Outlet />
|
||||
|
||||
<NuqsAdapter>
|
||||
<Outlet />
|
||||
</NuqsAdapter>
|
||||
</ModalsProvider>
|
||||
</ThemeProvider>
|
||||
</SWRConfig>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useApiPagination } from '@/components/pages/files/useApiPagination';
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { useFileNavStore } from '@/lib/client/store/fileNav';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
@@ -24,6 +23,7 @@ import { IconFolder, IconUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
import { Link, Params, useLoaderData, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { useQueryState, parseAsInteger } from 'nuqs';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
|
||||
@@ -47,7 +47,7 @@ export async function loader({ params, request }: { params: Params<string>; requ
|
||||
function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
|
||||
return (
|
||||
<Link to={`/folder/${folder.id}`} style={{ textDecoration: 'none' }}>
|
||||
<Card withBorder shadow='sm' radius='sm' style={{ cursor: 'pointer' }}>
|
||||
<Card withBorder shadow='sm' style={{ cursor: 'pointer' }}>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Group gap='xs'>
|
||||
<IconFolder size='1.2rem' />
|
||||
@@ -78,8 +78,8 @@ export function Component() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage] = useQueryState('perpage', 15);
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [perpage] = useQueryState('perpage', parseAsInteger.withDefault(15));
|
||||
|
||||
const { data, isLoading } = useApiPagination<Response['/api/server/folder/[id]']>(
|
||||
{
|
||||
|
||||
@@ -168,9 +168,13 @@ export async function render(
|
||||
const safeOriginalName = stripHtml(file.originalName || '');
|
||||
const safeType = stripHtml(file.type || '');
|
||||
|
||||
const meta = `
|
||||
${
|
||||
user?.view?.embedTitle && user.view.embed
|
||||
const viewEnabled = !!user.view?.enabled;
|
||||
const showRichOg = viewEnabled && !!user.view.embed;
|
||||
const showMediaOg = viewEnabled && (!!user.view.embed || !!user.view.embedMediaOnly);
|
||||
const pageUrl = `${host}${url.split('?')[0]}`;
|
||||
|
||||
const richMeta = [
|
||||
showRichOg && user?.view?.embedTitle
|
||||
? `<meta property="og:title" content="${stripHtml(
|
||||
parseString(user.view.embedTitle, {
|
||||
file: file as unknown as File,
|
||||
@@ -178,10 +182,8 @@ export async function render(
|
||||
...metrics,
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedDescription && user.view.embed
|
||||
: '',
|
||||
showRichOg && user?.view?.embedDescription
|
||||
? `<meta property="og:description" content="${stripHtml(
|
||||
parseString(user.view.embedDescription, {
|
||||
file: file as unknown as File,
|
||||
@@ -189,10 +191,8 @@ export async function render(
|
||||
...metrics,
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedSiteName && user.view.embed
|
||||
: '',
|
||||
showRichOg && user?.view?.embedSiteName
|
||||
? `<meta property="og:site_name" content="${stripHtml(
|
||||
parseString(user.view.embedSiteName, {
|
||||
file: file as unknown as File,
|
||||
@@ -200,10 +200,8 @@ export async function render(
|
||||
...metrics,
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedColor && user.view.embed
|
||||
: '',
|
||||
showRichOg && user?.view?.embedColor
|
||||
? `<meta property="theme-color" content="${stripHtml(
|
||||
parseString(user.view.embedColor, {
|
||||
file: file as unknown as File,
|
||||
@@ -211,67 +209,70 @@ export async function render(
|
||||
...metrics,
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ');
|
||||
|
||||
${
|
||||
file.type?.startsWith('image')
|
||||
const imageOg =
|
||||
showMediaOg && file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:type" content="image" />
|
||||
<meta property="og:image" itemProp="image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="twitter:title" content="${safeFilename}" />
|
||||
${showRichOg ? `<meta property="twitter:title" content="${safeFilename}" />` : ''}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
${
|
||||
file.type?.startsWith('video')
|
||||
const videoOg =
|
||||
showMediaOg && file.type?.startsWith('video')
|
||||
? `
|
||||
${file.thumbnail ? `<meta property="og:image" content="${host}/raw/${file.thumbnail.path}" />` : ''}
|
||||
<meta property="og:type" content="video.other" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
<meta property="og:video:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:video:width" content="1920" />
|
||||
<meta property="og:video:height" content="1080" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
${
|
||||
file.type?.startsWith('audio')
|
||||
const audioOg =
|
||||
showMediaOg && file.type?.startsWith('audio')
|
||||
? `
|
||||
<meta name="twitter:card" content="player" />
|
||||
<meta name="twitter:player" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream:content_type" content="${safeType}" />
|
||||
<meta name="twitter:title" content="${safeFilename}" />
|
||||
${showRichOg ? `<meta name="twitter:title" content="${safeFilename}" />` : ''}
|
||||
<meta name="twitter:player:width" content="720" />
|
||||
<meta name="twitter:player:height" content="480" />
|
||||
|
||||
<meta property="og:type" content="music.song" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
<meta property="og:audio" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:secure_url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:type" content="${safeType}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
${
|
||||
!file.type?.startsWith('video') && !file.type?.startsWith('image')
|
||||
const otherOg =
|
||||
showRichOg && !file.type?.startsWith('video') && !file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
<title>${file.originalName ? safeOriginalName : safeFilename}</title>
|
||||
`;
|
||||
const docTitle = `<title>${file.originalName ? safeOriginalName : safeFilename}</title>`;
|
||||
|
||||
const includeHead = showRichOg || showMediaOg;
|
||||
const headMeta = includeHead
|
||||
? [richMeta, imageOg, videoOg, audioOg, otherOg, docTitle].filter(Boolean).join('\n')
|
||||
: '';
|
||||
|
||||
return {
|
||||
html,
|
||||
meta: `${user.view.embed ? meta : ''}\n${createZiplineSsr(data)}`,
|
||||
meta: `${headMeta ? `${headMeta}\n` : ''}${createZiplineSsr(data)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,3 +5,11 @@
|
||||
font-weight: 700;
|
||||
font-size: var(--mantine-font-size-xl);
|
||||
}
|
||||
|
||||
.mantine-Table-th {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mantine-datatable {
|
||||
border-radius: var(--mantine-radius-default);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ export default function ThemeProvider({
|
||||
forceColorScheme={theme.colorScheme as unknown as any}
|
||||
theme={createTheme({
|
||||
...themeComponents(theme),
|
||||
defaultRadius: 'md',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function FileModal({
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<DashboardFileType file={file} show />
|
||||
{open && <DashboardFileType file={file} show />}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing='md' my='xs'>
|
||||
<FileStat Icon={IconFileInfo} title='Type' value={file.type} />
|
||||
|
||||
@@ -542,7 +542,7 @@ export default function FileViewer({
|
||||
overscrollBehavior: 'contain',
|
||||
}}
|
||||
>
|
||||
{file ? (
|
||||
{open && file ? (
|
||||
<Box
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
@@ -550,10 +550,11 @@ export default function FileViewer({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
height: 'fit-content',
|
||||
alignSelf: 'stretch',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
width: '100%',
|
||||
overflow: 'visible',
|
||||
paddingLeft: '4rem',
|
||||
paddingRight: '4rem',
|
||||
@@ -568,7 +569,7 @@ export default function FileViewer({
|
||||
scrollParent={scrollParent}
|
||||
/>
|
||||
|
||||
{open && sequenced && fileNavButtons && file && (
|
||||
{sequenced && fileNavButtons && file && (
|
||||
<>
|
||||
<ActionButton
|
||||
Icon={IconChevronLeft}
|
||||
|
||||
@@ -36,6 +36,24 @@ export function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenSizedMedia({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
alignSelf: 'stretch',
|
||||
minHeight: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardFileType({
|
||||
file,
|
||||
show,
|
||||
@@ -54,6 +72,8 @@ export default function DashboardFileType({
|
||||
scrollParent?: HTMLElement | null;
|
||||
}) {
|
||||
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
|
||||
const mediaAutoMuted = useSettingsStore((state) => state.settings.mediaAutoMuted);
|
||||
|
||||
const { fileUrl, thumbnailUrl, viewUrl } = useFileUrls({ file, token });
|
||||
const db = isDbFile(file) ? file : null;
|
||||
|
||||
@@ -97,9 +117,6 @@ export default function DashboardFileType({
|
||||
}
|
||||
|
||||
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
|
||||
const mediaMax = fullscreen
|
||||
? { maxWidth: 'min(96vw, calc(100vw - 3rem))', maxHeight: 'calc(100vh - 7.5rem)' }
|
||||
: undefined;
|
||||
|
||||
if (type === 'video') {
|
||||
if (!fileUrl) return <Loader />;
|
||||
@@ -123,21 +140,24 @@ export default function DashboardFileType({
|
||||
return <Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
const video = (
|
||||
<video
|
||||
width='100%'
|
||||
width={fullscreen ? undefined : '100%'}
|
||||
autoPlay
|
||||
muted
|
||||
muted={mediaAutoMuted}
|
||||
controls
|
||||
src={fileUrl}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
objectFit: 'contain',
|
||||
...(fullscreen
|
||||
? { ...mediaMax, width: 'auto', height: 'auto' }
|
||||
: { maxWidth: '85vw', maxHeight: '85vh' }),
|
||||
? { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto' }
|
||||
: { maxWidth: '85vw', maxHeight: '85vh', width: '100%' }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return fullscreen ? <FullscreenSizedMedia>{video}</FullscreenSizedMedia> : video;
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
@@ -147,20 +167,26 @@ export default function DashboardFileType({
|
||||
return <MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />;
|
||||
}
|
||||
|
||||
const image = (
|
||||
<MantineImage
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
fit='contain'
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
...(fullscreen
|
||||
? { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto' }
|
||||
: { maxWidth: '70vw', maxHeight: '70vw' }),
|
||||
}}
|
||||
onClick={() => allowZoom && setZoomOpen(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<MantineImage
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
objectFit: 'contain',
|
||||
...(fullscreen
|
||||
? { ...mediaMax, width: 'auto', height: 'auto' }
|
||||
: { maxWidth: '70vw', maxHeight: '70vw' }),
|
||||
}}
|
||||
onClick={() => allowZoom && setZoomOpen(true)}
|
||||
/>
|
||||
<>
|
||||
{fullscreen ? <FullscreenSizedMedia>{image}</FullscreenSizedMedia> : <Center>{image}</Center>}
|
||||
{allowZoom && zoomOpen && (
|
||||
<FileZoomModal setOpen={setZoomOpen}>
|
||||
<MantineImage
|
||||
@@ -176,14 +202,14 @@ export default function DashboardFileType({
|
||||
/>
|
||||
</FileZoomModal>
|
||||
)}
|
||||
</Center>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'audio') {
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<audio autoPlay muted controls style={{ width: '100%' }} src={fileUrl} />
|
||||
<audio autoPlay muted={mediaAutoMuted} controls style={{ width: '100%' }} src={fileUrl} />
|
||||
) : (
|
||||
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import Stat from '@/components/Stat';
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { Button, Group, Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
@@ -17,11 +18,12 @@ import { lazy, Suspense } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
const ActivityChart = lazy(() => import('./parts/ActivityChart'));
|
||||
const Recents = lazy(() => import('./parts/Recents'));
|
||||
|
||||
export default function DashboardHome() {
|
||||
const { user } = useLogin();
|
||||
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
|
||||
const { homeShowActivity, homeShowRecents, homeShowTypes } = useSettingsStore((state) => state.settings);
|
||||
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
|
||||
|
||||
const config = useConfig();
|
||||
@@ -38,6 +40,32 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
</Skeleton>
|
||||
|
||||
{homeShowRecents && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Paper radius='md' withBorder p='md' mt='lg'>
|
||||
<Skeleton height={24} width={180} mb='xs' animate />
|
||||
<Skeleton height={260} mt='md' animate />
|
||||
</Paper>
|
||||
}
|
||||
>
|
||||
<Group mt='md' mb='xs' style={{ alignItems: 'center' }}>
|
||||
<Title order={2}>Recent files</Title>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='compact-xs'
|
||||
component={Link}
|
||||
to='/dashboard/files'
|
||||
leftSection={<IconFiles size='1rem' />}
|
||||
>
|
||||
View all files
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Recents />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{user?.quota && (user.quota.maxBytes || user.quota.maxFiles) ? (
|
||||
<Text size='sm' c='dimmed'>
|
||||
{user.quota.filesQuota === 'BY_BYTES' ? (
|
||||
@@ -60,41 +88,9 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group mt='md' mb='xs' style={{ alignItems: 'center' }}>
|
||||
<Title order={2}>Recent files</Title>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='compact-xs'
|
||||
component={Link}
|
||||
to='/dashboard/files'
|
||||
leftSection={<IconFiles size='1rem' />}
|
||||
>
|
||||
View all files
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{recentLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} height={350} animate />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : recent?.length !== 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{recent!.map((file, i) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={i}>
|
||||
<DashboardFile file={file} />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size='sm' c='dimmed'>
|
||||
You have no recent files. The last three files you uploaded will appear here.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group mt='md' style={{ alignItems: 'center' }}>
|
||||
<Title order={2}>Stats</Title>
|
||||
|
||||
{(!config.features?.metrics?.adminOnly || isAdministrator(user?.role)) && (
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -113,90 +109,98 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
|
||||
{statsLoading ? (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} height={105} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Title order={3} mt='lg' mb='xs'>
|
||||
File types
|
||||
</Title>
|
||||
|
||||
<Paper radius='sm' withBorder>
|
||||
<ScrollArea.Autosize mah={400} type='auto'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>File Type</Table.Th>
|
||||
<Table.Th>Count</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
</>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} height={105} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
<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)} />
|
||||
<Stat Icon={IconDeviceSdCard} title='Average storage used' value={bytes(stats!.avgStorageUsed)} />
|
||||
<Stat Icon={IconEyeFilled} title='File views' value={stats!.views} />
|
||||
<Stat Icon={IconEyeFilled} title='Average file views' value={Math.round(stats!.avgViews)} />
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
<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)} />
|
||||
<Stat Icon={IconDeviceSdCard} title='Average storage used' value={bytes(stats!.avgStorageUsed)} />
|
||||
<Stat Icon={IconEyeFilled} title='File views' value={stats!.views} />
|
||||
<Stat Icon={IconEyeFilled} title='Average file views' value={Math.round(stats!.avgViews)} />
|
||||
|
||||
<Stat Icon={IconLink} title='Links created' value={stats!.urlsCreated} />
|
||||
<Stat Icon={IconLink} title='Total link views' value={Math.round(stats!.urlViews)} />
|
||||
</SimpleGrid>
|
||||
<Stat Icon={IconLink} title='Links created' value={stats!.urlsCreated} />
|
||||
<Stat Icon={IconLink} title='Total link views' value={Math.round(stats!.urlViews)} />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{Object.keys(stats!.sortTypeCount).length !== 0 && (
|
||||
<>
|
||||
<Title order={3} mt='lg' mb='xs'>
|
||||
File types
|
||||
</Title>
|
||||
<Paper radius='sm' withBorder>
|
||||
<ScrollArea.Autosize mah={400} type='auto'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>File Type</Table.Th>
|
||||
<Table.Th>Count</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{Object.entries(stats!.sortTypeCount)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([type, count], i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{type}</Table.Td>
|
||||
<Table.Td>{count}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{homeShowActivity && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Paper radius='md' withBorder p='md' mt='lg'>
|
||||
<Skeleton height={24} width={180} mb='xs' animate />
|
||||
<Skeleton height={260} mt='md' animate />
|
||||
</Paper>
|
||||
}
|
||||
>
|
||||
<ActivityChart />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{statsLoading ? (
|
||||
<Paper withBorder my='md'>
|
||||
<ScrollArea.Autosize mah={400} type='auto'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>File Type</Table.Th>
|
||||
<Table.Th>Count</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
) : (
|
||||
Object.keys(stats!.sortTypeCount).length !== 0 &&
|
||||
homeShowTypes && (
|
||||
<>
|
||||
<Title order={3} mt='lg' mb='xs'>
|
||||
File types
|
||||
</Title>
|
||||
<Paper withBorder my='md'>
|
||||
<ScrollArea.Autosize mah={400} type='auto'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>File Type</Table.Th>
|
||||
<Table.Th>Count</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{Object.entries(stats!.sortTypeCount)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([type, count], i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{type}</Table.Td>
|
||||
<Table.Td>{count}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import { ChartTooltip, LineChart } from '@mantine/charts';
|
||||
import { Box, Group, Paper, Select, Skeleton, Text, Title } from '@mantine/core';
|
||||
import { IconChartAreaLine, IconLogin2, IconUpload } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const CHART_HEIGHT = 260;
|
||||
|
||||
function parseChartDate(value: unknown): dayjs.Dayjs | null {
|
||||
if (value == null || value === '') return null;
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
const d = dayjs(value);
|
||||
return d.isValid() ? d : null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const d = dayjs(value);
|
||||
return d.isValid() ? d : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDayLabel(value: unknown) {
|
||||
const d = parseChartDate(value);
|
||||
if (!d) return '';
|
||||
|
||||
const today = dayjs().startOf('day');
|
||||
if (d.isSame(today, 'day')) return 'Today';
|
||||
if (d.isSame(today.subtract(1, 'day'), 'day')) return 'Yesterday';
|
||||
return d.format('MMM D');
|
||||
}
|
||||
|
||||
export default function ActivityChart() {
|
||||
const [days, setDays] = useState(14);
|
||||
const { data, isLoading } = useSWR<Response['/api/user/activity']>('/api/user/activity?days=' + days);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Paper radius='md' withBorder p='md' mt='lg'>
|
||||
<Skeleton height={24} width={180} mb='xs' animate />
|
||||
<Skeleton height={16} width={240} mb='lg' animate />
|
||||
<Skeleton height={CHART_HEIGHT} animate />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.series.length) return null;
|
||||
|
||||
const chartData = data.series
|
||||
.map((point) => {
|
||||
const d = dayjs(point.date);
|
||||
if (!d.isValid()) return null;
|
||||
|
||||
return {
|
||||
date: d.valueOf(),
|
||||
uploads: point.uploads,
|
||||
logins: point.logins,
|
||||
};
|
||||
})
|
||||
.filter((point) => point !== null);
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
const hasActivity = data.totals.uploads > 0 || data.totals.logins > 0;
|
||||
|
||||
return (
|
||||
<Paper radius='md' withBorder p='md' mt='lg'>
|
||||
<Group justify='space-between' align='flex-start' mb='lg' wrap='nowrap'>
|
||||
<Box>
|
||||
<Title order={3} fw={600}>
|
||||
Activity
|
||||
</Title>
|
||||
<Group gap='xs' style={{ alignItems: 'center' }}>
|
||||
<Text size='sm' c='dimmed' mt={4}>
|
||||
Your uploads and logins over the last{' '}
|
||||
</Text>
|
||||
<Select
|
||||
value={String(days)}
|
||||
onChange={(v) => setDays(Number(v))}
|
||||
data={[
|
||||
{ value: '1', label: '1 day' },
|
||||
{ value: '7', label: '7 days' },
|
||||
{ value: '14', label: '14 days' },
|
||||
{ value: '30', label: '30 days' },
|
||||
]}
|
||||
size='0.4rem'
|
||||
variant='filled'
|
||||
p={0}
|
||||
m={0}
|
||||
fw={500}
|
||||
styles={{
|
||||
input: {
|
||||
color: 'var(--mantine-primary-color-filled)',
|
||||
padding: 10,
|
||||
width: '10em',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
section: {
|
||||
margin: 0,
|
||||
},
|
||||
option: {
|
||||
fontSize: '1rem',
|
||||
},
|
||||
wrapper: {
|
||||
borderRadius: 1,
|
||||
},
|
||||
}}
|
||||
comboboxProps={{
|
||||
dropdownPadding: 0,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group gap='lg' visibleFrom='sm'>
|
||||
<Group gap='xs'>
|
||||
<IconUpload size='1rem' style={{ opacity: 0.85 }} color='var(--mantine-primary-color-filled)' />
|
||||
<Box>
|
||||
<Text size='xs' c='dimmed' lh={1.2}>
|
||||
Uploads
|
||||
</Text>
|
||||
<Text size='sm' fw={600} lh={1.3}>
|
||||
{data.totals.uploads}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap='xs'>
|
||||
<IconLogin2 size='1rem' style={{ opacity: 0.65 }} color='var(--mantine-color-gray-5)' />
|
||||
<Box>
|
||||
<Text size='xs' c='dimmed' lh={1.2}>
|
||||
Logins
|
||||
</Text>
|
||||
<Text size='sm' fw={600} lh={1.3}>
|
||||
{data.totals.logins}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hasActivity ? (
|
||||
<Paper withBorder h={CHART_HEIGHT} radius='md' p='md' ta='center'>
|
||||
<Group align='center' justify='center' h='100%'>
|
||||
<IconChartAreaLine size='1.75rem' style={{ opacity: 0.35 }} />
|
||||
<Text size='sm' c='dimmed'>
|
||||
No uploads or logins in this period yet
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<LineChart
|
||||
h={CHART_HEIGHT}
|
||||
data={chartData}
|
||||
dataKey='date'
|
||||
curveType='natural'
|
||||
connectNulls
|
||||
withLegend={false}
|
||||
withDots={false}
|
||||
activeDotProps={{ r: 4, strokeWidth: 2 }}
|
||||
gridAxis='none'
|
||||
tickLine='none'
|
||||
strokeWidth={2}
|
||||
series={[
|
||||
{
|
||||
name: 'uploads',
|
||||
label: 'Uploads',
|
||||
color: 'var(--mantine-primary-color-filled)',
|
||||
},
|
||||
{
|
||||
name: 'logins',
|
||||
label: 'Logins',
|
||||
color: 'gray.5',
|
||||
},
|
||||
]}
|
||||
xAxisProps={{
|
||||
tickMargin: 12,
|
||||
minTickGap: 32,
|
||||
tickFormatter: (v) => formatDayLabel(v),
|
||||
}}
|
||||
yAxisProps={{
|
||||
width: 36,
|
||||
tickMargin: 8,
|
||||
}}
|
||||
tooltipProps={{
|
||||
content: ({ label, payload }) => (
|
||||
<ChartTooltip
|
||||
label={formatDayLabel(label) || '—'}
|
||||
payload={payload}
|
||||
series={[
|
||||
{ name: 'uploads', label: 'Uploads', color: 'var(--mantine-primary-color-filled)' },
|
||||
{ name: 'logins', label: 'Logins', color: 'gray.5' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { SimpleGrid, Skeleton, Text } from '@mantine/core';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function Recents() {
|
||||
const { data, isLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} height={350} animate />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
if (data?.length)
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{data!.map((file, i) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={i}>
|
||||
<DashboardFile file={file} />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
return (
|
||||
<Text size='sm' c='dimmed'>
|
||||
You have no recent files. The last three files you uploaded will appear here.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { IncompleteFileStatus } from '@/prisma/client';
|
||||
import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardFilesModals } from '.';
|
||||
import { DashboardFilesModals, DashboardFilesModalsUpdate } from '.';
|
||||
|
||||
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
PENDING: (
|
||||
@@ -38,7 +37,7 @@ export default function PendingFilesModal({
|
||||
setModals,
|
||||
}: {
|
||||
modals: DashboardFilesModals;
|
||||
setModals: UpdateFn<DashboardFilesModals>;
|
||||
setModals: DashboardFilesModalsUpdate;
|
||||
}) {
|
||||
const { data: incompleteFiles, mutate } = useSWR<
|
||||
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
||||
@@ -72,7 +71,7 @@ export default function PendingFilesModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={modals.pending} onClose={() => setModals('pending', false)} title='Pending Files'>
|
||||
<Modal opened={modals.pending} onClose={() => setModals({ pending: false })} title='Pending Files'>
|
||||
<Stack gap='xs'>
|
||||
{incompleteFiles?.map((incompleteFile) => (
|
||||
<Card key={incompleteFile.id} withBorder>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import useObjectState, { type UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { useViewStore } from '@/lib/client/store/view';
|
||||
import { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
@@ -10,7 +9,8 @@ import {
|
||||
IconTableOptions,
|
||||
IconTags,
|
||||
} from '@tabler/icons-react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { parseAsBoolean, useQueryStates } from 'nuqs';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PendingFilesModal from './PendingFilesModal';
|
||||
import TagsModal from './tags/TagsModal';
|
||||
import FavoriteFiles from './views/FavoriteFiles';
|
||||
@@ -24,48 +24,21 @@ export type DashboardFilesModals = {
|
||||
pending: boolean;
|
||||
};
|
||||
|
||||
export function useModals() {
|
||||
return useQueryStates({
|
||||
table: parseAsBoolean.withDefault(false),
|
||||
idSearch: parseAsBoolean.withDefault(false),
|
||||
tags: parseAsBoolean.withDefault(false),
|
||||
pending: parseAsBoolean.withDefault(false),
|
||||
});
|
||||
}
|
||||
|
||||
export type DashboardFilesModalsUpdate = ReturnType<typeof useModals>[1];
|
||||
|
||||
export default function DashboardFiles() {
|
||||
const view = useViewStore((state) => state.files);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const modalKeys: Array<keyof DashboardFilesModals> = ['table', 'idSearch', 'tags', 'pending'];
|
||||
|
||||
const modalQS = (key: keyof DashboardFilesModals) => searchParams.get(key) === 'true';
|
||||
|
||||
const [modals, setModalState] = useObjectState<DashboardFilesModals>({
|
||||
table: modalQS('table'),
|
||||
idSearch: modalQS('idSearch'),
|
||||
tags: modalQS('tags'),
|
||||
pending: modalQS('pending'),
|
||||
});
|
||||
|
||||
const updateModalQuery = (updates: Partial<DashboardFilesModals>) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
|
||||
for (const key of modalKeys) {
|
||||
if (!(key in updates)) continue;
|
||||
|
||||
if (updates[key]) next.set(key, 'true');
|
||||
else next.delete(key);
|
||||
}
|
||||
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const setModals: UpdateFn<DashboardFilesModals> = (keyOrObj: any, value?: any) => {
|
||||
if (typeof keyOrObj === 'object' && value === undefined) {
|
||||
setModalState(keyOrObj);
|
||||
updateModalQuery(keyOrObj);
|
||||
return;
|
||||
}
|
||||
|
||||
setModalState(keyOrObj, value);
|
||||
updateModalQuery({ [keyOrObj]: value });
|
||||
};
|
||||
const [modals, setModals] = useModals();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -92,12 +65,15 @@ export default function DashboardFiles() {
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconTags size='1rem' />} onClick={() => setModals('tags', !modals.tags)}>
|
||||
<Menu.Item
|
||||
leftSection={<IconTags size='1rem' />}
|
||||
onClick={() => setModals({ tags: !modals.tags })}
|
||||
>
|
||||
Manage Tags
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileDots size='1rem' />}
|
||||
onClick={() => setModals('pending', !modals.pending)}
|
||||
onClick={() => setModals({ pending: !modals.pending })}
|
||||
>
|
||||
View Pending Files
|
||||
</Menu.Item>
|
||||
@@ -106,13 +82,13 @@ export default function DashboardFiles() {
|
||||
<Menu.Label>Table Options</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconGridPatternFilled size='1rem' />}
|
||||
onClick={() => setModals('idSearch', !modals.idSearch)}
|
||||
onClick={() => setModals({ idSearch: !modals.idSearch })}
|
||||
>
|
||||
Search by ID
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTableOptions size='1rem' />}
|
||||
onClick={() => setModals('table', !modals.table)}
|
||||
onClick={() => setModals({ table: !modals.table })}
|
||||
>
|
||||
Table Options
|
||||
</Menu.Item>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function CreateTagModal({ open, onClose }: { open: boolean; onClo
|
||||
{...form.getInputProps('color')}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm'>
|
||||
<Button type='submit' variant='outline'>
|
||||
Create tag
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function EditTagModal({
|
||||
{...form.getInputProps('color')}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm' disabled={!form.isDirty}>
|
||||
<Button type='submit' variant='outline' disabled={!form.isDirty}>
|
||||
Edit tag
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -2,13 +2,12 @@ import { mutateFiles } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconPencil, IconPlus, IconTagOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardFilesModals } from '..';
|
||||
import { DashboardFilesModals, DashboardFilesModalsUpdate } from '..';
|
||||
import CreateTagModal from './CreateTagModal';
|
||||
import EditTagModal from './EditTagModal';
|
||||
import TagPill from './TagPill';
|
||||
@@ -18,7 +17,7 @@ export default function TagsModals({
|
||||
setModals,
|
||||
}: {
|
||||
modals: DashboardFilesModals;
|
||||
setModals: UpdateFn<DashboardFilesModals>;
|
||||
setModals: DashboardFilesModalsUpdate;
|
||||
}) {
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
@@ -55,7 +54,7 @@ export default function TagsModals({
|
||||
|
||||
<Modal
|
||||
opened={modals.tags}
|
||||
onClose={() => setModals('tags', false)}
|
||||
onClose={() => setModals({ tags: false })}
|
||||
title={
|
||||
<Group>
|
||||
<Title>Tags</Title>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
@@ -16,11 +15,12 @@ import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function FavoriteFiles() {
|
||||
const [page, setPage] = useQueryState('fpage', 1);
|
||||
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
|
||||
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import DashboardFile from '@/components/file/DashboardFile';
|
||||
import { useFileNavStore } from '@/lib/client/store/fileNav';
|
||||
import {
|
||||
Button,
|
||||
@@ -14,20 +14,19 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
import DashboardFile from '@/components/file/DashboardFile';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
|
||||
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
|
||||
|
||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45, 60];
|
||||
|
||||
export default function Files({ id, folderId }: { id?: string; folderId?: string }) {
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(15);
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [perpage, setPerpage] = useQueryState('perpage', parseAsInteger.withDefault(15));
|
||||
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
|
||||
@@ -4,7 +4,6 @@ import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import { useFileNavStore } from '@/lib/client/store/fileNav';
|
||||
import { NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
@@ -41,13 +40,12 @@ import {
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable } from 'mantine-datatable';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { DashboardFilesModals } from '..';
|
||||
import { DashboardFilesModals, DashboardFilesModalsUpdate } from '..';
|
||||
import TableEditModal from '../TableEditModal';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
import TagPill from '../tags/TagPill';
|
||||
@@ -60,7 +58,7 @@ type ReducerQuery = {
|
||||
action: { field: string; query: string };
|
||||
};
|
||||
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50];
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50, 70, 100];
|
||||
|
||||
function SearchFilter({
|
||||
setSearchField,
|
||||
@@ -189,7 +187,7 @@ export default function FileTable({
|
||||
id?: string;
|
||||
folderId?: string;
|
||||
modals?: Partial<DashboardFilesModals>;
|
||||
setModals?: UpdateFn<DashboardFilesModals>;
|
||||
setModals?: DashboardFilesModalsUpdate;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
@@ -203,8 +201,8 @@ export default function FileTable({
|
||||
return buildFolderHierarchy(folders);
|
||||
}, [folders]);
|
||||
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(20);
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [perpage, setPerpage] = useQueryState('perpage', parseAsInteger.withDefault(20));
|
||||
const [sort, setSort] = useState<
|
||||
| 'id'
|
||||
| 'createdAt'
|
||||
@@ -394,7 +392,7 @@ export default function FileTable({
|
||||
/>
|
||||
|
||||
{modals && setModals && (
|
||||
<TableEditModal opened={!!modals.table} onClose={() => setModals('table', false)} />
|
||||
<TableEditModal opened={!!modals.table} onClose={() => setModals({ table: false })} />
|
||||
)}
|
||||
|
||||
<Box>
|
||||
@@ -510,7 +508,6 @@ export default function FileTable({
|
||||
{/*@ts-ignore*/}
|
||||
<DataTable
|
||||
mt='xs'
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={data?.page ?? []}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
@@ -16,11 +15,12 @@ import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiPagination } from '../files/useApiPagination';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function FavoriteFiles() {
|
||||
const [page, setPage] = useQueryState('fpage', 1);
|
||||
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
favorite: true,
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function FolderCard({
|
||||
<MoveFolderModal folder={folder} opened={moveOpen} onClose={() => setMoveOpen(false)} />
|
||||
<DeleteFolderModal opened={deleteOpen} folder={folder} onClose={() => setDeleteOpen(false)} />
|
||||
|
||||
<Card withBorder shadow='sm' radius='sm' style={{ cursor: onNavigate ? 'pointer' : 'default' }}>
|
||||
<Card withBorder shadow='sm' style={{ cursor: onNavigate ? 'pointer' : 'default' }}>
|
||||
<Card.Section withBorder inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
|
||||
<Group justify='space-between'>
|
||||
<Group gap='xs'>
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function DashboardFolders() {
|
||||
{...form.getInputProps('isPublic', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm' leftSection={<IconFolderPlus size='1rem' />}>
|
||||
<Button type='submit' variant='outline' leftSection={<IconFolderPlus size='1rem' />}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -168,7 +168,6 @@ export default function FolderTableView({
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={sorted ?? []}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function InviteCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card withBorder shadow='sm' radius='sm'>
|
||||
<Card withBorder shadow='sm'>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Group justify='space-between'>
|
||||
<Anchor href={`/invite/${invite.code}`} target='_blank' fw={400}>
|
||||
|
||||
@@ -96,13 +96,7 @@ export default function DashboardInvites() {
|
||||
{...form.getInputProps('maxUses')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
variant='outline'
|
||||
fullWidth
|
||||
radius='sm'
|
||||
leftSection={<IconPlus size='1rem' />}
|
||||
>
|
||||
<Button type='submit' variant='outline' fullWidth leftSection={<IconPlus size='1rem' />}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function InviteTableView() {
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={sorted ?? []}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { DatePicker } from '@mantine/dates';
|
||||
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { lazy, useState } from 'react';
|
||||
import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
|
||||
import { StatsCardsSkeleton } from './parts/StatsCards';
|
||||
import { StatsTablesSkeleton } from './parts/StatsTables';
|
||||
import { useApiStats } from './useStats';
|
||||
|
||||
const FilesUrlsCountGraph = lazy(() => import('./parts/FilesUrlsCountGraph'));
|
||||
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
|
||||
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
|
||||
const StatsCards = lazy(() => import('./parts/StatsCards'));
|
||||
@@ -133,16 +133,16 @@ export default function DashboardMetrics() {
|
||||
<StatsCardsSkeleton />
|
||||
<StatsTablesSkeleton />
|
||||
</div>
|
||||
) : data?.length ? (
|
||||
) : data?.points.length ? (
|
||||
<div>
|
||||
<StatsCards data={data} />
|
||||
<StatsTables data={data} />
|
||||
<StatsCards points={data.points} />
|
||||
<StatsTables latest={data.latest} />
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }}>
|
||||
<FilesUrlsCountGraph metrics={data} />
|
||||
<ViewsGraph metrics={data} />
|
||||
<FilesUrlsCountGraph points={data.points} />
|
||||
<ViewsGraph points={data.points} />
|
||||
</SimpleGrid>
|
||||
<div>
|
||||
<StorageGraph metrics={data} />
|
||||
<StorageGraph points={data.points} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { MetricsPoint } from '@/lib/metrics';
|
||||
import { ChartTooltip, LineChart } from '@mantine/charts';
|
||||
import { Paper, Title } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { defaultChartProps } from '../statsHelpers';
|
||||
|
||||
export default function FilesUrlsCountGraph({ metrics }: { metrics: Metric[] }) {
|
||||
const sortedMetrics = metrics.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
export default function FilesUrlsCountGraph({ points }: { points: MetricsPoint[] }) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
points
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((point) => ({
|
||||
date: new Date(point.createdAt).getTime(),
|
||||
files: point.files,
|
||||
urls: point.urls,
|
||||
})),
|
||||
[points],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper radius='md' withBorder p='sm'>
|
||||
<Title order={3}>Count</Title>
|
||||
|
||||
<LineChart
|
||||
data={sortedMetrics.map((metric) => ({
|
||||
date: new Date(metric.createdAt).getTime(),
|
||||
files: metric.data.files,
|
||||
urls: metric.data.urls,
|
||||
}))}
|
||||
data={data}
|
||||
series={[
|
||||
{
|
||||
name: 'files',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { MetricsPoint } from '@/lib/metrics';
|
||||
import { Group, Paper, rgba, SimpleGrid, Skeleton, Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowDown,
|
||||
@@ -21,8 +21,8 @@ function StatCard({
|
||||
Icon,
|
||||
}: {
|
||||
title: string;
|
||||
first: number;
|
||||
last: number;
|
||||
first: number | bigint;
|
||||
last: number | bigint;
|
||||
Icon: TablerIcon;
|
||||
formatter?: (value: number) => string;
|
||||
}) {
|
||||
@@ -35,9 +35,9 @@ function StatCard({
|
||||
}[color];
|
||||
|
||||
return (
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper radius='md' withBorder p='sm'>
|
||||
<Group justify='space-between'>
|
||||
<Text size='xl' fw='bolder'>
|
||||
<Text size='xl' fw={900}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
@@ -45,8 +45,8 @@ function StatCard({
|
||||
</Group>
|
||||
|
||||
<Group justify='flex-start' gap='xs'>
|
||||
<Text size='xl' fw='bolder'>
|
||||
{formatter ? formatter(first) : first}
|
||||
<Text size='lg' fw={600}>
|
||||
{formatter ? formatter(Number(first)) : first}
|
||||
</Text>
|
||||
|
||||
<Paper
|
||||
@@ -54,7 +54,6 @@ function StatCard({
|
||||
py={2}
|
||||
pl={5}
|
||||
pr={8}
|
||||
radius='sm'
|
||||
display='flex'
|
||||
bg={rgba(`var(--mantine-color-${color}-6)`, 0.25)}
|
||||
>
|
||||
@@ -87,14 +86,11 @@ export function StatsCardsSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatsCards({ data }: { data: Metric[] }) {
|
||||
if (!data.length) return null;
|
||||
const sortedMetrics = data.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
export default function StatsCards({ points }: { points: MetricsPoint[] }) {
|
||||
if (!points.length) return null;
|
||||
|
||||
const recent = sortedMetrics[0];
|
||||
const last = sortedMetrics[sortedMetrics.length - 1];
|
||||
const recent = points[0];
|
||||
const last = points[points.length - 1];
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
@@ -105,28 +101,18 @@ export default function StatsCards({ data }: { data: Metric[] }) {
|
||||
}}
|
||||
mb='sm'
|
||||
>
|
||||
<StatCard title='Files' first={recent.data.files} last={last.data.files} Icon={IconFiles} />
|
||||
<StatCard title='URLs' first={recent.data.urls} last={last.data.urls} Icon={IconLink} />
|
||||
<StatCard title='Files' first={recent.files} last={last.files} Icon={IconFiles} />
|
||||
<StatCard title='URLs' first={recent.urls} last={last.urls} Icon={IconLink} />
|
||||
<StatCard
|
||||
title='Storage Used'
|
||||
first={recent.data.storage}
|
||||
last={last.data.storage}
|
||||
first={recent.storage}
|
||||
last={last.storage}
|
||||
formatter={bytes}
|
||||
Icon={IconDatabase}
|
||||
/>
|
||||
<StatCard title='Users' first={recent.data.users} last={last.data.users} Icon={IconUsers} />
|
||||
<StatCard
|
||||
title='File Views'
|
||||
first={recent.data.fileViews}
|
||||
last={last.data.fileViews}
|
||||
Icon={IconEyeFilled}
|
||||
/>
|
||||
<StatCard
|
||||
title='URL Views'
|
||||
first={recent.data.urlViews}
|
||||
last={last.data.urlViews}
|
||||
Icon={IconEyeFilled}
|
||||
/>
|
||||
<StatCard title='Users' first={recent.users} last={last.users} Icon={IconUsers} />
|
||||
<StatCard title='File Views' first={recent.fileViews} last={last.fileViews} Icon={IconEyeFilled} />
|
||||
<StatCard title='URL Views' first={recent.urlViews} last={last.urlViews} Icon={IconEyeFilled} />
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function StatsTablesSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<Paper radius='sm' withBorder>
|
||||
<Paper radius='md' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
@@ -42,7 +42,7 @@ export function StatsTablesSkeleton() {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder mah={500}>
|
||||
<Paper withBorder mah={500} radius='md'>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
@@ -65,7 +65,7 @@ export function StatsTablesSkeleton() {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder>
|
||||
<Paper withBorder radius='md'>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
@@ -86,7 +86,7 @@ export function StatsTablesSkeleton() {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper withBorder p='sm'>
|
||||
<Skeleton height={500} />
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
@@ -94,18 +94,18 @@ export function StatsTablesSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
if (!data.length) return null;
|
||||
export default function StatsTables({ latest }: { latest: Metric | null }) {
|
||||
if (!latest) return null;
|
||||
|
||||
const recent = data[0]; // it is sorted by desc so 0 is the first one.
|
||||
const recent = latest;
|
||||
|
||||
if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<Paper radius='sm' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Paper radius='md' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto' bdrs='md'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -131,8 +131,8 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder mah={500}>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Paper radius='md' withBorder mah={500}>
|
||||
<ScrollArea.Autosize mah={500} type='auto' bdrs='md'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -156,8 +156,8 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Paper radius='md' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto' bdrs='md'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -179,7 +179,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper radius='md' withBorder p='sm'>
|
||||
<TypesPieChart metric={recent} />
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { LineChart, ChartTooltip } from '@mantine/charts';
|
||||
import { MetricsPoint } from '@/lib/metrics';
|
||||
import { ChartTooltip, LineChart } from '@mantine/charts';
|
||||
import { Paper, Title } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { defaultChartProps } from '../statsHelpers';
|
||||
|
||||
export default function StorageGraph({ metrics }: { metrics: Metric[] }) {
|
||||
const sortedMetrics = metrics.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
export default function StorageGraph({ points }: { points: MetricsPoint[] }) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
points
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((point) => ({
|
||||
date: new Date(point.createdAt).getTime(),
|
||||
storage: point.storage,
|
||||
})),
|
||||
[points],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper radius='sm' withBorder p='sm' mt='md'>
|
||||
<Paper radius='md' withBorder p='sm' mt='md'>
|
||||
<Title order={3} mb='sm'>
|
||||
Storage Used
|
||||
</Title>
|
||||
|
||||
<LineChart
|
||||
data={sortedMetrics.map((metric) => ({
|
||||
date: new Date(metric.createdAt).getTime(),
|
||||
storage: metric.data.storage,
|
||||
}))}
|
||||
data={data}
|
||||
series={[
|
||||
{
|
||||
name: 'storage',
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { MetricsPoint } from '@/lib/metrics';
|
||||
import { ChartTooltip, LineChart } from '@mantine/charts';
|
||||
import { Paper, Title } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { defaultChartProps } from '../statsHelpers';
|
||||
|
||||
export default function ViewsGraph({ metrics }: { metrics: Metric[] }) {
|
||||
const sortedMetrics = metrics.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
export default function ViewsGraph({ points }: { points: MetricsPoint[] }) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
points
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((point) => ({
|
||||
date: new Date(point.createdAt).getTime(),
|
||||
files: point.fileViews,
|
||||
urls: point.urlViews,
|
||||
})),
|
||||
[points],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper radius='md' withBorder p='sm'>
|
||||
<Title order={3}>Views</Title>
|
||||
<LineChart
|
||||
data={sortedMetrics.map((metric) => ({
|
||||
date: new Date(metric.createdAt).getTime(),
|
||||
files: metric.data.fileViews,
|
||||
urls: metric.data.urlViews,
|
||||
}))}
|
||||
data={data}
|
||||
series={[
|
||||
{
|
||||
name: 'files',
|
||||
|
||||
@@ -13,7 +13,10 @@ export const defaultChartProps: Partial<LineChartProps> & { dataKey: string } =
|
||||
dataKey: 'date',
|
||||
};
|
||||
|
||||
export function percentChange(a: number, b: number): [string, string] {
|
||||
export function percentChange(a: number | bigint, b: number | bigint): [string, string] {
|
||||
if (typeof a === 'bigint') a = Number(a);
|
||||
if (typeof b === 'bigint') b = Number(b);
|
||||
|
||||
const change = Math.round(((b - a) / a) * 100);
|
||||
const color = change > 0 ? 'green' : change < 0 ? 'red' : 'gray';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, LoadingOverlay, NumberInput, Select, Stack, Switch, TextInput }
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
import { checkCommaArray, settingsOnSubmit } from '../settingsOnSubmit';
|
||||
import useServerSettings from '../useServerSettings';
|
||||
|
||||
export default function Files() {
|
||||
@@ -25,6 +25,8 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
|
||||
filesRoute: data.settings.filesRoute,
|
||||
filesLength: data.settings.filesLength,
|
||||
filesDefaultFormat: data.settings.filesDefaultFormat,
|
||||
filesDisabledTypes: data.settings.filesDisabledTypes.join(', '),
|
||||
filesDisabledTypesDefault: data.settings.filesDisabledTypesDefault,
|
||||
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', '),
|
||||
filesMaxFileSize: data.settings.filesMaxFileSize,
|
||||
filesDefaultExpiration: data.settings.filesDefaultExpiration,
|
||||
@@ -55,25 +57,17 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
|
||||
values.filesMaxExpiration = values.filesMaxExpiration.trim();
|
||||
}
|
||||
|
||||
if (!values.filesDisabledExtensions) {
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = [];
|
||||
} else if (
|
||||
values.filesDisabledExtensions &&
|
||||
typeof values.filesDisabledExtensions === 'string' &&
|
||||
values.filesDisabledExtensions.trim() === ''
|
||||
) {
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = [];
|
||||
if (values.filesDisabledTypesDefault?.trim() === '' || !values.filesDisabledTypesDefault) {
|
||||
values.filesDisabledTypesDefault = null;
|
||||
} else {
|
||||
if (!Array.isArray(values.filesDisabledExtensions))
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = values.filesDisabledExtensions
|
||||
.split(',')
|
||||
.map((ext) => ext.trim())
|
||||
.filter((ext) => ext !== '');
|
||||
values.filesDisabledTypesDefault = values.filesDisabledTypesDefault.trim();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = checkCommaArray(values.filesDisabledExtensions);
|
||||
// @ts-ignore
|
||||
values.filesDisabledTypes = checkCommaArray(values.filesDisabledTypes);
|
||||
|
||||
return settingsOnSubmit(navigate, form)(values);
|
||||
};
|
||||
|
||||
@@ -86,6 +80,20 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
|
||||
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Disabled Types'
|
||||
description='Mimetypes to disable, separated by commas. It is recommended to have the Assume Mimetypes setting enabled if you are disabling mimetypes, as this will also block files with the corresponding extensions.'
|
||||
placeholder='text/html, application/javascript'
|
||||
{...form.getInputProps('filesDisabledTypes')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default MIME for Disabled Types'
|
||||
description='The default MIME type to use for disabled types. Leave blank to completely block disabled types.'
|
||||
placeholder='application/octet-stream'
|
||||
{...form.getInputProps('filesDisabledTypesDefault')}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Remove GPS Metadata'
|
||||
description='Remove GPS metadata from files.'
|
||||
|
||||
@@ -6,6 +6,22 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
export function checkCommaArray(value: unknown): string[] {
|
||||
if (!value) return [];
|
||||
|
||||
if (value && typeof value === 'string' && value.trim() === '') return [];
|
||||
|
||||
if (!Array.isArray(value) && typeof value === 'string')
|
||||
return value
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => x !== '');
|
||||
|
||||
if (Array.isArray(value)) return value.map((x) => String(x).trim()).filter((x) => x !== '');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<typeof useForm<any>>) {
|
||||
return async (values: unknown) => {
|
||||
const { data, error } = await fetchApi<Response['/api/server/settings']>(
|
||||
|
||||
@@ -45,6 +45,12 @@ export default function SettingsDashboard() {
|
||||
checked={settings.disableMediaPreview}
|
||||
onChange={(event) => update('disableMediaPreview', event.currentTarget.checked)}
|
||||
/>
|
||||
<Switch
|
||||
label='Mute video and audio previews'
|
||||
description='When enabled, video and audio in the file viewer autoplay muted. Turning this off tries to play sound immediately. Browsers may block unmuted autoplay until you interact with the page.'
|
||||
checked={settings.mediaAutoMuted}
|
||||
onChange={(event) => update('mediaAutoMuted', event.currentTarget.checked)}
|
||||
/>
|
||||
<Switch
|
||||
label='Warn on deletion'
|
||||
description='Show a warning when deleting stuff. When this is disabled, files, urls, etc will be deleted with no prior warning! Folders, users, and bulk-transactions are exempt from this rule and will always warn you before deleting anything.'
|
||||
@@ -57,6 +63,26 @@ export default function SettingsDashboard() {
|
||||
checked={settings.fileNavButtons}
|
||||
onChange={(event) => update('fileNavButtons', event.currentTarget.checked)}
|
||||
/>
|
||||
<Switch
|
||||
label='Show recents'
|
||||
description='Show recent uploads and logins on the home page.'
|
||||
checked={settings.homeShowRecents}
|
||||
onChange={(event) => update('homeShowRecents', event.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Show activity'
|
||||
description='Show your recent activity as a graph on the home page.'
|
||||
checked={settings.homeShowActivity}
|
||||
onChange={(event) => update('homeShowActivity', event.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Show file types'
|
||||
description='Show the file types table on the home page.'
|
||||
checked={settings.homeShowTypes}
|
||||
onChange={(event) => update('homeShowTypes', event.currentTarget.checked)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -60,6 +60,7 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
|
||||
enabled: user.view.enabled || false,
|
||||
content: user.view.content || '',
|
||||
embed: user.view.embed || false,
|
||||
embedMediaOnly: user.view.embedMediaOnly || false,
|
||||
embedTitle: user.view.embedTitle || '',
|
||||
embedDescription: user.view.embedDescription || '',
|
||||
embedSiteName: user.view.embedSiteName || '',
|
||||
@@ -75,6 +76,7 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
|
||||
const valuesTrimmed = {
|
||||
enabled: values.enabled,
|
||||
embed: values.embed,
|
||||
embedMediaOnly: values.embed ? false : values.embedMediaOnly,
|
||||
content: values.content.trim() || null,
|
||||
embedTitle: values.embedTitle.trim() || null,
|
||||
embedDescription: values.embedDescription.trim() || null,
|
||||
@@ -186,6 +188,20 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
|
||||
disabled={!form.values.enabled}
|
||||
my='xs'
|
||||
{...form.getInputProps('embed', { type: 'checkbox' })}
|
||||
onChange={(event) => {
|
||||
form.getInputProps('embed', { type: 'checkbox' }).onChange(event);
|
||||
if (event.currentTarget.checked) {
|
||||
form.setFieldValue('embedMediaOnly', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Media-only link preview'
|
||||
description='When embeds are off, still add OpenGraph image/video tags so Discord and similar apps unfurl the media only (no custom title, description, or site name). The URL you paste stays in the message as plain text.'
|
||||
disabled={!form.values.enabled || form.values.embed}
|
||||
my='xs'
|
||||
{...form.getInputProps('embedMediaOnly', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='sm'>
|
||||
|
||||
@@ -208,7 +208,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
</Collapse>
|
||||
|
||||
<Collapse expanded={progress.speed > 0 && progress.remaining > 0}>
|
||||
<Paper withBorder p='xs' radius='sm'>
|
||||
<Paper withBorder p='xs'>
|
||||
<Text ta='center' size='sm'>
|
||||
{bytes(progress.speed)}/s, {humanizeDuration(progress.remaining)} remaining
|
||||
</Text>
|
||||
@@ -216,7 +216,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
</Collapse>
|
||||
|
||||
<Collapse expanded={progress.percent === 100}>
|
||||
<Paper withBorder p='xs' radius='sm'>
|
||||
<Paper withBorder p='xs'>
|
||||
<Text ta='center' size='sm' c='yellow' fw={500}>
|
||||
Finalizing upload(s)...
|
||||
</Text>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function UserCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card withBorder shadow='sm' radius='sm'>
|
||||
<Card withBorder shadow='sm'>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Group justify='space-between'>
|
||||
{url.enabled ? (
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function DashboardURLs() {
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm' leftSection={<IconLink size='1rem' />}>
|
||||
<Button type='submit' variant='outline' leftSection={<IconLink size='1rem' />}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -155,7 +155,6 @@ export default function UrlTableView() {
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={sorted ?? []}
|
||||
|
||||
@@ -268,13 +268,7 @@ export default function EditUserModal({
|
||||
/>
|
||||
<Divider />
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
variant='outline'
|
||||
color='blue'
|
||||
radius='sm'
|
||||
leftSection={<IconUserEdit size='1rem' />}
|
||||
>
|
||||
<Button type='submit' variant='outline' color='blue' leftSection={<IconUserEdit size='1rem' />}>
|
||||
Update user
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function UserCard({ user }: { user: User }) {
|
||||
<>
|
||||
<EditUserModal user={user} opened={opened} onClose={() => setOpen(false)} />
|
||||
|
||||
<Card withBorder shadow='sm' radius='sm'>
|
||||
<Card withBorder shadow='sm'>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Group justify='space-between'>
|
||||
<Group>
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { type loader } from '@/client/pages/dashboard/admin/users/[id]/files';
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import useObjectState from '@/lib/client/hooks/useObjectState';
|
||||
import { useViewStore } from '@/lib/client/store/view';
|
||||
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
|
||||
import { IconArrowBackUp, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
|
||||
import { Link, useLoaderData } from 'react-router-dom';
|
||||
import { DashboardFilesModals } from '../files';
|
||||
import FilesTableView from '../files/views/FilesTableView';
|
||||
import { useModals } from '../files';
|
||||
import FilesGridView from '../files/views/FilesGridView';
|
||||
import FilesTableView from '../files/views/FilesTableView';
|
||||
|
||||
export default function ViewUserFiles() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const view = useViewStore((state) => state.files);
|
||||
const [modals, setModals] = useObjectState<Partial<DashboardFilesModals>>({
|
||||
table: false,
|
||||
idSearch: false,
|
||||
});
|
||||
const [modals, setModals] = useModals();
|
||||
|
||||
if (!data) return;
|
||||
|
||||
@@ -34,13 +30,13 @@ export default function ViewUserFiles() {
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Table Options'>
|
||||
<ActionIcon variant='outline' onClick={() => setModals('table', !modals.table)}>
|
||||
<ActionIcon variant='outline' onClick={() => setModals({ table: !modals.table })}>
|
||||
<IconTableOptions size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Search by ID'>
|
||||
<ActionIcon variant='outline' onClick={() => setModals('idSearch', !modals.idSearch)}>
|
||||
<ActionIcon variant='outline' onClick={() => setModals({ idSearch: !modals.idSearch })}>
|
||||
<IconGridPatternFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function DashboardUsers() {
|
||||
{...form.getInputProps('role')}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm' leftSection={<IconUserPlus size='1rem' />}>
|
||||
<Button type='submit' variant='outline' leftSection={<IconUserPlus size='1rem' />}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -45,7 +45,6 @@ export default function UserTableView() {
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={sorted ?? []}
|
||||
|
||||
@@ -63,6 +63,7 @@ export const API_ERRORS = {
|
||||
1062: 'No files in multipart/form-data request',
|
||||
1063: 'Already linked to this OAuth provider',
|
||||
1064: 'Invalid OAuth state parameter',
|
||||
1065: 'Invalid MIME type',
|
||||
|
||||
// 2xxx, session errors
|
||||
2000: 'Invalid login session',
|
||||
|
||||
@@ -34,6 +34,7 @@ import { ApiUserMfaPasskeyResponse } from '@/server/routes/api/user/mfa/passkey'
|
||||
import { ApiUserMfaTotpResponse } from '@/server/routes/api/user/mfa/totp';
|
||||
import { ApiUserRecentResponse } from '@/server/routes/api/user/recent';
|
||||
import { ApiUserSessionsResponse } from '@/server/routes/api/user/sessions';
|
||||
import { ApiUserActivityResponse } from '@/server/routes/api/user/activity';
|
||||
import { ApiUserStatsResponse } from '@/server/routes/api/user/stats';
|
||||
import { ApiUserTagsResponse } from '@/server/routes/api/user/tags';
|
||||
import { ApiUserTagsIdResponse } from '@/server/routes/api/user/tags/[id]';
|
||||
@@ -70,6 +71,7 @@ export type Response = {
|
||||
'/api/user/sessions': ApiUserSessionsResponse;
|
||||
'/api/user': ApiUserResponse;
|
||||
'/api/user/stats': ApiUserStatsResponse;
|
||||
'/api/user/activity': ApiUserActivityResponse;
|
||||
'/api/user/recent': ApiUserRecentResponse;
|
||||
'/api/user/token': ApiUserTokenResponse;
|
||||
'/api/user/export': ApiUserExportResponse;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
function parseValue<T>(value: string | null, defaultValue: T): T {
|
||||
if (value === null) return defaultValue;
|
||||
|
||||
if (typeof defaultValue === 'number') {
|
||||
const parsed = Number(value);
|
||||
return isNaN(parsed) ? defaultValue : (parsed as T);
|
||||
}
|
||||
|
||||
if (typeof defaultValue === 'boolean') {
|
||||
return (value === 'true') as T;
|
||||
}
|
||||
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function useQueryState<T>(key: string, defaultValue: T): [T, (value: T | null) => void] {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const rawValue = searchParams.get(key);
|
||||
const value: T = parseValue(rawValue, defaultValue);
|
||||
|
||||
const setValue = (newValue: T | null) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (newValue === null) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, String(newValue));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware';
|
||||
export type SettingsStore = {
|
||||
settings: {
|
||||
disableMediaPreview: boolean;
|
||||
mediaAutoMuted: boolean;
|
||||
warnDeletion: boolean;
|
||||
fileNavButtons: boolean;
|
||||
fileViewer: 'default' | 'fullscreen';
|
||||
@@ -11,6 +12,9 @@ export type SettingsStore = {
|
||||
themeDark: string;
|
||||
themeLight: string;
|
||||
domain: '' | string;
|
||||
homeShowRecents: boolean;
|
||||
homeShowActivity: boolean;
|
||||
homeShowTypes: boolean;
|
||||
};
|
||||
|
||||
update: <K extends keyof SettingsStore['settings']>(key: K, value: SettingsStore['settings'][K]) => void;
|
||||
@@ -18,6 +22,7 @@ export type SettingsStore = {
|
||||
|
||||
const defaultSettings: SettingsStore['settings'] = {
|
||||
disableMediaPreview: false,
|
||||
mediaAutoMuted: true,
|
||||
warnDeletion: true,
|
||||
fileNavButtons: true,
|
||||
fileViewer: 'fullscreen',
|
||||
@@ -25,6 +30,9 @@ const defaultSettings: SettingsStore['settings'] = {
|
||||
themeDark: 'builtin:dark_blue',
|
||||
themeLight: 'builtin:light_blue',
|
||||
domain: '',
|
||||
homeShowRecents: true,
|
||||
homeShowActivity: true,
|
||||
homeShowTypes: true,
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>()(
|
||||
|
||||
@@ -22,6 +22,8 @@ export const DATABASE_TO_PROP = {
|
||||
filesRoute: 'files.route',
|
||||
filesLength: 'files.length',
|
||||
filesDefaultFormat: 'files.defaultFormat',
|
||||
filesDisabledTypes: 'files.disabledTypes',
|
||||
filesDisabledTypesDefault: 'files.disabledTypesDefault',
|
||||
filesDisabledExtensions: 'files.disabledExtensions',
|
||||
filesMaxFileSize: 'files.maxFileSize',
|
||||
filesDefaultExpiration: 'files.defaultExpiration',
|
||||
|
||||
@@ -56,6 +56,8 @@ export const ENVS = [
|
||||
env('files.route', 'FILES_ROUTE', 'string', true),
|
||||
env('files.length', 'FILES_LENGTH', 'number', true),
|
||||
env('files.defaultFormat', 'FILES_DEFAULT_FORMAT', 'string', true),
|
||||
env('files.disabledTypes', 'FILES_DISABLED_TYPES', 'string[]', true),
|
||||
env('files.disabledTypesDefault', 'FILES_DISABLED_TYPES_DEFAULT', 'string', true),
|
||||
env('files.disabledExtensions', 'FILES_DISABLED_EXTENSIONS', 'string[]', true),
|
||||
env('files.maxFileSize', 'FILES_MAX_FILE_SIZE', 'string', true),
|
||||
env('files.defaultExpiration', 'FILES_DEFAULT_EXPIRATION', 'string', true),
|
||||
|
||||
@@ -169,10 +169,11 @@ export async function read() {
|
||||
}
|
||||
|
||||
global.__tamperedConfig__.push(col);
|
||||
logger.info('overriding database value from env', { col, value: val });
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('overridden db settings from env vars', { overridden: global.__tamperedConfig__ });
|
||||
|
||||
const raw = structuredClone(rawConfig);
|
||||
|
||||
for (const [key, value] of Object.entries(database)) {
|
||||
|
||||
@@ -18,6 +18,8 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export const MIME_REGEX = /^[a-zA-Z0-9!#$&^_\-\+.]+\/[a-zA-Z0-9!#$&^_\-\+.]+$/gi;
|
||||
|
||||
export const MAX_SAFE_TIMEOUT_MS = 2147483647;
|
||||
|
||||
export function validateInterval(value: string): boolean {
|
||||
@@ -131,6 +133,8 @@ export const schema = z.object({
|
||||
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/u'),
|
||||
length: z.number().default(6),
|
||||
defaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat', 'random-words']).default('random'),
|
||||
disabledTypes: z.array(z.string().regex(MIME_REGEX, 'Invalid MIME type format')).default([]),
|
||||
disabledTypesDefault: z.string().nullable().default(null),
|
||||
disabledExtensions: z.array(z.string()).default([]),
|
||||
maxFileSize: z.string().default('100mb'),
|
||||
defaultExpiration: z.string().nullable().default(null),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { access, constants, copyFile, readdir, rename, rm, stat, writeFile } fro
|
||||
import { join, resolve, sep } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { Datasource, ListOptions, PutOptions } from './Datasource';
|
||||
import { log } from '../logger';
|
||||
|
||||
async function existsAndCanRW(path: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -15,6 +16,7 @@ async function existsAndCanRW(path: string): Promise<boolean> {
|
||||
|
||||
export class LocalDatasource extends Datasource {
|
||||
name = 'local';
|
||||
logger = log('datasource').c('local');
|
||||
|
||||
constructor(public dir: string) {
|
||||
super();
|
||||
@@ -40,9 +42,7 @@ export class LocalDatasource extends Datasource {
|
||||
|
||||
public async put(file: string, data: Buffer | string, { noDelete }: PutOptions): Promise<void> {
|
||||
const path = this.resolvePath(file);
|
||||
if (!path) {
|
||||
throw new Error('Invalid path provided');
|
||||
}
|
||||
if (!path) throw new Error('Invalid path provided');
|
||||
|
||||
// handles if given a path to a file, it will just move it instead of doing unecessary writes
|
||||
if (typeof data === 'string' && data.startsWith('/')) {
|
||||
@@ -69,14 +69,17 @@ export class LocalDatasource extends Datasource {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = join(this.dir, file);
|
||||
const path = this.resolvePath(file);
|
||||
if (!path) throw new Error('Invalid path provided');
|
||||
|
||||
if (!existsSync(path)) return Promise.resolve();
|
||||
|
||||
return rm(path);
|
||||
}
|
||||
|
||||
public async size(file: string): Promise<number> {
|
||||
const path = join(this.dir, file);
|
||||
const path = this.resolvePath(file);
|
||||
if (!path) throw new Error('Invalid path provided');
|
||||
if (!existsSync(path)) return 0;
|
||||
|
||||
const { size } = await stat(path);
|
||||
@@ -98,15 +101,18 @@ export class LocalDatasource extends Datasource {
|
||||
}
|
||||
|
||||
public async range(file: string, start: number, end: number): Promise<Readable> {
|
||||
const path = join(this.dir, file);
|
||||
const path = this.resolvePath(file);
|
||||
if (!path) throw new Error('Invalid path provided');
|
||||
|
||||
const readStream = createReadStream(path, { start, end });
|
||||
|
||||
return readStream;
|
||||
}
|
||||
|
||||
public async rename(from: string, to: string): Promise<void> {
|
||||
const fromPath = join(this.dir, from);
|
||||
const toPath = join(this.dir, to);
|
||||
const fromPath = this.resolvePath(from);
|
||||
const toPath = this.resolvePath(to);
|
||||
if (!fromPath || !toPath) throw new Error('Invalid path provided');
|
||||
|
||||
if (!existsSync(fromPath))
|
||||
throw new Error(`Something went very wrong! File ${from} does not exist in local datasource.`);
|
||||
|
||||
@@ -23,6 +23,7 @@ export const userViewSchema = z
|
||||
showFolder: z.boolean().nullish(),
|
||||
content: z.string().nullish(),
|
||||
embed: z.boolean().nullish(),
|
||||
embedMediaOnly: z.boolean().nullish(),
|
||||
embedTitle: z.string().nullish(),
|
||||
embedDescription: z.string().nullish(),
|
||||
embedColor: z.string().nullish(),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import z from 'zod';
|
||||
import { prisma } from './db';
|
||||
import { Metric } from './db/models/metric';
|
||||
|
||||
export const metricsPointSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
users: z.number(),
|
||||
files: z.number(),
|
||||
fileViews: z.number(),
|
||||
urls: z.number(),
|
||||
urlViews: z.number(),
|
||||
storage: z.bigint(),
|
||||
});
|
||||
|
||||
export type MetricsPoint = z.infer<typeof metricsPointSchema>;
|
||||
|
||||
export function getMetricsPoints(from?: Date, to?: Date): Promise<MetricsPoint[]> {
|
||||
if (from && to) {
|
||||
return prisma.$queryRaw<MetricsPoint[]>`
|
||||
SELECT
|
||||
id,
|
||||
"createdAt",
|
||||
(data->>'users')::int AS users,
|
||||
(data->>'files')::int AS files,
|
||||
(data->>'fileViews')::int AS "fileViews",
|
||||
(data->>'urls')::int AS urls,
|
||||
(data->>'urlViews')::int AS "urlViews",
|
||||
(data->>'storage')::bigint AS storage
|
||||
FROM "Metric"
|
||||
WHERE "createdAt" >= ${from} AND "createdAt" <= ${to}
|
||||
ORDER BY "createdAt" DESC
|
||||
`;
|
||||
}
|
||||
|
||||
return prisma.$queryRaw<MetricsPoint[]>`
|
||||
SELECT
|
||||
id,
|
||||
"createdAt",
|
||||
(data->>'users')::int AS users,
|
||||
(data->>'files')::int AS files,
|
||||
(data->>'fileViews')::int AS "fileViews",
|
||||
(data->>'urls')::int AS urls,
|
||||
(data->>'urlViews')::int AS "urlViews",
|
||||
(data->>'storage')::bigint AS storage
|
||||
FROM "Metric"
|
||||
ORDER BY "createdAt" DESC
|
||||
`;
|
||||
}
|
||||
|
||||
export function getLatestMetricsPoint(from?: Date, to?: Date): Promise<Metric | null> {
|
||||
return prisma.metric.findFirst({
|
||||
where: from && to ? { createdAt: { gte: from, lte: to } } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export function downsample(points: MetricsPoint[], max: number = 500): MetricsPoint[] {
|
||||
if (points.length <= max) return points;
|
||||
|
||||
const indices = new Set<number>();
|
||||
indices.add(0);
|
||||
indices.add(points.length - 1);
|
||||
|
||||
const middle = max - 2;
|
||||
const step = (points.length - 1) / (middle + 1);
|
||||
for (let i = 1; i <= middle; i++) {
|
||||
indices.add(Math.round(i * step));
|
||||
}
|
||||
|
||||
return [...indices].sort((a, b) => a - b).map((i) => points[i]!);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export function themeComponents(theme: ZiplineTheme): MantineThemeOverride {
|
||||
return {
|
||||
...rest,
|
||||
variantColorResolver: variantColorResolver,
|
||||
defaultRadius: 'md',
|
||||
components: {
|
||||
...components,
|
||||
AppShell: AppShell.extend({
|
||||
|
||||
@@ -20,16 +20,18 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Fetch a folder by ID. Behavior varies based on public and allowUploads flags.',
|
||||
description: 'Fetch a folder by ID/name. Behavior varies based on public and allowUploads flags.',
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
querystring: paginationQs.pick({
|
||||
page: true,
|
||||
perpage: true,
|
||||
sortBy: true,
|
||||
order: true,
|
||||
}),
|
||||
querystring: paginationQs
|
||||
.pick({
|
||||
page: true,
|
||||
perpage: true,
|
||||
sortBy: true,
|
||||
order: true,
|
||||
})
|
||||
.partial({ page: true }),
|
||||
response: {
|
||||
200: z.object({
|
||||
folder: folderSchema.partial(),
|
||||
@@ -42,10 +44,11 @@ export default typedPlugin(
|
||||
},
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { page, perpage, sortBy, order } = req.query;
|
||||
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: { id },
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
OR: [{ id }, { name: id }],
|
||||
},
|
||||
include: {
|
||||
children: {
|
||||
where: { public: true },
|
||||
@@ -68,6 +71,21 @@ export default typedPlugin(
|
||||
if (!folder) throw new ApiError(9002);
|
||||
if (!folder.public && !folder.allowUploads) throw new ApiError(9002);
|
||||
|
||||
const { page, perpage, sortBy, order } = req.query;
|
||||
if (!page && folder.allowUploads) {
|
||||
return res.send({
|
||||
folder: {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
allowUploads: folder.allowUploads,
|
||||
public: folder.public,
|
||||
},
|
||||
page: [],
|
||||
total: 0,
|
||||
pages: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const where = { folderId: folder.id };
|
||||
const total = await prisma.file.count({ where });
|
||||
const pages = total === 0 ? 0 : Math.ceil(total / perpage);
|
||||
@@ -85,20 +103,6 @@ export default typedPlugin(
|
||||
true,
|
||||
);
|
||||
|
||||
if (!folder.public && folder.allowUploads) {
|
||||
return res.send({
|
||||
folder: {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
allowUploads: folder.allowUploads,
|
||||
public: folder.public,
|
||||
},
|
||||
page: [],
|
||||
total,
|
||||
pages,
|
||||
});
|
||||
}
|
||||
|
||||
if (folder.parentId) {
|
||||
folder.parent = await buildPublicParentChain(folder.parentId);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { createToken } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
import { export3Schema } from '@/lib/import/version3/validateExport';
|
||||
import { log } from '@/lib/logger';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
@@ -182,10 +184,16 @@ export default typedPlugin(
|
||||
continue;
|
||||
}
|
||||
|
||||
let sanitizedFilename = sanitizeFilename(file.name);
|
||||
if (!sanitizedFilename) {
|
||||
sanitizedFilename = randomCharacters(12);
|
||||
logger.warn('file has invalid name, using random name', { file: id, new: sanitizedFilename });
|
||||
}
|
||||
|
||||
const created = await prisma.file.create({
|
||||
data: {
|
||||
userId: user,
|
||||
name: file.name,
|
||||
name: sanitizedFilename,
|
||||
originalName: file.original_name || null,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { createToken } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
import { export4Schema } from '@/lib/import/version4/validateExport';
|
||||
import { log } from '@/lib/logger';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
@@ -355,10 +357,19 @@ export default typedPlugin(
|
||||
|
||||
const folderId = file.folderId ? importedFolders[file.folderId] : null;
|
||||
|
||||
let sanitizedFilename = sanitizeFilename(file.name);
|
||||
if (!sanitizedFilename) {
|
||||
sanitizedFilename = randomCharacters(12);
|
||||
logger.warn('file has invalid name, using random name', {
|
||||
file: file.id,
|
||||
new: sanitizedFilename,
|
||||
});
|
||||
}
|
||||
|
||||
const created = await prisma.file.create({
|
||||
data: {
|
||||
userId,
|
||||
name: file.name,
|
||||
name: sanitizedFilename,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
folderId,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { checkOutput, COMPRESS_TYPES } from '@/lib/compress';
|
||||
import { reloadSettings } from '@/lib/config';
|
||||
import type { readDatabaseSettings } from '@/lib/config/read/db';
|
||||
import { safeConfig } from '@/lib/config/safe';
|
||||
import { MAX_SAFE_TIMEOUT_MS } from '@/lib/config/validate';
|
||||
import { MAX_SAFE_TIMEOUT_MS, MIME_REGEX } from '@/lib/config/validate';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
@@ -181,6 +181,8 @@ export default typedPlugin(
|
||||
'Provided route is reserved',
|
||||
),
|
||||
filesLength: z.number().min(1).max(64),
|
||||
filesDisabledTypes: z.array(z.string().regex(MIME_REGEX, 'Invalid MIME type')),
|
||||
filesDisabledTypesDefault: z.string().regex(MIME_REGEX, 'Invalid MIME type').nullable(),
|
||||
filesDefaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat']),
|
||||
filesDisabledExtensions: z
|
||||
.union([
|
||||
@@ -191,7 +193,6 @@ export default typedPlugin(
|
||||
typeof value === 'string' ? value.split(',').map((ext) => ext.trim()) : value,
|
||||
),
|
||||
filesMaxFileSize: zBytes,
|
||||
|
||||
filesDefaultExpiration: zMs.nullable(),
|
||||
filesMaxExpiration: zMs.nullable(),
|
||||
filesAssumeMimetypes: z.boolean(),
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Metric, metricSchema } from '@/lib/db/models/metric';
|
||||
import { metricSchema } from '@/lib/db/models/metric';
|
||||
import { downsample, getLatestMetricsPoint, getMetricsPoints, metricsPointSchema } from '@/lib/metrics';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { zQsBoolean } from '@/lib/validation';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiStatsResponse = Metric[];
|
||||
export const apiStatsResponseSchema = z.object({
|
||||
latest: metricSchema.nullable(),
|
||||
points: z.array(metricsPointSchema),
|
||||
});
|
||||
|
||||
export type ApiStatsResponse = z.infer<typeof apiStatsResponseSchema>;
|
||||
|
||||
export const PATH = '/api/stats';
|
||||
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
@@ -39,7 +45,7 @@ export default typedPlugin(
|
||||
all: zQsBoolean.default(false),
|
||||
}),
|
||||
response: {
|
||||
200: z.array(metricSchema),
|
||||
200: apiStatsResponseSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
@@ -60,30 +66,20 @@ export default typedPlugin(
|
||||
if (fromDate > new Date()) throw new ApiError(1059);
|
||||
}
|
||||
|
||||
const stats = await prisma.metric.findMany({
|
||||
where: {
|
||||
...(!all && {
|
||||
createdAt: {
|
||||
gte: fromDate,
|
||||
lte: toDate,
|
||||
},
|
||||
}),
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
const [latest, points] = await Promise.all([
|
||||
getLatestMetricsPoint(!all ? fromDate : undefined, !all ? toDate : undefined),
|
||||
all ? getMetricsPoints() : getMetricsPoints(fromDate, toDate),
|
||||
]);
|
||||
|
||||
if (!config.features.metrics.showUserSpecific) {
|
||||
for (let i = 0; i !== stats.length; ++i) {
|
||||
const stat = stats[i].data;
|
||||
|
||||
stat.filesUsers = [];
|
||||
stat.urlsUsers = [];
|
||||
}
|
||||
if (latest && !config.features.metrics.showUserSpecific) {
|
||||
latest.data.filesUsers = [];
|
||||
latest.data.urlsUsers = [];
|
||||
}
|
||||
|
||||
return res.send(stats);
|
||||
return res.send({
|
||||
latest,
|
||||
points: downsample(points),
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -155,7 +155,8 @@ export default typedPlugin(
|
||||
const { fileName } = nameResult;
|
||||
|
||||
// determine mimetype
|
||||
const { mimetype, assumed } = await getMimetype(file.mimetype, extension);
|
||||
const { assumed, ...mimeRes } = await getMimetype(file.mimetype, extension);
|
||||
let mimetype = mimeRes.mimetype;
|
||||
|
||||
if (config.files.assumeMimetypes) {
|
||||
response.assumedMimetypes![i] = assumed;
|
||||
@@ -170,6 +171,12 @@ export default typedPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
if (config.files.disabledTypes.includes(mimetype.trim().toLowerCase())) {
|
||||
console.log(mimetype, config.files.disabledTypesDefault);
|
||||
if (config.files.disabledTypesDefault) mimetype = config.files.disabledTypesDefault;
|
||||
else throw new ApiError(1065, `file[${i}]: File type ${mimetype} is not allowed`);
|
||||
}
|
||||
|
||||
// compress the image if requested
|
||||
let compressed;
|
||||
if (mimetype.startsWith('image/') && options.imageCompression) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import dayjs from 'dayjs';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiUserActivityDay = {
|
||||
date: string;
|
||||
uploads: number;
|
||||
logins: number;
|
||||
};
|
||||
|
||||
export type ApiUserActivityResponse = {
|
||||
days: number;
|
||||
series: ApiUserActivityDay[];
|
||||
totals: {
|
||||
uploads: number;
|
||||
logins: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const PATH = '/api/user/activity';
|
||||
|
||||
const MAX_DAYS = 90;
|
||||
const DEFAULT_DAYS = 14;
|
||||
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Daily upload and login counts for the authenticated user over a recent window.',
|
||||
querystring: z.object({
|
||||
days: z.coerce.number().int().min(1).max(MAX_DAYS).default(DEFAULT_DAYS),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
days: z.number(),
|
||||
series: z.array(
|
||||
z.object({
|
||||
date: z.string(),
|
||||
uploads: z.number(),
|
||||
logins: z.number(),
|
||||
}),
|
||||
),
|
||||
totals: z.object({
|
||||
uploads: z.number(),
|
||||
logins: z.number(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const days = req.query.days;
|
||||
const start = dayjs()
|
||||
.subtract(days - 1, 'day')
|
||||
.startOf('day')
|
||||
.toDate();
|
||||
|
||||
const [files, sessions] = await Promise.all([
|
||||
prisma.file.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
createdAt: { gte: start },
|
||||
},
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.userSession.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
createdAt: { gte: start },
|
||||
},
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const uploadsByDay = new Map<string, number>();
|
||||
const loginsByDay = new Map<string, number>();
|
||||
|
||||
for (const file of files) {
|
||||
const key = dayjs(file.createdAt).format('YYYY-MM-DD');
|
||||
uploadsByDay.set(key, (uploadsByDay.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = dayjs(session.createdAt).format('YYYY-MM-DD');
|
||||
loginsByDay.set(key, (loginsByDay.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const series: ApiUserActivityDay[] = [];
|
||||
let totalUploads = 0;
|
||||
let totalLogins = 0;
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const day = dayjs().subtract(i, 'day').startOf('day');
|
||||
const key = day.format('YYYY-MM-DD');
|
||||
const uploads = uploadsByDay.get(key) ?? 0;
|
||||
const logins = loginsByDay.get(key) ?? 0;
|
||||
|
||||
totalUploads += uploads;
|
||||
totalLogins += logins;
|
||||
|
||||
series.push({
|
||||
date: day.toISOString(),
|
||||
uploads,
|
||||
logins,
|
||||
});
|
||||
}
|
||||
|
||||
return res.send({
|
||||
days,
|
||||
series,
|
||||
totals: {
|
||||
uploads: totalUploads,
|
||||
logins: totalLogins,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
@@ -133,6 +133,7 @@ export default typedPlugin(
|
||||
id: {
|
||||
in: files,
|
||||
},
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -52,6 +52,7 @@ export default typedPlugin(
|
||||
.object({
|
||||
content: z.string().nullish(),
|
||||
embed: z.boolean().optional(),
|
||||
embedMediaOnly: z.boolean().optional(),
|
||||
embedTitle: z.string().nullish(),
|
||||
embedDescription: z.string().nullish(),
|
||||
embedColor: z.string().nullish(),
|
||||
@@ -101,6 +102,14 @@ export default typedPlugin(
|
||||
...(req.body.view.enabled !== undefined && { enabled: req.body.view.enabled || false }),
|
||||
...(req.body.view.content !== undefined && { content: req.body.view.content || null }),
|
||||
...(req.body.view.embed !== undefined && { embed: req.body.view.embed || false }),
|
||||
...(req.body.view.embedMediaOnly !== undefined && {
|
||||
embedMediaOnly: (() => {
|
||||
const embedOn = !!(req.body.view.embed !== undefined
|
||||
? req.body.view.embed
|
||||
: (req.user.view as { embed?: boolean }).embed);
|
||||
return embedOn ? false : req.body.view.embedMediaOnly || false;
|
||||
})(),
|
||||
}),
|
||||
...(req.body.view.embedTitle !== undefined && {
|
||||
embedTitle: req.body.view.embedTitle || null,
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseRange } from '@/lib/api/range';
|
||||
import { config } from '@/lib/config';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
import { log } from '@/lib/logger';
|
||||
import { guess } from '@/lib/mimes';
|
||||
import { TimedCache } from '@/lib/timedCache';
|
||||
@@ -34,10 +35,16 @@ export const rawFileHandler = async (
|
||||
const { id } = req.params;
|
||||
const { token, download } = req.query;
|
||||
|
||||
const idSanitized = sanitizeFilename(id);
|
||||
if (!idSanitized) return res.callNotFound();
|
||||
|
||||
if (id.startsWith('.thumbnail')) {
|
||||
const thumbnail = await prisma.thumbnail.findFirst({
|
||||
where: {
|
||||
path: id,
|
||||
path: idSanitized,
|
||||
file: {
|
||||
password: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user