mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
feat/playlist: Add UI for playlist
This commit is contained in:
@@ -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 <ErrorComponent error={new Error(token.error)} />;
|
||||
|
||||
// Check if file is media (video / audio)
|
||||
let playlistFiles: z.infer<typeof Schema_File>[] = [];
|
||||
if (file.data.mimeType.includes("video") || file.data.mimeType.includes("audio")) {
|
||||
const sib = await GetSiblingsMedia(rest);
|
||||
if (!sib.success) return <ErrorComponent error={new Error(sib.error)} />;
|
||||
playlistFiles = sib.data;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PreviewLayout
|
||||
@@ -141,6 +152,8 @@ export default async function RestPage({ params }: Props) {
|
||||
: "unknown"
|
||||
}
|
||||
token={token.data}
|
||||
playlist={playlistFiles}
|
||||
paths={rest}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -39,11 +39,11 @@ type Props = {
|
||||
type: "video" | "audio";
|
||||
};
|
||||
export default function PreviewMedia({ file, type }: Props) {
|
||||
const loading = useLoading();
|
||||
const player = useRef<MediaPlayerInstance>(null);
|
||||
const [canPlay, setCanPlay] = useState<boolean>(false);
|
||||
|
||||
const [isLoop, setIsLoop] = useState<boolean>(false);
|
||||
const loading = useLoading();
|
||||
|
||||
const smallAudioLayoutQuery = useCallback<MediaPlayerQuery>(({ width }) => {
|
||||
return width < 576;
|
||||
@@ -53,7 +53,7 @@ export default function PreviewMedia({ file, type }: Props) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full items-center justify-center py-3'>
|
||||
<div className='flex h-full w-full items-center justify-center gap-2 py-3 pb-0'>
|
||||
{loading ? (
|
||||
<PageLoader message='Loading media...' />
|
||||
) : (
|
||||
@@ -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"}
|
||||
|
||||
@@ -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<typeof Schema_File>;
|
||||
playlist: z.infer<typeof Schema_File>[];
|
||||
};
|
||||
|
||||
export default function MediaPlaylistLayout({
|
||||
type = "separated",
|
||||
paths,
|
||||
currentItem,
|
||||
playlist,
|
||||
}: MediaPlaylistLayoutProps) {
|
||||
const loading = useLoading();
|
||||
const { isDesktop } = useResponsive();
|
||||
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [totalIndex, setTotalIndex] = useState(0);
|
||||
|
||||
const uniquePlaylist = useMemo<z.infer<typeof Schema_File>[]>(
|
||||
() => 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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Playlist</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className='grid w-full place-items-center'>
|
||||
<div className='mx-auto inline-flex w-full items-center gap-4 tablet:w-[calc(100%-4rem)]'>
|
||||
<Skeleton className='aspect-video w-full basis-1/2 tablet:basis-1/3' />
|
||||
<Skeleton className='aspect-video w-full basis-1/2 tablet:basis-1/3' />
|
||||
{isDesktop && <Skeleton className='aspect-video w-full basis-1/2 tablet:basis-1/3' />}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Carousel
|
||||
className='mx-auto h-full w-full'
|
||||
opts={{
|
||||
loop: true,
|
||||
startIndex: (playlist.findIndex((item) => item.encryptedId === currentItem.encryptedId) ?? 0) + 1,
|
||||
containScroll: "keepSnaps",
|
||||
}}
|
||||
>
|
||||
<CarouselContent className='h-full pb-2'>
|
||||
{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 ? <Fragment>{children}</Fragment> : <Link href={itemUrl}>{children}</Link>;
|
||||
|
||||
return (
|
||||
<CarouselItem
|
||||
key={item.encryptedId}
|
||||
className='basis-1/2 tablet:basis-1/3'
|
||||
>
|
||||
<Wrapper>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg border transition",
|
||||
isCurrent ? "border-primary opacity-100" : "opacity-70 hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
{isCurrent && <Badge className='absolute right-2 top-2'>Playing</Badge>}
|
||||
{item.mimeType.includes("video") ? (
|
||||
<img
|
||||
src={`/api/thumb/${item.encryptedId}`}
|
||||
alt={`Preview of ${item.name}`}
|
||||
className='aspect-video w-full rounded-lg bg-black object-contain'
|
||||
/>
|
||||
) : (
|
||||
<div className='grid aspect-video w-full place-items-center rounded-lg bg-black'>
|
||||
<Icon
|
||||
name={getPreviewIcon(item.fileExtension ?? "", item.mimeType)}
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='w-full p-2'>
|
||||
<p
|
||||
className='line-clamp-1 break-all'
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className='-left-4 hidden tablet:flex' />
|
||||
<CarouselNext className='-right-4 hidden tablet:flex' />
|
||||
</Carousel>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
if (type === "inside")
|
||||
return (
|
||||
<Accordion
|
||||
type='single'
|
||||
collapsible
|
||||
className={"w-full"}
|
||||
defaultValue={"playlist"}
|
||||
>
|
||||
<AccordionItem
|
||||
value='playlist'
|
||||
className={"w-full"}
|
||||
>
|
||||
<AccordionTrigger>
|
||||
<div className={"inline-flex w-full items-center gap-2 "}>
|
||||
<Icon name='List' />
|
||||
Playlist
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{loading ? (
|
||||
<div className={"grid w-full grid-cols-3 gap-4 rounded-lg tablet:grid-cols-4 tablet:items-center"}>
|
||||
<Skeleton className='aspect-video w-full basis-1/3 tablet:basis-1/4' />
|
||||
<Skeleton className='aspect-video w-full basis-1/3 tablet:basis-1/4' />
|
||||
<Skeleton className='aspect-video w-full basis-1/3 tablet:basis-1/4' />
|
||||
<Skeleton className='hidden aspect-video w-full basis-1/3 tablet:block tablet:basis-1/4' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid w-full grid-cols-3 gap-4 rounded-lg pb-8 tablet:grid-cols-4 tablet:items-center'>
|
||||
{/* Current media */}
|
||||
<Item
|
||||
item={currentItem}
|
||||
isCurrent
|
||||
className={"shrink-0 grow-0 basis-1/3 tablet:basis-1/4"}
|
||||
/>
|
||||
|
||||
{/* Playlist */}
|
||||
<Carousel
|
||||
className={"relative col-span-2 tablet:col-span-3"}
|
||||
// plugins={[WheelGesturesPlugin({ forceWheelAxis: "y" })]}
|
||||
setApi={setApi}
|
||||
opts={{
|
||||
dragFree: true,
|
||||
}}
|
||||
>
|
||||
<CarouselContent className={"py-1"}>
|
||||
{uniquePlaylist.map((item) => {
|
||||
const itemUrl = paths.slice(0, paths.length - 1).join("/") + `/${encodeURIComponent(item.name)}`;
|
||||
|
||||
return (
|
||||
<CarouselItem
|
||||
key={item.encryptedId}
|
||||
className='basis-1/2 tablet:basis-1/3'
|
||||
>
|
||||
<Link href={itemUrl}>
|
||||
<Item
|
||||
item={item}
|
||||
isCurrent={false}
|
||||
/>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
</CarouselContent>
|
||||
<div className={"absolute -bottom-8 right-0 inline-flex w-full items-center justify-between"}>
|
||||
<div className={"inline-flex items-center gap-1"}>
|
||||
{Array.from({
|
||||
length: totalIndex,
|
||||
}).map((_, index) => (
|
||||
<div
|
||||
key={`indicator-${index}`}
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
index === currentIndex ? "bg-primary" : "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={"relative inline-flex items-center gap-1"}>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size='icon'
|
||||
className={"size-6 rounded-full p-0"}
|
||||
disabled={!api?.canScrollPrev()}
|
||||
onClick={() => api?.scrollPrev()}
|
||||
>
|
||||
<Icon name='ChevronLeft' />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size='icon'
|
||||
className={"size-6 rounded-full p-0"}
|
||||
disabled={!api?.canScrollNext()}
|
||||
onClick={() => api?.scrollNext()}
|
||||
>
|
||||
<Icon name='ChevronRight' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Carousel>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
type ItemProps = {
|
||||
item: z.infer<typeof Schema_File>;
|
||||
isCurrent: boolean;
|
||||
className?: string;
|
||||
};
|
||||
function Item({ item, isCurrent, className }: ItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg border transition",
|
||||
isCurrent ? "border-primary opacity-100" : "opacity-70 hover:opacity-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isCurrent && <Badge className='absolute right-2 top-2'>Playing</Badge>}
|
||||
{item.mimeType.includes("video") ? (
|
||||
<img
|
||||
src={`/api/thumb/${item.encryptedId}`}
|
||||
alt={`Preview of ${item.name}`}
|
||||
className='aspect-video w-full rounded-lg bg-black object-contain'
|
||||
/>
|
||||
) : (
|
||||
<div className='grid aspect-video w-full place-items-center rounded-lg bg-black'>
|
||||
<Icon
|
||||
name={getPreviewIcon(item.fileExtension ?? "", item.mimeType)}
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='w-full p-2'>
|
||||
<p
|
||||
className='line-clamp-1 break-all'
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof Schema_File>;
|
||||
fileType: "unknown" | ReturnType<typeof getFileType>;
|
||||
token: string;
|
||||
playlist: z.infer<typeof Schema_File>[];
|
||||
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 <PreviewImage file={data} />;
|
||||
case "video":
|
||||
return (
|
||||
<PreviewMedia
|
||||
file={data}
|
||||
type='video'
|
||||
/>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<PreviewMedia
|
||||
file={data}
|
||||
type='audio'
|
||||
type={fileType}
|
||||
/>
|
||||
);
|
||||
case "code":
|
||||
@@ -135,6 +133,15 @@ export default function PreviewLayout({ data, fileType, token }: Props) {
|
||||
<>{PreviewComponent}</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<MediaPlaylistLayout
|
||||
type={"inside"}
|
||||
paths={paths}
|
||||
currentItem={data}
|
||||
playlist={playlist}
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<PreviewInformation
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(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
|
||||
}
|
||||
@@ -177,3 +177,12 @@ body {
|
||||
}
|
||||
@layer base {
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user