Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions apps/api/src/modules/v1/folders/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,22 @@ export const FolderSummary = t.Composite([
}),
]);

export const VideoSummary = t.Pick(_videoSelect, [
"id",
"title",
"folderId",
"visibility",
"pinned",
"duration",
"status",
"createdAt",
"updatedAt",
export const VideoSummary = t.Composite([
t.Pick(_videoSelect, [
"id",
"title",
"folderId",
"visibility",
"pinned",
"duration",
"status",
"createdAt",
"updatedAt",
]),
t.Object({
thumbnailUrl: t.Optional(t.Nullable(t.String())),
previewUrl: t.Optional(t.Nullable(t.String())),
}),
]);

export const BrowseQuery = t.Object({
Expand Down
65 changes: 41 additions & 24 deletions apps/api/src/modules/v1/folders/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { and, desc, eq, isNull, sql } from "@vidcastx/database";
import { db } from "@vidcastx/database/client";
import { folders } from "@vidcastx/database/schema/folder-schema";
import { videos } from "@vidcastx/database/schema/video-schema";
import { getDownloadUrl } from "@vidcastx/storage";

type Folder = typeof folders.$inferSelect;

Expand All @@ -22,6 +23,8 @@ interface VideoSummaryRow {
status: typeof videos.$inferSelect.status;
createdAt: Date;
updatedAt: Date;
thumbnailUrl: string | null;
previewUrl: string | null;
}

const folderSummaryCols = {
Expand All @@ -37,18 +40,6 @@ const folderSummaryCols = {
updatedAt: folders.updatedAt,
};

const videoSummaryCols = {
id: videos.id,
title: videos.title,
folderId: videos.folderId,
visibility: videos.visibility,
pinned: videos.pinned,
duration: videos.duration,
status: videos.status,
createdAt: videos.createdAt,
updatedAt: videos.updatedAt,
};

async function getDescendantIds(folderId: string, orgId: string): Promise<string[]> {
const res = await db.execute<{ id: string }>(
sql`
Expand Down Expand Up @@ -138,18 +129,44 @@ export const FolderService = {
},

async listChildVideos(orgId: string, parentId: string | null): Promise<VideoSummaryRow[]> {
const rows = await db
.select(videoSummaryCols)
.from(videos)
.where(
and(
eq(videos.orgId, orgId),
isNull(videos.deletedAt),
parentId === null ? isNull(videos.folderId) : eq(videos.folderId, parentId),
),
)
.orderBy(desc(videos.createdAt));
return rows;
const rows = await db.query.videos.findMany({
columns: {
id: true,
title: true,
folderId: true,
visibility: true,
pinned: true,
duration: true,
status: true,
createdAt: true,
updatedAt: true,
},
where: and(
eq(videos.orgId, orgId),
isNull(videos.deletedAt),
parentId === null ? isNull(videos.folderId) : eq(videos.folderId, parentId),
),
orderBy: [desc(videos.createdAt)],
with: {
assets: {
columns: { type: true, storageKey: true },
},
},
});

return Promise.all(
rows.map(async (v) => {
const assetMap = new Map(v.assets.map((a) => [a.type, a]));
const thumb = assetMap.get("thumbnail");
const preview = assetMap.get("preview_gif");
const [thumbnailUrl, previewUrl] = await Promise.all([
thumb ? getDownloadUrl(thumb.storageKey) : Promise.resolve(null),
preview ? getDownloadUrl(preview.storageKey) : Promise.resolve(null),
]);
const { assets: _assets, ...rest } = v;
return { ...rest, thumbnailUrl, previewUrl };
}),
);
},

async browse(orgId: string, parentId: string | null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ interface VideoThumbProps {
title: string;
status?: string;
duration?: string | null;
overlay?: React.ReactNode;
}

export function VideoThumb({ poster, preview, title, status, duration }: VideoThumbProps) {
export function VideoThumb({ poster, preview, title, status, duration, overlay }: VideoThumbProps) {
const [hover, setHover] = useState(false);
const [previewReady, setPreviewReady] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
Expand All @@ -33,7 +34,6 @@ export function VideoThumb({ poster, preview, title, status, duration }: VideoTh
}, [hover, preview]);

const handlePointerEnter = () => {
// Guard against coarse-pointer (touch) devices
if (globalThis.matchMedia("(hover: none)").matches) return;
setHover(true);
};
Expand Down Expand Up @@ -102,6 +102,8 @@ export function VideoThumb({ poster, preview, title, status, duration }: VideoTh
{status}
</span>
)}

{overlay}
</div>
);
}
3 changes: 2 additions & 1 deletion apps/app/src/features/folders/components/folder-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useToggleVideoPin } from "../api/use-toggle-video-pin";
import { useUpdateFolder } from "../api/use-update-folder";
import { CreateFolderDialog } from "./create-folder-dialog";
import { FolderBreadcrumb } from "./folder-breadcrumb";
import { FolderGridSkeleton } from "./folder-grid-skeleton";
import { MixedGrid } from "./mixed-grid";

interface FolderBrowserProps {
Expand Down Expand Up @@ -111,7 +112,7 @@ export function FolderBrowser({ parentId }: FolderBrowserProps) {
)}

{isLoading ? (
<div className="text-muted-foreground py-16 text-center text-sm">Loading…</div>
<FolderGridSkeleton />
) : (
<MixedGrid
folders={data?.folders ?? []}
Expand Down
17 changes: 17 additions & 0 deletions apps/app/src/features/folders/components/folder-grid-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Skeleton } from "@vidcastx/ui/components/skeleton";

const GRID_CLASSES = "grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5";

export function FolderGridSkeleton({ count = 10 }: { count?: number }) {
return (
<div className={GRID_CLASSES}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="space-y-3">
<Skeleton className="aspect-video w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
);
}
58 changes: 21 additions & 37 deletions apps/app/src/features/folders/components/video-card.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { memo, useCallback } from "react";
import { FileVideo, Globe, Lock, Pin, Play } from "lucide-react";
import { Globe, Lock, Pin } from "lucide-react";

import { VideoThumb } from "#app/components/video-thumb";

import type { VideoSummary, VideoVisibility } from "../types";
import { MediaCardShell } from "./media-card-shell";
Expand Down Expand Up @@ -36,51 +38,33 @@ function VisibilityIcon({ visibility }: { visibility: VideoVisibility }) {
return <Lock className="size-3" />;
}

function hashHue(id: string): number {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + (id.codePointAt(i) ?? 0)) >>> 0;
return h % 360;
}

function VideoCardImpl({ video, onOpen, onTogglePin }: VideoCardProps) {
const { id, title, duration, visibility, createdAt, pinned } = video;
const { id, title, duration, visibility, createdAt, pinned, status, thumbnailUrl, previewUrl } = video;
const formattedDuration = formatDuration(duration);
const hue = hashHue(id);

const gradient = {
backgroundImage: `linear-gradient(135deg, hsl(${hue} 65% 55%), hsl(${(hue + 40) % 360} 55% 35%))`,
};

const handleOpen = useCallback(() => onOpen?.(id), [onOpen, id]);
const handleTogglePin = useCallback(() => {
onTogglePin(video);
}, [onTogglePin, video]);

const thumb = (
<div className="bg-muted relative aspect-video w-full overflow-hidden" style={gradient}>
<div className="absolute inset-0 flex items-center justify-center">
<FileVideo className="size-10 text-white/30" />
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/20">
<Play
className="size-10 text-white opacity-0 drop-shadow-lg transition-opacity duration-200 group-hover:opacity-100"
fill="white"
/>
</div>
{pinned && (
<span
className="bg-background/80 absolute top-1.5 right-1.5 inline-flex size-5 items-center justify-center"
aria-label="Pinned"
>
<Pin className="size-3" />
</span>
)}
{formattedDuration && (
<span className="absolute right-1.5 bottom-1.5 bg-black/80 px-1.5 py-0.5 text-[11px] font-medium text-white">
{formattedDuration}
</span>
)}
</div>
<VideoThumb
poster={thumbnailUrl}
preview={previewUrl}
title={title}
status={status}
duration={formattedDuration}
overlay={
pinned ? (
<span
className="bg-background/80 absolute top-1.5 right-1.5 inline-flex size-5 items-center justify-center"
aria-label="Pinned"
>
<Pin className="size-3" />
</span>
) : undefined
}
/>
);

const titleNode = <h3 className="line-clamp-2 text-sm leading-snug font-semibold group-hover:underline">{title}</h3>;
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/features/videos/components/video-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
DropdownMenuTrigger,
} from "@vidcastx/ui/components/dropdown-menu";

import { VideoThumb } from "./video-thumb";
import { VideoThumb } from "#app/components/video-thumb";

interface VideoCardVideo {
id: string;
Expand Down
1 change: 0 additions & 1 deletion apps/app/src/features/videos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@ export { VideoList } from "./components/video-page";
export { VideoUploadForm } from "./components/video-upload-form";
export { VideosGrid } from "./components/videos-grid";
export { VideoCard } from "./components/video-card";
export { VideoThumb } from "./components/video-thumb";
export { useVideos } from "./api/use-videos";
export * from "./schemas";
Loading
Loading