Merge pull request #42 from mbahArip/v2

feat: Playlist panel for media files
This commit is contained in:
Arief Rachmawan
2025-03-06 10:36:06 +07:00
committed by GitHub
13 changed files with 631 additions and 23 deletions
+5 -1
View File
@@ -116,7 +116,11 @@ For now, I don't have any plan to implement this, because I think it's not neces
While I think it's important, I don't have any plan to implement this for now.
### ~Encryption cause error~
### TS video file doesn't recognized
Somehow google return the mime type as code instead of video. I don't know how to fix this, so for now you can't preview the TS video file.
### ~~Encryption cause error~~
~~It seems the configurator are generating wrong encrypted folder ID, so it will cause error when you try to access the folder.~~
Should be fixed on v2.0.4, waiting for feedback / confirmation
BIN
View File
Binary file not shown.
+7 -6
View File
@@ -15,14 +15,14 @@
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@oslojs/encoding": "^1.1.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-context-menu": "^2.2.4",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-label": "^2.1.1",
@@ -33,25 +33,26 @@
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-virtual": "^3.11.2",
"@vidstack/react": "^1.12.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
"embla-carousel-react": "^8.5.1",
"embla-carousel-wheel-gestures": "^8.0.1",
"fflate": "^0.8.2",
"framer-motion": "^11.13.3",
"googleapis": "^144.0.0",
"lucide-react": "^0.468.0",
"lucide-react": "^0.477.0",
"next": "^15.1.6",
"next-themes": "^0.4.4",
"nextjs-toploader": "^1.6.11",
+89
View File
@@ -9,6 +9,8 @@ import { Schema_File, Schema_File_Shortcut } from "~/types/schema";
import config from "config";
import { ValidatePaths } from "./paths";
/**
* List files in a folder
* @param {object} options
@@ -372,3 +374,90 @@ export async function GetContent(id: string): Promise<ActionResponseSchema<strin
data: data as string,
};
}
/**
* Get siblings media files from the same parent folder
* @param paths - Paths to check
*/
export async function GetSiblingsMedia(paths: string[]): Promise<ActionResponseSchema<z.infer<typeof Schema_File>[]>> {
const pathIds = await ValidatePaths(paths);
if (!pathIds.success)
return {
success: false,
message: "Failed to validate paths",
error: pathIds.error,
};
const folderPaths = pathIds.data.filter((item) => item.mimeType === "application/vnd.google-apps.folder");
const parentId = folderPaths[folderPaths.length - 1]?.id ?? config.apiConfig.rootFolder;
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
const decryptedParentId = await encryptionService.decrypt(parentId);
const decryptedSharedDrive = isSharedDrive
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
: undefined;
const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and ");
const filterQuery: string = [
...config.apiConfig.defaultQuery,
`'${decryptedParentId}' in parents`,
filterName,
"(mimeType contains 'video' or mimeType contains 'audio')",
].join(" and ");
const { data } = await gdrive.files.list({
q: filterQuery,
fields: `files(${config.apiConfig.defaultField})`,
orderBy: config.apiConfig.defaultOrder,
pageSize: 100,
...(decryptedSharedDrive && {
supportsAllDrives: true,
includeItemsFromAllDrives: true,
driveId: decryptedSharedDrive,
corpora: "drive",
}),
});
if (!data.files?.length) return { success: true, message: "No siblings media found", data: [] };
const files: z.infer<typeof Schema_File>[] = [];
for (const file of data.files) {
files.push({
encryptedId: await encryptionService.encrypt(file.id!),
encryptedWebContentLink: file.webContentLink ? await encryptionService.encrypt(file.webContentLink) : undefined,
name: file.name!,
mimeType: file.mimeType!,
trashed: file.trashed ?? false,
modifiedTime: new Date(file.modifiedTime!).toLocaleDateString(),
fileExtension: file.fileExtension ?? undefined,
size: file.size ? Number(file.size) : undefined,
thumbnailLink: file.thumbnailLink ?? undefined,
imageMediaMetadata: file.imageMediaMetadata
? {
width: Number(file.imageMediaMetadata.width),
height: Number(file.imageMediaMetadata.height),
rotation: Number(file.imageMediaMetadata.rotation ?? 0),
}
: undefined,
videoMediaMetadata: file.videoMediaMetadata
? {
durationMillis: Number(file.videoMediaMetadata.durationMillis),
height: Number(file.videoMediaMetadata.height),
width: Number(file.videoMediaMetadata.width),
}
: undefined,
});
}
const parsed = Schema_File.array().safeParse(files);
if (!parsed.success)
return {
success: false,
message: "Failed to parse siblings media",
error: parsed.error.message,
};
return {
success: true,
message: "Siblings media fetched",
data: parsed.data,
};
}
+14 -1
View File
@@ -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>
);
+111
View File
@@ -0,0 +1,111 @@
import { type NextRequest, NextResponse } from "next/server";
import stream from "stream";
import { promisify } from "util";
import { gdrive } from "~/lib/utils.server";
const pipeline = promisify(stream.pipeline);
const fileId = "1KvMU9sy8fQKKmVKU5xf_u3Y2JZefe0kt";
export async function GET(request: NextRequest) {
try {
const start = Date.now();
console.log("Fetching metadata", Date.now() - start);
const meta = await gdrive.files.get({
fileId,
fields: "size,mimeType,name",
supportsAllDrives: true,
});
const { data: metadata } = meta;
console.log("Fetching file", Date.now() - start);
// const data = await gdrive.files.get(
// {
// fileId: fileId,
// alt: "media",
// acknowledgeAbuse: true,
// },
// {
// responseType: "stream",
// },
// );
console.log("Setting headers", Date.now() - start);
const headers = new Headers();
headers.set("Content-Type", metadata.mimeType ?? "application/octet-stream");
if (metadata.size) headers.set("Content-Length", metadata.size.toString());
headers.set("Content-Disposition", `attachment; filename="${metadata.name}"`);
// console.log("Streaming file", Date.now() - start);
// const contentStream = data.data;
// const webStream = new ReadableStream({
// async start(controller) {
// contentStream.on("data", (chunk) => {
// controller.enqueue(chunk);
// });
// contentStream.on("end", () => {
// controller.close();
// });
// contentStream.on("error", (err) => {
// console.error(err);
// controller.error(err);
// });
// },
// cancel() {
// contentStream.destroy();
// },
// });
// console.log("Returning response", Date.now() - start);
return new Response(
new ReadableStream({
async start(controller) {
try {
gdrive.files.get(
{
fileId: fileId,
alt: "media",
acknowledgeAbuse: true,
supportsAllDrives: true,
},
{
responseType: "stream",
},
(err, res) => {
if (err) throw err;
res?.data
.on("data", (chunk) => {
controller.enqueue(chunk);
})
.on("end", () => {
controller.close();
})
.on("error", (err) => {
console.error(err);
controller.error(err);
});
},
);
} catch (error) {
controller.error(error);
console.error(error);
}
},
}),
{
headers,
},
);
} catch (error) {
const e = error as Error;
console.error(e);
return NextResponse.json(
{
error: e.message,
},
{
status: 500,
},
);
}
}
+2 -3
View File
@@ -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>
);
}
+16 -9
View File
@@ -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
+57
View File
@@ -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 }
+19
View File
@@ -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
}
+9
View File
@@ -177,3 +177,12 @@ body {
}
@layer base {
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+6 -3
View File
@@ -1,4 +1,7 @@
import twTypography from "@tailwindcss/typography";
import twVidstack from "@vidstack/react/tailwind.cjs";
import type { Config } from "tailwindcss";
import twAnimate from "tailwindcss-animate";
import tw from "tailwindcss/defaultTheme";
export default {
@@ -103,9 +106,9 @@ export default {
},
},
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
require("@vidstack/react/tailwind.cjs")({
twAnimate,
twTypography,
twVidstack({
selector: ".media-player",
prefix: "media",
}),