Compare commits

..

12 Commits

Author SHA1 Message Date
diced 8fb21988a7 feat(v4.6.1): version 2026-05-20 09:01:10 -07:00
diced 72fc8116d4 fix: build errors 2026-05-20 09:00:19 -07:00
dicedtomato 177febf305 Merge commit from fork 2026-05-20 08:54:59 -07:00
diced a8c65c19b4 refactor: nuqs 2026-05-15 23:03:11 -07:00
diced 3fc3dcd1ed fix: folder files not showing anything 2026-05-15 14:48:00 -07:00
zorex 0c52b48c05 feat(view): media-only OpenGraph previews when embeds disabled (#1090)
Add embedMediaOnly view setting: emit og:image/og:video (and related tags)
without custom title, description, or site name for Discord-style unfurls.
Canonical og:url uses the view page URL. API and dashboard settings updated.

Co-authored-by: dicedtomato <git@diced.sh>
2026-05-15 12:57:58 -07:00
diced 5c386a792e fix: add pnpm-workspace.yml to dockerfile 2026-05-14 23:59:00 -07:00
diced c4f8aa52a4 chore: update node + update packages 2026-05-14 23:55:45 -07:00
diced 5c0097fed5 feat: option to turn on automute 2026-05-14 23:09:01 -07:00
diced 6ebd8f68f9 fix: #1083 2026-05-14 22:54:13 -07:00
diced f6188cf15b fix: #1089 2026-05-13 16:03:29 -07:00
diced c9cbc2322f fix: #1087 2026-05-12 19:33:49 -07:00
30 changed files with 1287 additions and 1690 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.6.0",
"version": "4.6.1",
"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"
}
+989 -1425
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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:
+5 -1
View File
@@ -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>
+3 -3
View File
@@ -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'));
@@ -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]']>(
{
+42 -41
View File
@@ -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)}`,
};
}
@@ -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}
+49 -23
View File
@@ -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)} />
);
@@ -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>
+21 -45
View File
@@ -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>
@@ -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>
@@ -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,
@@ -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.'
@@ -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'>
+5 -9
View File
@@ -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>
-37
View File
@@ -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];
}
+2
View File
@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware';
export type SettingsStore = {
settings: {
disableMediaPreview: boolean;
mediaAutoMuted: boolean;
warnDeletion: boolean;
fileNavButtons: boolean;
fileViewer: 'default' | 'fullscreen';
@@ -18,6 +19,7 @@ export type SettingsStore = {
const defaultSettings: SettingsStore['settings'] = {
disableMediaPreview: false,
mediaAutoMuted: true,
warnDeletion: true,
fileNavButtons: true,
fileViewer: 'fullscreen',
+14 -8
View File
@@ -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.`);
+1
View File
@@ -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(),
+23 -21
View File
@@ -24,12 +24,14 @@ export default typedPlugin(
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,7 +44,6 @@ 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 },
@@ -68,6 +69,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 +101,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);
}
+9 -1
View File
@@ -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,
+12 -1
View File
@@ -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,
+9
View File
@@ -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,
}),