diff --git a/src/app/[...rest]/page.tsx b/src/app/[...rest]/page.tsx index 5b2f3c9..07f7571 100644 --- a/src/app/[...rest]/page.tsx +++ b/src/app/[...rest]/page.tsx @@ -1,5 +1,6 @@ import { type Metadata, type ResolvedMetadata } from "next"; import { notFound } from "next/navigation"; +import { type z } from "zod"; import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer"; import { ErrorComponent, Password } from "~/components/layout"; @@ -9,7 +10,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { getFileType } from "~/lib/previewHelper"; import { formatPathToBreadcrumb } from "~/lib/utils"; -import { GetBanner, GetFile, GetReadme, ListFiles } from "~/actions/files"; +import { type Schema_File } from "~/types/schema"; + +import { GetBanner, GetFile, GetReadme, GetSiblingsMedia, ListFiles } from "~/actions/files"; import { CheckPagePassword } from "~/actions/password"; import { ValidatePaths } from "~/actions/paths"; import { CreateFileToken } from "~/actions/token"; @@ -131,6 +134,14 @@ export default async function RestPage({ params }: Props) { const token = await CreateFileToken(file.data); if (!token.success) return ; + // Check if file is media (video / audio) + let playlistFiles: z.infer[] = []; + if (file.data.mimeType.includes("video") || file.data.mimeType.includes("audio")) { + const sib = await GetSiblingsMedia(rest); + if (!sib.success) return ; + playlistFiles = sib.data; + } + return ( ); diff --git a/src/components/preview/Media.tsx b/src/components/preview/Media.tsx index 6e9208c..a9a1213 100644 --- a/src/components/preview/Media.tsx +++ b/src/components/preview/Media.tsx @@ -39,11 +39,11 @@ type Props = { type: "video" | "audio"; }; export default function PreviewMedia({ file, type }: Props) { - const loading = useLoading(); const player = useRef(null); const [canPlay, setCanPlay] = useState(false); const [isLoop, setIsLoop] = useState(false); + const loading = useLoading(); const smallAudioLayoutQuery = useCallback(({ width }) => { return width < 576; @@ -53,7 +53,7 @@ export default function PreviewMedia({ file, type }: Props) { }, []); return ( - + {loading ? ( ) : ( @@ -65,7 +65,6 @@ export default function PreviewMedia({ file, type }: Props) { type: file.mimeType as AudioMimeType | VideoMimeType, }} loop={isLoop} - autoPlay playsInline crossOrigin viewType={type === "audio" ? "audio" : "video"} diff --git a/src/components/preview/MediaPlaylistLayout.tsx b/src/components/preview/MediaPlaylistLayout.tsx new file mode 100644 index 0000000..946b1eb --- /dev/null +++ b/src/components/preview/MediaPlaylistLayout.tsx @@ -0,0 +1,296 @@ +"use client"; + +import Link from "next/link"; +import { Fragment, useEffect, useMemo, useState } from "react"; +import { type z } from "zod"; + +import { useResponsive } from "~/context/responsiveContext"; +import useLoading from "~/hooks/useLoading"; +import { getPreviewIcon } from "~/lib/previewHelper"; +import { cn } from "~/lib/utils"; + +import { type Schema_File } from "~/types/schema"; + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../ui/accordion"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "../ui/carousel"; +import Icon from "../ui/icon"; +import { Skeleton } from "../ui/skeleton"; + +type MediaPlaylistLayoutProps = { + type?: "separated" | "inside"; + paths: string[]; + currentItem: z.infer; + playlist: z.infer[]; +}; + +export default function MediaPlaylistLayout({ + type = "separated", + paths, + currentItem, + playlist, +}: MediaPlaylistLayoutProps) { + const loading = useLoading(); + const { isDesktop } = useResponsive(); + + const [api, setApi] = useState(); + const [currentIndex, setCurrentIndex] = useState(0); + const [totalIndex, setTotalIndex] = useState(0); + + const uniquePlaylist = useMemo[]>( + () => playlist.filter((item) => `${item.name}#${item.size}` !== `${currentItem.name}#${currentItem.size}`), + [currentItem, playlist], + ); + + useEffect(() => { + if (!api) return; + + setTotalIndex(api.scrollSnapList().length); + setCurrentIndex(api.selectedScrollSnap()); + + api.on("select", () => { + setCurrentIndex(api.selectedScrollSnap()); + }); + }, [api]); + + // <= 1, because it always include current media + if (!uniquePlaylist.length) return null; + if (type === "separated") + return ( + + + Playlist + + + {loading ? ( + + + + + {isDesktop && } + + + ) : ( + item.encryptedId === currentItem.encryptedId) ?? 0) + 1, + containScroll: "keepSnaps", + }} + > + + {playlist.map((item) => { + const isCurrent = `${item.name}#${item.size}` === `${currentItem.name}#${currentItem.size}`; + const itemUrl = paths.slice(0, paths.length - 1).join("/") + `/${encodeURIComponent(item.name)}`; + const Wrapper = ({ children }: { children: React.ReactNode }) => + isCurrent ? {children} : {children}; + + return ( + + + + {isCurrent && Playing} + {item.mimeType.includes("video") ? ( + + ) : ( + + + + )} + + + {item.name} + + + + + + ); + })} + + + + + )} + + + ); + if (type === "inside") + return ( + + + + + + Playlist + + + + {loading ? ( + + + + + + + ) : ( + + {/* Current media */} + + + {/* Playlist */} + + + {uniquePlaylist.map((item) => { + const itemUrl = paths.slice(0, paths.length - 1).join("/") + `/${encodeURIComponent(item.name)}`; + + return ( + + + + + + ); + })} + + + + {Array.from({ + length: totalIndex, + }).map((_, index) => ( + + ))} + + + + api?.scrollPrev()} + > + + + + api?.scrollNext()} + > + + + + + + + )} + + + + ); + + return <>>; +} + +type ItemProps = { + item: z.infer; + isCurrent: boolean; + className?: string; +}; +function Item({ item, isCurrent, className }: ItemProps) { + return ( + + {isCurrent && Playing} + {item.mimeType.includes("video") ? ( + + ) : ( + + + + )} + + + {item.name} + + + + ); +} diff --git a/src/components/preview/PreviewLayout.tsx b/src/components/preview/PreviewLayout.tsx index 04c98a2..9edae28 100644 --- a/src/components/preview/PreviewLayout.tsx +++ b/src/components/preview/PreviewLayout.tsx @@ -14,7 +14,7 @@ import { PreviewUnknown, } from "~/components/preview"; import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"; import { type getFileType } from "~/lib/previewHelper"; import { cn } from "~/lib/utils"; @@ -23,29 +23,27 @@ import { type Schema_File } from "~/types/schema"; import config from "config"; +import MediaPlaylistLayout from "./MediaPlaylistLayout"; + type Props = { data: z.infer; fileType: "unknown" | ReturnType; token: string; + playlist: z.infer[]; + paths: string[]; }; -export default function PreviewLayout({ data, fileType, token }: Props) { +export default function PreviewLayout({ data, paths, fileType, token, playlist }: Props) { const [view, setView] = useState<"markdown" | "raw">("markdown"); const PreviewComponent = useMemo(() => { switch (fileType) { case "image": return ; case "video": - return ( - - ); case "audio": return ( ); case "code": @@ -135,6 +133,15 @@ export default function PreviewLayout({ data, fileType, token }: Props) { <>{PreviewComponent}> )} + + + + , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/src/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/src/styles/globals.css b/src/styles/globals.css index fc30c8b..dabd7a7 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -177,3 +177,12 @@ body { } @layer base { } + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +}
+ {item.name} +