mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 20:01:36 +00:00
+2
-1
@@ -46,14 +46,15 @@
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.0.2",
|
||||
"fflate": "^0.8.2",
|
||||
"googleapis": "^118.0.0",
|
||||
"input-otp": "^1.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.363.0",
|
||||
"next": "^14.1.4",
|
||||
"next-themes": "^0.3.0",
|
||||
"nextjs-toploader": "^1.6.11",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "^18",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.10.0",
|
||||
|
||||
+15
-2
@@ -51,6 +51,9 @@ export default function FileGrid({ data }: Props) {
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(
|
||||
`/api/thumb/${data.encryptedId}?size=2`,
|
||||
);
|
||||
const [actionOpen, setActionOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
@@ -220,13 +223,23 @@ export default function FileGrid({ data }: Props) {
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={`/api/thumb/${data.encryptedId}`}
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='rounded-top-[var(--radius)] absolute -z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-cover opacity-50'
|
||||
/>
|
||||
<img
|
||||
src={`/api/thumb/${data.encryptedId}`}
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain backdrop-blur'
|
||||
/>
|
||||
|
||||
|
||||
+19
-6
@@ -29,6 +29,7 @@ import {
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
@@ -42,14 +43,16 @@ export default function FileList({ data }: Props) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const filePath = useMemo<string>(() => {
|
||||
// const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
|
||||
// Set to pathname to remove the /e prefix
|
||||
const path = [pathname, encodeURIComponent(data.name)]
|
||||
.join("/")
|
||||
.replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(
|
||||
`/api/thumb/${data.encryptedId}?size=2`,
|
||||
);
|
||||
const [actionOpen, setActionOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
@@ -218,9 +221,14 @@ export default function FileList({ data }: Props) {
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={`/api/thumb/${data.encryptedId}`}
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-12'
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
@@ -230,6 +238,11 @@ export default function FileList({ data }: Props) {
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={24}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-[10px] text-foreground tablet:text-xs'>
|
||||
{durationToReadable(
|
||||
data.videoMediaMetadata?.durationMillis || 0,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -240,13 +253,13 @@ export default function FileList({ data }: Props) {
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-12'
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex w-full flex-col'>
|
||||
<div className='flex flex-grow flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
|
||||
@@ -503,6 +503,9 @@ export default function HeaderButton({ children }: PropsWithChildren) {
|
||||
}
|
||||
|
||||
function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(
|
||||
`/api/thumb/${data.encryptedId}?size=2`,
|
||||
);
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
@@ -542,9 +545,14 @@ function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={`/api/thumb/${data.encryptedId}`}
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-12'
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
@@ -564,7 +572,7 @@ function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-12'
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -105,14 +105,14 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
className={cn("h-48 w-64 object-contain")}
|
||||
/>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<h3 className='text-pretty text-center'>
|
||||
<h3 className='text-balance text-center'>
|
||||
{path === "global"
|
||||
? "This site are password protected"
|
||||
: "The folder or file you are trying to access is password protected"}
|
||||
</h3>
|
||||
<span className='muted text-pretty text-center'>
|
||||
{/* <span className='muted text-pretty text-center'>
|
||||
Please enter the password to access the content
|
||||
</span>
|
||||
</span> */}
|
||||
</div>
|
||||
|
||||
<form
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Separator } from "~/components/ui/separator";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
@@ -42,6 +43,14 @@ export default function PreviewAction({ file }: Props) {
|
||||
file.mimeType.startsWith("audio")
|
||||
);
|
||||
}, [file]);
|
||||
const showViewDoc = useMemo<boolean>(() => {
|
||||
const fileExtensionFallback = file.name.split(".").pop();
|
||||
const fileExt = file.fileExtension ?? fileExtensionFallback;
|
||||
if (!fileExt) return false;
|
||||
const fileType = getFileType(fileExt, file.mimeType);
|
||||
return fileType === "document" || fileType === "pdf";
|
||||
}, [file]);
|
||||
|
||||
const fileInfo = useMemo<{ label: string; value: string }[]>(() => {
|
||||
const value = [
|
||||
{
|
||||
@@ -154,6 +163,38 @@ export default function PreviewAction({ file }: Props) {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
const onOpenViewer = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Creating view token...", {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create view token");
|
||||
const streamURL = new URL(
|
||||
`/api/stream/${file.encryptedId}`,
|
||||
config.basePath,
|
||||
);
|
||||
streamURL.searchParams.set("token", token);
|
||||
toast.success("Opening viewer link...", {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
const viewerUrl = new URL(`/gview`, "https://docs.google.com");
|
||||
viewerUrl.searchParams.set("url", streamURL.toString());
|
||||
viewerUrl.searchParams.set("embedded", "true");
|
||||
window.open(viewerUrl.toString(), "_blank");
|
||||
}, 150);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
@@ -279,6 +320,20 @@ export default function PreviewAction({ file }: Props) {
|
||||
Raw Link
|
||||
</Button>
|
||||
) : null}
|
||||
{showViewDoc ? (
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='gap-3'
|
||||
onClick={onOpenViewer}
|
||||
>
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
size={16}
|
||||
/>
|
||||
Open in Viewer
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
|
||||
+59
-16
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import AudioPlayer from "react-h5-audio-player";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
@@ -10,7 +10,27 @@ import { cn } from "~/utils";
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
import "./r5-style.css";
|
||||
|
||||
// import "./r5-style.css";
|
||||
|
||||
const Plyr = dynamic(() => import("plyr-react"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading player...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
@@ -28,7 +48,7 @@ export default function PreviewAudio({ file }: Props) {
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setAudioSrc(`/api/download/${file.encryptedId}?token=${token}`);
|
||||
setAudioSrc(`/api/stream/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
@@ -66,20 +86,43 @@ export default function PreviewAudio({ file }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<div className='w-full'>
|
||||
<AudioPlayer
|
||||
autoPlay={false}
|
||||
layout='stacked-reverse'
|
||||
src={audioSrc}
|
||||
showJumpControls={false}
|
||||
showFilledVolume
|
||||
style={{
|
||||
borderRadius: "var(--radius)",
|
||||
<Plyr
|
||||
source={{
|
||||
type: "audio",
|
||||
sources: [
|
||||
{
|
||||
src: audioSrc,
|
||||
type: file.mimeType,
|
||||
size: file.size,
|
||||
},
|
||||
],
|
||||
title: file.name,
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error(e);
|
||||
setError(
|
||||
"Could not preview this audio, try downloading the file",
|
||||
);
|
||||
options={{
|
||||
controls: [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"duration",
|
||||
"mute",
|
||||
"volume",
|
||||
"settings",
|
||||
"fullscreen",
|
||||
],
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
loop: {
|
||||
active: false,
|
||||
},
|
||||
speed: {
|
||||
selected: 1,
|
||||
options: [0.5, 1, 1.5, 2],
|
||||
},
|
||||
keyboard: {
|
||||
focused: true,
|
||||
global: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+134
-62
@@ -1,22 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
|
||||
import DocViewer, { DocRenderer } from "@cyntler/react-doc-viewer";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
const GoogleDocsViewerRenderer: DocRenderer = ({
|
||||
mainState: { currentDocument, documentLoading },
|
||||
}) => {
|
||||
if (!currentDocument || !currentDocument.uri) return null;
|
||||
|
||||
const viewerUrl = new URL(`/gview`, "https://docs.google.com");
|
||||
viewerUrl.searchParams.set("url", currentDocument.uri);
|
||||
viewerUrl.searchParams.set("embedded", "true");
|
||||
|
||||
if (documentLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading document...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={viewerUrl.toString()}
|
||||
className='m-0 h-full max-h-[70dvh] min-h-[70dvh] w-full overflow-hidden rounded-[var(--radius)] border border-border p-0 !text-black'
|
||||
frameBorder={0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
GoogleDocsViewerRenderer.fileTypes = [
|
||||
"application/vnd.google-apps.document",
|
||||
"application/vnd.google-apps.presentation",
|
||||
"application/vnd.google-apps.spreadsheet",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"text/csv",
|
||||
"application/pdf",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
];
|
||||
GoogleDocsViewerRenderer.weight = 100;
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
|
||||
export default function PreviewDoc({ file }: Props) {
|
||||
const [docSrc, setDocSrc] = useState<string>("");
|
||||
const [docBuffer, setDocBuffer] = useState<ArrayBuffer>();
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
@@ -28,11 +81,12 @@ export default function PreviewDoc({ file }: Props) {
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
const buffer = await fetch(
|
||||
`/api/download/${file.encryptedId}?token=${token}`,
|
||||
).then((res) => res.arrayBuffer());
|
||||
setDocBuffer(buffer);
|
||||
setDocSrc(`/api/download/${file.encryptedId}?token=${token}`);
|
||||
const streamURL = new URL(
|
||||
`/api/stream/${file.encryptedId}`,
|
||||
config.basePath,
|
||||
);
|
||||
streamURL.searchParams.set("token", token);
|
||||
setDocSrc(streamURL.toString());
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
@@ -69,61 +123,79 @@ export default function PreviewDoc({ file }: Props) {
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<DocViewer
|
||||
key={file.encryptedId}
|
||||
documents={[
|
||||
{
|
||||
uri: docSrc,
|
||||
fileData: docBuffer,
|
||||
fileName: file.name,
|
||||
fileType: file.mimeType,
|
||||
},
|
||||
]}
|
||||
pluginRenderers={DocViewerRenderers}
|
||||
config={{
|
||||
header: {
|
||||
disableHeader: true,
|
||||
},
|
||||
loadingRenderer: {
|
||||
overrideComponent: () => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
showLoadingTimeout: 10000,
|
||||
},
|
||||
noRenderer: {
|
||||
overrideComponent: () => (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>
|
||||
Error loading document
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}}
|
||||
className={cn(
|
||||
"h-full max-h-[70dvh] min-h-[70dvh] w-full rounded-[var(--radius)] border border-border !text-black",
|
||||
)}
|
||||
theme={{
|
||||
disableThemeScrollbar: true,
|
||||
}}
|
||||
/>
|
||||
<div className='h-fit w-full space-y-3 overflow-hidden rounded-[var(--radius)]'>
|
||||
<DocViewer
|
||||
key={file.encryptedId}
|
||||
documents={[
|
||||
{
|
||||
uri: docSrc,
|
||||
fileName: file.name,
|
||||
fileType: file.mimeType,
|
||||
},
|
||||
]}
|
||||
pluginRenderers={[GoogleDocsViewerRenderer]}
|
||||
config={{
|
||||
header: {
|
||||
disableHeader: true,
|
||||
},
|
||||
loadingRenderer: {
|
||||
overrideComponent: () => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
showLoadingTimeout: 10000,
|
||||
},
|
||||
noRenderer: {
|
||||
overrideComponent: () => (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>
|
||||
Error loading document
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}}
|
||||
className={cn(
|
||||
"h-full max-h-[70dvh] min-h-[70dvh] w-full overflow-hidden rounded-[var(--radius)] !text-black",
|
||||
"rounded-[var(--radius)] [&>div#proxy-renderer]:overflow-hidden ",
|
||||
)}
|
||||
theme={{
|
||||
disableThemeScrollbar: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
It might failed to load the document, you can try to refresh
|
||||
the page or download the document to view it.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+59
-23
@@ -6,6 +6,9 @@ import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
@@ -13,7 +16,10 @@ type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewImage({ file }: Props) {
|
||||
const [imgSrc, setImgSrc] = useState<string>("");
|
||||
const [imgSrc, setImgSrc] = useState<string>(
|
||||
`/api/thumb/${file.encryptedId}?size=4`,
|
||||
);
|
||||
const [imgLoaded, setImgLoaded] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
@@ -25,27 +31,30 @@ export default function PreviewImage({ file }: Props) {
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
await fetch(`/api/download/${file.encryptedId}?token=${token}`)
|
||||
|
||||
const streamURL = new URL(
|
||||
`/api/thumb/${file.encryptedId}?size=1000`,
|
||||
config.basePath,
|
||||
);
|
||||
streamURL.searchParams.set("token", token);
|
||||
fetch(streamURL, {
|
||||
headers: {
|
||||
Range: `bytes=0-${(file.size || 1) - 1}`,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch image");
|
||||
if (!res.ok) {
|
||||
throw new Error("Could not load image");
|
||||
}
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImgSrc(reader.result as string);
|
||||
};
|
||||
reader.onerror = (e) => {
|
||||
console.error(e);
|
||||
setError(
|
||||
"Could not preview this image, try downloading the file",
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e.message);
|
||||
setError(e.message);
|
||||
const urlobject = URL.createObjectURL(blob);
|
||||
setImgSrc(urlobject);
|
||||
const timeout = setTimeout(() => {
|
||||
setImgLoaded(true);
|
||||
clearTimeout(timeout);
|
||||
}, 150); // Add a delay to show the image
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
@@ -83,11 +92,38 @@ export default function PreviewImage({ file }: Props) {
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={file.name}
|
||||
className='max-h-[70dvh] w-full rounded-[var(--radius)] bg-muted object-contain object-center'
|
||||
/>
|
||||
<div className='h-fit w-full space-y-3 overflow-hidden rounded-[var(--radius)]'>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={file.name}
|
||||
className={cn(
|
||||
"h-full max-h-[70dvh] w-full bg-muted object-contain object-center transition",
|
||||
imgLoaded ? "blur-none" : "animate-pulse blur",
|
||||
)}
|
||||
onError={(e) => {
|
||||
console.error(e);
|
||||
setError(
|
||||
"Could not preview this image, try downloading the file",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
This image is a preview and may not be the full resolution.
|
||||
Please download the file for the full resolution.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+66
-34
@@ -3,11 +3,15 @@
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import PreviewAction from "./@preview.action";
|
||||
import PreviewAudio from "./@preview.audio";
|
||||
import PreviewDoc from "./@preview.doc";
|
||||
@@ -26,50 +30,78 @@ export default function FilePreviewLayout({ data, fileType }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Card>
|
||||
<RichHeader
|
||||
title={data.name}
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
fileType={fileType}
|
||||
/>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<div className='px-3'>
|
||||
{fileType === "image" ? (
|
||||
<PreviewImage file={data} />
|
||||
) : fileType === "audio" ? (
|
||||
<PreviewAudio file={data} />
|
||||
) : fileType === "video" ? (
|
||||
<PreviewVideo file={data} />
|
||||
) : fileType === "code" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
code
|
||||
view={view}
|
||||
{config.apiConfig.streamMaxSize &&
|
||||
Number(data.size || 0) > config.apiConfig.streamMaxSize ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[33dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
) : fileType === "text" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "markdown" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "document" ? (
|
||||
<PreviewDoc file={data} />
|
||||
) : fileType === "pdf" ? (
|
||||
<PreviewDoc file={data} />
|
||||
) : fileType === "manga" ? (
|
||||
<PreviewManga file={data} />
|
||||
) : (
|
||||
<PreviewUnknown />
|
||||
)}
|
||||
</div>
|
||||
<h4 className='text-muted-foreground'>Preview not available</h4>
|
||||
<p className='text-center text-muted-foreground tablet:text-sm'>
|
||||
Looks like this file size exceed the preview size limit
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/**
|
||||
* TODO: Might need a better way to handle large files preview
|
||||
* like manga, pdf, etc
|
||||
*
|
||||
* For now it's downloading the whole file and then previewing it
|
||||
* which is not a good implementation
|
||||
*/
|
||||
<div className='px-3'>
|
||||
{fileType === "image" ? (
|
||||
<PreviewImage file={data} />
|
||||
) : fileType === "audio" ? (
|
||||
<PreviewAudio file={data} />
|
||||
) : fileType === "video" ? (
|
||||
<PreviewVideo file={data} />
|
||||
) : fileType === "code" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
code
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "text" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "markdown" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "document" ? (
|
||||
<PreviewDoc file={data} />
|
||||
) : fileType === "pdf" ? (
|
||||
<PreviewDoc file={data} />
|
||||
) : fileType === "manga" ? (
|
||||
<PreviewManga file={data} />
|
||||
) : (
|
||||
<PreviewUnknown />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PreviewAction file={data} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+180
-49
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import JSZip from "jszip";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AsyncUnzipInflate, Unzip } from "fflate";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselApi,
|
||||
@@ -15,9 +17,12 @@ import {
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "~/components/ui/carousel";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
@@ -31,40 +36,101 @@ export default function PreviewManga({ file }: Props) {
|
||||
const [currentImage, setCurrentImage] = useState<number>(1);
|
||||
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
const [loadedPercent, setLoadedPercent] = useState<number>(0);
|
||||
const [loadFirstX, setLoadFirstX] = useState<number>(5); // 5MB
|
||||
|
||||
const abortController = useRef<AbortController>(new AbortController());
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!abortController.current) {
|
||||
abortController.current = new AbortController();
|
||||
}
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No video to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
const manga = await fetch(
|
||||
`/api/download/${file.encryptedId}?token=${token}`,
|
||||
const streamURL = new URL(
|
||||
`/api/stream/${file.encryptedId}`,
|
||||
config.basePath,
|
||||
);
|
||||
const archiveBlob = await manga.blob();
|
||||
const zipData = await JSZip.loadAsync(archiveBlob);
|
||||
streamURL.searchParams.set("token", token);
|
||||
|
||||
const tempArray: { name: string; blob: string }[] = [];
|
||||
const files = Object.values(zipData.files);
|
||||
for (const file of files) {
|
||||
const f = zipData.file(file.name);
|
||||
if (!f) continue;
|
||||
const blob = await f.async("blob");
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImages((prev) => {
|
||||
const exist = prev.find((p) => p.name === file.name);
|
||||
if (exist) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ name: file.name, blob: reader.result as string },
|
||||
];
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
const bufferStream = await fetch(streamURL, {
|
||||
signal: abortController.current.signal,
|
||||
headers: {
|
||||
Range: `bytes=0-${
|
||||
Math.min(Number(file.size || 1), loadFirstX * 1024 * 1024) - 1
|
||||
}`,
|
||||
},
|
||||
});
|
||||
|
||||
const contentLength = bufferStream.headers.get("Content-Length");
|
||||
const totalBytes = parseInt(contentLength || "0", 10);
|
||||
|
||||
const reader = bufferStream.body?.getReader();
|
||||
if (reader) {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
|
||||
const percent = (receivedLength / totalBytes) * 100;
|
||||
setLoadedPercent((prev) => {
|
||||
if (percent > prev) return percent;
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(receivedLength);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
buffer.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
|
||||
const unzip = new Unzip((file) => {
|
||||
file.ondata = (err, data, final) => {
|
||||
if (err) throw err;
|
||||
// Check if the file is an image
|
||||
if (
|
||||
!file.name
|
||||
.toLowerCase()
|
||||
.includes(".jpg" || ".jpeg" || ".png" || ".gif" || ".webp")
|
||||
)
|
||||
return;
|
||||
if (!final) return; // Skip if not fully loaded
|
||||
|
||||
const blob = new Blob([data]);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImages((prev) => {
|
||||
const exist = prev.find((p) => p.name === file.name);
|
||||
if (exist) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ name: file.name, blob: reader.result as string },
|
||||
];
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
};
|
||||
file.start();
|
||||
});
|
||||
unzip.register(AsyncUnzipInflate);
|
||||
unzip.push(buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
@@ -74,7 +140,12 @@ export default function PreviewManga({ file }: Props) {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file]);
|
||||
|
||||
// return () => {
|
||||
// abortController.current.abort("Cancelled by user");
|
||||
// };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
@@ -99,7 +170,31 @@ export default function PreviewManga({ file }: Props) {
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading manga content...</p>
|
||||
<p>Please wait while we downloading the content for preview</p>
|
||||
<div className='flex w-full max-w-sm flex-col gap-1'>
|
||||
<Progress
|
||||
className='h-2 w-full'
|
||||
value={loadedPercent}
|
||||
/>
|
||||
<span className='text-center text-xs text-muted-foreground'>
|
||||
{Math.round(loadedPercent)}% loaded
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
onClick={async () => {
|
||||
abortController.current.abort("Cancelled by user");
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setLoading(false);
|
||||
setError("Preview cancelled by user");
|
||||
clearTimeout(timeout);
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
Cancel Preview
|
||||
</Button>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
@@ -111,7 +206,7 @@ export default function PreviewManga({ file }: Props) {
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex w-full flex-col items-center justify-center gap-3'>
|
||||
<div className='h-full w-full tablet:px-8'>
|
||||
<Carousel
|
||||
className={cn(
|
||||
@@ -124,24 +219,28 @@ export default function PreviewManga({ file }: Props) {
|
||||
setApi={setApi}
|
||||
>
|
||||
<CarouselContent className='h-full w-full'>
|
||||
{images.map((image, index) => (
|
||||
<CarouselItem
|
||||
key={index}
|
||||
className='flex h-full flex-col items-center justify-center'
|
||||
>
|
||||
<img
|
||||
src={image.blob}
|
||||
alt={`${image.name} - Page ${index + 1}`}
|
||||
className={cn(
|
||||
"w-full object-contain",
|
||||
viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full",
|
||||
)}
|
||||
/>
|
||||
<span className='muted text-center text-xs'>
|
||||
{image.name}
|
||||
</span>
|
||||
</CarouselItem>
|
||||
))}
|
||||
{images
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((image, index) => (
|
||||
<CarouselItem
|
||||
key={index}
|
||||
className='flex h-full flex-col items-center justify-center'
|
||||
>
|
||||
<img
|
||||
src={image.blob}
|
||||
alt={`${image.name} - Page ${index + 1}`}
|
||||
className={cn(
|
||||
"w-full object-contain",
|
||||
viewSize === "fit"
|
||||
? "h-full max-h-[70dvh]"
|
||||
: "h-full",
|
||||
)}
|
||||
/>
|
||||
<span className='muted text-center text-xs'>
|
||||
{image.name}
|
||||
</span>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
{isDesktop ? (
|
||||
<>
|
||||
@@ -151,11 +250,27 @@ export default function PreviewManga({ file }: Props) {
|
||||
) : null}
|
||||
</Carousel>
|
||||
</div>
|
||||
<div className='flex w-full items-center justify-end gap-1.5'>
|
||||
<span className='muted text-sm'>
|
||||
<div className='flex w-full items-center justify-end gap-3'>
|
||||
<span className='text-primary-foreground'>
|
||||
{currentImage}/{images.length}
|
||||
</span>
|
||||
<div
|
||||
<Button
|
||||
size='icon'
|
||||
variant={"ghost"}
|
||||
className='aspect-square size-8 p-0.5'
|
||||
onClick={() =>
|
||||
setViewSize((prev) => {
|
||||
if (prev === "fit") return "full";
|
||||
return "fit";
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={viewSize === "fit" ? "Maximize" : "Minimize"}
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
{/* <div
|
||||
className='relative z-10 cursor-pointer p-0.5 text-foreground/80 transition hover:text-foreground'
|
||||
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
|
||||
>
|
||||
@@ -163,8 +278,24 @@ export default function PreviewManga({ file }: Props) {
|
||||
name={viewSize === "fit" ? "Maximize" : "Minimize"}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
We only load the first {loadFirstX}MB of the file for preview.
|
||||
Please download the file for full content.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function PreviewRich({ file, code, view }: Props) {
|
||||
// if (code) {
|
||||
// setContent(`\`\`\`${file.fileExtension}\n${text}\`\`\``);
|
||||
// } else {
|
||||
setContent(text);
|
||||
setContent(text.trim());
|
||||
// }
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
@@ -91,7 +91,7 @@ export default function PreviewRich({ file, code, view }: Props) {
|
||||
<Markdown
|
||||
content={
|
||||
code && view === "markdown"
|
||||
? `\`\`\`${file.fileExtension}\n${content}\`\`\``
|
||||
? `\`\`\`${file.fileExtension}\n${content}`
|
||||
: content
|
||||
}
|
||||
view={view}
|
||||
@@ -99,23 +99,26 @@ export default function PreviewRich({ file, code, view }: Props) {
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
|
||||
expand
|
||||
? "pointer-events-none opacity-0"
|
||||
: "pointer-events-auto opacity-100",
|
||||
"bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
|
||||
expand ? "relative" : "absolute",
|
||||
// expand
|
||||
// ? "pointer-events-none opacity-0"
|
||||
// : "pointer-events-auto opacity-100",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
className='gap-1.5'
|
||||
onClick={() => {
|
||||
setExpand(true);
|
||||
setExpand((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name='ChevronDown'
|
||||
name={expand ? "ChevronUp" : "ChevronDown"}
|
||||
size={16}
|
||||
/>
|
||||
Expand
|
||||
{expand ? "Collapse" : "Expand"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+63
-26
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactPlayer from "react-player";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
@@ -10,6 +10,25 @@ import Icon from "~/components/Icon";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
const Plyr = dynamic(() => import("plyr-react"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading player...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
@@ -26,7 +45,7 @@ export default function PreviewVideo({ file }: Props) {
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setVideoSrc(`/api/download/${file.encryptedId}?token=${token}`);
|
||||
setVideoSrc(`/api/stream/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
@@ -63,30 +82,48 @@ export default function PreviewVideo({ file }: Props) {
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<ReactPlayer
|
||||
key={file.encryptedId}
|
||||
url={videoSrc}
|
||||
controls
|
||||
pip
|
||||
wrapper={({ children }) => (
|
||||
<div className='h-[60dvh] w-full overflow-hidden rounded-[var(--radius)] bg-muted'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
maxHeight: "60vh",
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error(error.message);
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError("Failed to load video. (Probably not supported?)");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='h-full w-full'>
|
||||
<Plyr
|
||||
source={{
|
||||
type: "video",
|
||||
sources: [
|
||||
{
|
||||
src: videoSrc,
|
||||
type: file.mimeType,
|
||||
size: file.size,
|
||||
},
|
||||
],
|
||||
poster: `/api/thumb/${file.encryptedId}?size=1000`,
|
||||
}}
|
||||
// crossOrigin='anonymous'
|
||||
options={{
|
||||
toggleInvert: true,
|
||||
settings: ["quality", "speed"],
|
||||
ratio: "16:9",
|
||||
controls: [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"duration",
|
||||
"mute",
|
||||
"volume",
|
||||
"settings",
|
||||
"pip",
|
||||
"download",
|
||||
"fullscreen",
|
||||
],
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
loop: { active: false },
|
||||
speed: { selected: 1, options: [0.5, 1, 1.5, 2] },
|
||||
keyboard: {
|
||||
focused: true,
|
||||
global: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function Readme({ content, title }: Props) {
|
||||
title={title}
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
fileType={"markdown"}
|
||||
/>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Markdown
|
||||
|
||||
+36
-22
@@ -1,37 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { CardHeader } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
view: "markdown" | "raw";
|
||||
onViewChange: (value: "markdown" | "raw") => void;
|
||||
fileType: ReturnType<typeof getFileType> | "unknown";
|
||||
};
|
||||
export default function RichHeader({ title, view, onViewChange }: Props) {
|
||||
export default function RichHeader({
|
||||
title,
|
||||
view,
|
||||
onViewChange,
|
||||
fileType,
|
||||
}: Props) {
|
||||
return (
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex flex-col gap-3 mobile:flex-row mobile:items-center mobile:justify-between'>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<div className='flex w-full items-center mobile:w-fit'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "markdown" ? "default" : "secondary"}
|
||||
onClick={() => onViewChange("markdown")}
|
||||
className='w-full rounded-r-none mobile:w-fit'
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "raw" ? "default" : "secondary"}
|
||||
onClick={() => onViewChange("raw")}
|
||||
className='w-full rounded-l-none mobile:w-fit'
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex flex-col gap-3 overflow-hidden mobile:flex-row mobile:items-center mobile:justify-between'>
|
||||
{/* <CardTitle> */}
|
||||
<h3 className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>
|
||||
{title}
|
||||
</h3>
|
||||
{/* </CardTitle> */}
|
||||
{["markdown", "code", "text"].includes(fileType) && (
|
||||
<div className='flex w-full items-center mobile:w-fit'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "markdown" ? "default" : "secondary"}
|
||||
onClick={() => onViewChange("markdown")}
|
||||
className='w-full rounded-r-none mobile:w-fit'
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "raw" ? "default" : "secondary"}
|
||||
onClick={() => onViewChange("raw")}
|
||||
className='w-full rounded-l-none mobile:w-fit'
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
|
||||
@@ -306,6 +306,7 @@ export function Configuration() {
|
||||
|
||||
const configContent: string = `import { z } from "zod";
|
||||
import { Schema_Config } from "~/schema";
|
||||
import isDev from "~/utils/isDev";
|
||||
|
||||
const config: z.input<typeof Schema_Config> = {
|
||||
/**
|
||||
@@ -322,8 +323,7 @@ const config: z.input<typeof Schema_Config> = {
|
||||
* @default process.env.NEXT_PUBLIC_DOMAIN
|
||||
* @fallback process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
*/
|
||||
basePath:
|
||||
process.env.NODE_ENV === "development"
|
||||
basePath: isDev
|
||||
? "http://localhost:3000"
|
||||
: \`https://\${process.env.NEXT_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_VERCEL_URL}\`,
|
||||
|
||||
@@ -412,6 +412,21 @@ const config: z.input<typeof Schema_Config> = {
|
||||
* Default: true
|
||||
*/
|
||||
proxyThumbnail: ${configuration.api.proxyThumbnail ? "true" : "false"},
|
||||
|
||||
/**
|
||||
* Only show preview for files that are smaller than this size
|
||||
* If the file is larger than this size, it will show "can't preview" message instead
|
||||
*
|
||||
* Why?
|
||||
* Since the stream endpoint are counted as a bandwidth usage
|
||||
* I want to limit the preview to only small files
|
||||
* It also to prevent abuse from the user
|
||||
*
|
||||
* You can also set this to 0 to disable the limit
|
||||
*
|
||||
* Default: 100MB
|
||||
*/
|
||||
streamMaxSize: ${100 * 1024 * 1024},
|
||||
|
||||
/**
|
||||
* Special file name that will be used for certain purposes
|
||||
@@ -540,6 +555,7 @@ const config: z.input<typeof Schema_Config> = {
|
||||
footer: [
|
||||
"{{ siteName }} *v{{ version }}* @ {{ repository }}",
|
||||
"{{ year }} - Made with ❤️ by **{{ author }}**",
|
||||
isDev ? "Development Mode" : "",
|
||||
],
|
||||
|
||||
/**
|
||||
|
||||
+2
-1
@@ -268,7 +268,8 @@ export async function CheckPassword(
|
||||
const currentFolder = paths[folderIndex];
|
||||
if (!cookiesValue[currentFolder.id])
|
||||
throw {
|
||||
message: `Password for '${currentFolder.path}' is not set, please enter the password`,
|
||||
// message: `Password for '${currentFolder.path}' is not set, please enter the password`,
|
||||
message: `Please enter password for '${currentFolder.path}'`,
|
||||
path: currentFolder.id,
|
||||
};
|
||||
|
||||
|
||||
@@ -57,22 +57,8 @@ If you've already entered the password, please make sure your browser is not blo
|
||||
fields: "id, name, mimeType, size, fileExtension, webContentLink",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
const _fileContent = gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
},
|
||||
);
|
||||
|
||||
const [fileMeta, fileContent, filePaths] = await Promise.all([
|
||||
_fileMeta,
|
||||
_fileContent,
|
||||
_filePaths,
|
||||
]);
|
||||
const [fileMeta, filePaths] = await Promise.all([_fileMeta, _filePaths]);
|
||||
|
||||
if (!config.apiConfig.allowDownloadProtectedFile) {
|
||||
const checkPath = await CheckPaths(filePaths.split("/"));
|
||||
@@ -112,16 +98,24 @@ If you've already entered the password, please make sure your browser is not blo
|
||||
config.apiConfig.maxFileSize &&
|
||||
fileSize > config.apiConfig.maxFileSize
|
||||
) {
|
||||
console.log("File size is too large, redirecting to webContentLink");
|
||||
return NextResponse.redirect(fileMeta.data.webContentLink, {
|
||||
const contentUrl = new URL(fileMeta.data.webContentLink);
|
||||
contentUrl.searchParams.set("confirm", "1");
|
||||
return NextResponse.redirect(contentUrl, {
|
||||
status: 302,
|
||||
headers: {
|
||||
...request.headers,
|
||||
"Cache-Control": config.cacheControl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fileContent = await gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
},
|
||||
);
|
||||
|
||||
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
fileContent.data.on("data", (chunk) => {
|
||||
@@ -138,7 +132,6 @@ If you've already entered the password, please make sure your browser is not blo
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
...request.headers,
|
||||
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Readable } from "stream";
|
||||
|
||||
async function* nodeStreamToIterator(stream: any) {
|
||||
for await (const chunk of stream) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function iteratorToStream(iterator: AsyncGenerator<any>) {
|
||||
return new ReadableStream({
|
||||
async pull(controller) {
|
||||
const { done, value } = await iterator.next();
|
||||
if (done) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
export function streamFile(body: Readable): ReadableStream {
|
||||
const data: ReadableStream = iteratorToStream(nodeStreamToIterator(body));
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
CheckDownloadToken,
|
||||
CheckPassword,
|
||||
CheckPaths,
|
||||
RedirectSearchFile,
|
||||
} from "~/app/actions";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
|
||||
import isDev from "~/utils/isDev";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{
|
||||
params: { encryptedId },
|
||||
}: {
|
||||
params: {
|
||||
encryptedId: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
try {
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const token = sp.get("token");
|
||||
if (!token) throw new Error("Token not found");
|
||||
|
||||
// Only allow if the request is from the same domain or the referer is the same domain
|
||||
if (!isDev && !request.headers.get("Referer")?.includes(config.basePath)) {
|
||||
throw new Error("Invalid request");
|
||||
}
|
||||
|
||||
const tokenValidity = await CheckDownloadToken(token);
|
||||
if (!tokenValidity.success) throw new Error(tokenValidity.message);
|
||||
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
|
||||
const _filePaths = RedirectSearchFile(encryptedId);
|
||||
const _fileMeta = gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
fields: "id, name, mimeType, size, fileExtension, webContentLink",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Range": request.headers.get("Range") || `bytes=0-${1024 * 1024 - 1}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [filePaths, fileMeta] = await Promise.all([_filePaths, _fileMeta]);
|
||||
|
||||
const isFull =
|
||||
Number(request.headers.get("Range")?.split("-")[1] || 0) ===
|
||||
Number(fileMeta.data.size || "1") - 1;
|
||||
|
||||
const fileSize = Number(fileMeta.data.size || 0);
|
||||
if (!fileMeta.data.webContentLink)
|
||||
throw new Error("No download link found");
|
||||
|
||||
if (
|
||||
config.apiConfig.streamMaxSize &&
|
||||
fileSize > config.apiConfig.streamMaxSize
|
||||
) {
|
||||
throw new Error("File is too large to stream");
|
||||
}
|
||||
|
||||
if (!config.apiConfig.allowDownloadProtectedFile) {
|
||||
const checkPath = await CheckPaths(filePaths.split("/"));
|
||||
if (!checkPath.success) throw new Error("File not found");
|
||||
const unlocked = await CheckPassword(checkPath.data);
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path)
|
||||
throw new Error("No path returned from password checking");
|
||||
|
||||
const lockedIndex = checkPath.data.findIndex(
|
||||
(path) => path.id === unlocked.path,
|
||||
);
|
||||
// Get all path until the locked index, then join them
|
||||
const path = checkPath.data
|
||||
.slice(0, lockedIndex + 1)
|
||||
.map((path) => path.path)
|
||||
.join("/");
|
||||
return new NextResponse(
|
||||
`The file you're trying to access is protected by password.
|
||||
Please open the file link and enter the password to access the file, then try to download the file again.
|
||||
|
||||
Protected Path: ${new URL(path, config.basePath).toString()}
|
||||
|
||||
If you've already entered the password, please make sure your browser is not blocking cookies from this site.`,
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ranges = request.headers.get("Range") || "bytes=0-";
|
||||
const chunkSize = 5 * 1024 * 1024; // Load 5MB at a time
|
||||
let rangeStart = 0;
|
||||
let rangeEnd = Math.min(chunkSize, fileSize - 1);
|
||||
const rangeRegex = /bytes=(\d+)-(\d+)?/;
|
||||
const rangeSize = ranges.match(rangeRegex);
|
||||
if (rangeSize) {
|
||||
rangeStart = parseInt(rangeSize[1], 10);
|
||||
|
||||
if (isFull) {
|
||||
rangeEnd = fileSize - 1;
|
||||
} else {
|
||||
rangeEnd = Math.min(rangeStart + chunkSize, fileSize - 1);
|
||||
}
|
||||
}
|
||||
|
||||
const contentRange = `bytes=${rangeStart}-${rangeEnd}/${fileSize}`;
|
||||
const contentLength = rangeEnd ? rangeEnd - rangeStart + 1 : fileSize;
|
||||
|
||||
const fileContent = await gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
acknowledgeAbuse: true,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
headers: {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Range": `bytes=${rangeStart}-${rangeEnd}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const stream = fileContent.data as NodeJS.ReadableStream;
|
||||
const fileRange = fileContent.headers["content-range"];
|
||||
const fileLength = fileContent.headers["content-length"];
|
||||
|
||||
const readable = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on("data", (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
stream.on("end", () => {
|
||||
controller.close();
|
||||
});
|
||||
stream.on("error", (error) => {
|
||||
controller.error(error);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(readable, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Range": fileRange || contentRange,
|
||||
"Content-Length": fileLength || contentLength.toString(),
|
||||
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return new NextResponse(e.message, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
import isDev from "~/utils/isDev";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
@@ -16,6 +17,19 @@ export async function GET(
|
||||
{ params: { encryptedId } }: Props,
|
||||
) {
|
||||
try {
|
||||
const searchParams = new URL(request.nextUrl).searchParams;
|
||||
const size = searchParams.get("size") || "512";
|
||||
|
||||
// Only allow if the request is from the same domain or the referer is the same domain
|
||||
if (!isDev && !request.headers.get("Referer")?.includes(config.basePath)) {
|
||||
throw new Error("Invalid request");
|
||||
}
|
||||
|
||||
const validSize = z.coerce.number().safeParse(size);
|
||||
if (!validSize.success) {
|
||||
throw new Error("Invalid size");
|
||||
}
|
||||
|
||||
const defaultImage = NextResponse.redirect(
|
||||
new URL("/og.png", config.basePath),
|
||||
{
|
||||
@@ -23,86 +37,25 @@ export async function GET(
|
||||
},
|
||||
);
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const _fileMeta = gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields:
|
||||
"id, name, mimeType, fileExtension, webContentLink, thumbnailLink",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
|
||||
const url = `https://drive.google.com/thumbnail?id=${decryptedId}&sz=w${size}`;
|
||||
|
||||
if (!config.apiConfig.proxyThumbnail) {
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
const downloadThumb = await fetch(url, {
|
||||
cache: "force-cache",
|
||||
});
|
||||
const _fileContent = gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
const buffer = await downloadThumb.arrayBuffer();
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": "image/jpeg",
|
||||
"Content-Length": buffer.byteLength.toString(),
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
},
|
||||
);
|
||||
|
||||
const [fileMeta, fileContent] = await Promise.all([
|
||||
_fileMeta,
|
||||
_fileContent,
|
||||
]);
|
||||
const fileSize = Number(fileMeta.data.size || 0);
|
||||
|
||||
if (!fileMeta.data.webContentLink) return defaultImage;
|
||||
if (!fileMeta.data.thumbnailLink) return defaultImage;
|
||||
if (
|
||||
!fileMeta.data.mimeType?.startsWith("image") &&
|
||||
!fileMeta.data.mimeType?.startsWith("video")
|
||||
)
|
||||
return defaultImage;
|
||||
|
||||
// If svg, return actual image since there is no thumbnail for svg
|
||||
if (
|
||||
fileMeta.data.mimeType?.includes("svg") &&
|
||||
fileSize <= config.apiConfig.maxFileSize
|
||||
) {
|
||||
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
fileContent.data.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
fileContent.data.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
fileContent.data.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(
|
||||
fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
|
||||
)}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (config.apiConfig.proxyThumbnail) {
|
||||
const downloadThumb = await fetch(fileMeta.data.thumbnailLink, {
|
||||
cache: "force-cache",
|
||||
});
|
||||
const buffer = await downloadThumb.arrayBuffer();
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
|
||||
"Content-Length": buffer.byteLength.toString(),
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(
|
||||
fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
|
||||
)}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.redirect(fileMeta.data.thumbnailLink);
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Metadata } from "next";
|
||||
import { JetBrains_Mono, Outfit, Source_Sans_3 } from "next/font/google";
|
||||
import "plyr-react/plyr.css";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { formatFooter } from "~/utils/footerFormatter";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "~/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { Schema_Config } from "~/schema";
|
||||
|
||||
import isDev from "~/utils/isDev";
|
||||
|
||||
const config: z.input<typeof Schema_Config> = {
|
||||
/**
|
||||
* If possible, please don't change this value
|
||||
@@ -15,12 +17,11 @@ const config: z.input<typeof Schema_Config> = {
|
||||
* @default process.env.NEXT_PUBLIC_DOMAIN
|
||||
* @fallback process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
*/
|
||||
basePath:
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://localhost:3000"
|
||||
: `https://${
|
||||
process.env.NEXT_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
}`,
|
||||
basePath: isDev
|
||||
? "http://localhost:3000"
|
||||
: `https://${
|
||||
process.env.NEXT_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
}`,
|
||||
|
||||
/**
|
||||
* Allow access to the deploy guide
|
||||
@@ -108,6 +109,21 @@ const config: z.input<typeof Schema_Config> = {
|
||||
*/
|
||||
proxyThumbnail: true,
|
||||
|
||||
/**
|
||||
* Only show preview for files that are smaller than this size
|
||||
* If the file is larger than this size, it will show "can't preview" message instead
|
||||
*
|
||||
* Why?
|
||||
* Since the stream endpoint are counted as a bandwidth usage
|
||||
* I want to limit the preview to only small files
|
||||
* It also to prevent abuse from the user
|
||||
*
|
||||
* You can also set this to 0 to disable the limit
|
||||
*
|
||||
* Default: 100MB
|
||||
*/
|
||||
streamMaxSize: 100 * 1024 * 1024,
|
||||
|
||||
/**
|
||||
* Special file name that will be used for certain purposes
|
||||
* These files will be ignored when searching for files
|
||||
@@ -227,6 +243,7 @@ const config: z.input<typeof Schema_Config> = {
|
||||
footer: [
|
||||
"{{ siteName }} *v{{ version }}* @ {{ repository }}",
|
||||
"{{ year }} - Made with ❤️ by **{{ author }}**",
|
||||
isDev ? "Development Mode" : "",
|
||||
],
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,6 +95,7 @@ export const Schema_Config_API = z
|
||||
itemsPerPage: z.number().positive(),
|
||||
searchResult: z.number().positive(),
|
||||
proxyThumbnail: z.boolean(),
|
||||
streamMaxSize: z.number(),
|
||||
|
||||
specialFile: z.object({
|
||||
password: z.string(),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* It is a helper to check if it's not in production environment.
|
||||
* If you're developing, and not using vercel, you can add your own condition.
|
||||
*/
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" ||
|
||||
process.env.VERCEL_ENV === "development" ||
|
||||
process.env.VERCEL_ENV === "preview";
|
||||
|
||||
export default isDev;
|
||||
@@ -2859,10 +2859,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"core-util-is@npm:~1.0.0":
|
||||
version: 1.0.3
|
||||
resolution: "core-util-is@npm:1.0.3"
|
||||
checksum: 10c0/90a0e40abbddfd7618f8ccd63a74d88deea94e77d0e8dbbea059fa7ebebb8fbb4e2909667fe26f3a467073de1a542ebe6ae4c73a73745ac5833786759cd906c9
|
||||
"core-js@npm:^3.26.1":
|
||||
version: 3.37.0
|
||||
resolution: "core-js@npm:3.37.0"
|
||||
checksum: 10c0/7e00331f346318ca3f595c08ce9e74ddae744715aef137486c1399163afd79792fb94c3161280863adfdc3e30f8026912d56bd3036f93cacfc689d33e185f2ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2918,6 +2918,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"custom-event-polyfill@npm:^1.0.7":
|
||||
version: 1.0.7
|
||||
resolution: "custom-event-polyfill@npm:1.0.7"
|
||||
checksum: 10c0/b73c90d646d78f4acdff5453fa0f165f6d5506e32d074ca57d27f2bb7d9412356dab8cec7c00a0956b069e4987a8546a2c2c4b866d155e9fc4c27d223a225b78
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"damerau-levenshtein@npm:^1.0.8":
|
||||
version: 1.0.8
|
||||
resolution: "damerau-levenshtein@npm:1.0.8"
|
||||
@@ -3725,6 +3732,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fflate@npm:^0.8.2":
|
||||
version: 0.8.2
|
||||
resolution: "fflate@npm:0.8.2"
|
||||
checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fflate@npm:~0.6.9":
|
||||
version: 0.6.10
|
||||
resolution: "fflate@npm:0.6.10"
|
||||
@@ -4456,13 +4470,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"immediate@npm:~3.0.5":
|
||||
version: 3.0.6
|
||||
resolution: "immediate@npm:3.0.6"
|
||||
checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"import-fresh@npm:^3.0.0, import-fresh@npm:^3.2.1":
|
||||
version: 3.3.0
|
||||
resolution: "import-fresh@npm:3.3.0"
|
||||
@@ -4497,7 +4504,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:~2.0.3":
|
||||
"inherits@npm:2, inherits@npm:^2.0.3":
|
||||
version: 2.0.4
|
||||
resolution: "inherits@npm:2.0.4"
|
||||
checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
|
||||
@@ -4855,13 +4862,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isarray@npm:~1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "isarray@npm:1.0.0"
|
||||
checksum: 10c0/18b5be6669be53425f0b84098732670ed4e727e3af33bc7f948aac01782110eb9a18b3b329c5323bcdd3acdaae547ee077d3951317e7f133bff7105264b3003d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isexe@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "isexe@npm:2.0.0"
|
||||
@@ -5030,18 +5030,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jszip@npm:^3.10.1":
|
||||
version: 3.10.1
|
||||
resolution: "jszip@npm:3.10.1"
|
||||
dependencies:
|
||||
lie: "npm:~3.3.0"
|
||||
pako: "npm:~1.0.2"
|
||||
readable-stream: "npm:~2.3.6"
|
||||
setimmediate: "npm:^1.0.5"
|
||||
checksum: 10c0/58e01ec9c4960383fb8b38dd5f67b83ccc1ec215bf74c8a5b32f42b6e5fb79fada5176842a11409c4051b5b94275044851814a31076bf49e1be218d3ef57c863
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jwa@npm:^1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "jwa@npm:1.4.1"
|
||||
@@ -5137,15 +5125,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lie@npm:~3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "lie@npm:3.3.0"
|
||||
dependencies:
|
||||
immediate: "npm:~3.0.5"
|
||||
checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lil-gui@npm:~0.17.0":
|
||||
version: 0.17.0
|
||||
resolution: "lil-gui@npm:0.17.0"
|
||||
@@ -5181,6 +5160,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loadjs@npm:^4.2.0":
|
||||
version: 4.3.0
|
||||
resolution: "loadjs@npm:4.3.0"
|
||||
checksum: 10c0/8884520a7c5f3b0f6e4d3bc01d200c73b9c468986bea26acb54939d4a3f5da08f40f712812fadc1ff6030fca936e8c9eeb842aaafd287e32ca0ce6ae9f10e759
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"locate-path@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "locate-path@npm:6.0.0"
|
||||
@@ -6221,14 +6207,15 @@ __metadata:
|
||||
encoding: "npm:^0.1.13"
|
||||
eslint: "npm:8.38.0"
|
||||
eslint-config-next: "npm:^14.1.4"
|
||||
fflate: "npm:^0.8.2"
|
||||
googleapis: "npm:^118.0.0"
|
||||
input-otp: "npm:^1.2.3"
|
||||
jsonwebtoken: "npm:^9.0.0"
|
||||
jszip: "npm:^3.10.1"
|
||||
lucide-react: "npm:^0.363.0"
|
||||
next: "npm:^14.1.4"
|
||||
next-themes: "npm:^0.3.0"
|
||||
nextjs-toploader: "npm:^1.6.11"
|
||||
plyr-react: "npm:^5.3.0"
|
||||
postcss: "npm:^8.4.38"
|
||||
prettier: "npm:3.0.0"
|
||||
prettier-plugin-tailwindcss: "npm:0.5.12"
|
||||
@@ -6593,13 +6580,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pako@npm:~1.0.2":
|
||||
version: 1.0.11
|
||||
resolution: "pako@npm:1.0.11"
|
||||
checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"papaparse@npm:^5.4.1":
|
||||
version: 5.4.1
|
||||
resolution: "papaparse@npm:5.4.1"
|
||||
@@ -6750,6 +6730,37 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"plyr-react@npm:^5.3.0":
|
||||
version: 5.3.0
|
||||
resolution: "plyr-react@npm:5.3.0"
|
||||
dependencies:
|
||||
plyr: "npm:^3.7.7"
|
||||
react-aptor: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
plyr: ^3.7.7
|
||||
react: ">=16.8"
|
||||
peerDependenciesMeta:
|
||||
plyr:
|
||||
optional: false
|
||||
react:
|
||||
optional: true
|
||||
checksum: 10c0/b338c5f07277c124663aa4f820dffb0700a67ac6eab0015b77985bb70c308da96bdffff6d103033544de6516cade63027f4c077871a0fa0feba996dfd3b6f2c0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"plyr@npm:^3.7.7":
|
||||
version: 3.7.8
|
||||
resolution: "plyr@npm:3.7.8"
|
||||
dependencies:
|
||||
core-js: "npm:^3.26.1"
|
||||
custom-event-polyfill: "npm:^1.0.7"
|
||||
loadjs: "npm:^4.2.0"
|
||||
rangetouch: "npm:^2.0.1"
|
||||
url-polyfill: "npm:^1.1.12"
|
||||
checksum: 10c0/75c3e070f7829f76409e0d34784bf8070b827ad99c4713a36338af7ce8dff7cf38998403f34a4a0b4d6e99efd1856ee056443c7e838db1dbb98ed38828110a97
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"possible-typed-array-names@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "possible-typed-array-names@npm:1.0.0"
|
||||
@@ -6936,13 +6947,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"process-nextick-args@npm:~2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "process-nextick-args@npm:2.0.1"
|
||||
checksum: 10c0/bec089239487833d46b59d80327a1605e1c5287eaad770a291add7f45fda1bb5e28b38e0e061add0a1d0ee0984788ce74fa394d345eed1c420cacf392c554367
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"promise-retry@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "promise-retry@npm:2.0.1"
|
||||
@@ -7001,6 +7005,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rangetouch@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "rangetouch@npm:2.0.1"
|
||||
checksum: 10c0/5f7947d1c5e95f50630ed1e0cbaeb4c32a6be37b66d864a68f7e70a4e86f782eb869df9fa2357b981b1301d2158eff50002a199b0fbbbf6e1746bc9d213914d9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-aptor@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "react-aptor@npm:2.0.0"
|
||||
peerDependencies:
|
||||
react: ">=16.8"
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
checksum: 10c0/f2f00b494a2cb93b0ee73949d2031c2f5a8233d169c3d16792e7c6a5177a75310155e85282209a6f74883b3564d1f3a46d5d4d03ee1c16224fbdb5d5665504f8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-colorful@npm:^5.6.1":
|
||||
version: 5.6.1
|
||||
resolution: "react-colorful@npm:5.6.1"
|
||||
@@ -7269,21 +7292,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-stream@npm:~2.3.6":
|
||||
version: 2.3.8
|
||||
resolution: "readable-stream@npm:2.3.8"
|
||||
dependencies:
|
||||
core-util-is: "npm:~1.0.0"
|
||||
inherits: "npm:~2.0.3"
|
||||
isarray: "npm:~1.0.0"
|
||||
process-nextick-args: "npm:~2.0.0"
|
||||
safe-buffer: "npm:~5.1.1"
|
||||
string_decoder: "npm:~1.1.1"
|
||||
util-deprecate: "npm:~1.0.1"
|
||||
checksum: 10c0/7efdb01f3853bc35ac62ea25493567bf588773213f5f4a79f9c365e1ad13bab845ac0dae7bc946270dc40c3929483228415e92a3fc600cc7e4548992f41ee3fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readdirp@npm:~3.6.0":
|
||||
version: 3.6.0
|
||||
resolution: "readdirp@npm:3.6.0"
|
||||
@@ -7601,13 +7609,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1":
|
||||
version: 5.1.2
|
||||
resolution: "safe-buffer@npm:5.1.2"
|
||||
checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"safe-regex-test@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "safe-regex-test@npm:1.0.3"
|
||||
@@ -7688,13 +7689,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"setimmediate@npm:^1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "setimmediate@npm:1.0.5"
|
||||
checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shallowequal@npm:1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "shallowequal@npm:1.1.0"
|
||||
@@ -7936,15 +7930,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"string_decoder@npm:~1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "string_decoder@npm:1.1.1"
|
||||
dependencies:
|
||||
safe-buffer: "npm:~5.1.0"
|
||||
checksum: 10c0/b4f89f3a92fd101b5653ca3c99550e07bdf9e13b35037e9e2a1c7b47cec4e55e06ff3fc468e314a0b5e80bfbaf65c1ca5a84978764884ae9413bec1fc6ca924e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
|
||||
version: 6.0.1
|
||||
resolution: "strip-ansi@npm:6.0.1"
|
||||
@@ -8554,6 +8539,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"url-polyfill@npm:^1.1.12":
|
||||
version: 1.1.12
|
||||
resolution: "url-polyfill@npm:1.1.12"
|
||||
checksum: 10c0/69633c42e3182271d01d2f2f4acd889a9f54b88fd7b2f45fd84fed19eca7cfa98fb6bfbd43d9cd9fad222a11a2dffb562b8435cdaa67fca5e25e3da638741679
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"url-template@npm:^2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "url-template@npm:2.0.8"
|
||||
@@ -8601,7 +8593,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
|
||||
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "util-deprecate@npm:1.0.2"
|
||||
checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942
|
||||
|
||||
Reference in New Issue
Block a user