website library filter links, added 3d, and video thumbnails to prope… (#1174)

…rly show
This commit is contained in:
BFlat
2026-03-19 13:21:19 +07:00
committed by GitHub
parent 1178bb7ebf
commit 72bf6738e1
4 changed files with 87 additions and 17 deletions
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 249.21 224.64">
<g id="Layer_1-2" data-name="Layer 1">
<path d="M6.64.07c2.98-.4,5.65.92,7.26,3.41l66.32,109.22c4.55,5.38,10.84,10.03,18,11.02,8.98,1.24,17.64-3.5,25.87-6.45,38.94-13.94,78-27.61,117.04-41.24,6.08-.57,9.93,4.49,7.2,10.08l-76.03,135.25c-2.61,3.2-7.54,4.48-11.03,1.92-3.25-2.39-7.45-7.65-10.43-10.68-9.93-10.07-19.84-20.11-29.75-30.2-11.48-11.69-20.14-24.31-39.09-20.64-14.85,2.87-21.88,16.72-32.48,25.55-3.85,3.21-9.25,3.5-12.46-.75-1.69-2.24-2.1-6.25-2.7-9.05C22.12,121.2,12.09,64.37,0,8.01-.16,4.05,2.67.61,6.64.07Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 680 B

@@ -44,6 +44,7 @@ export function App() {
<Route path="/news" element={<NewsIndex basePath="/news" />} />
<Route path="/news/:slug" element={<NewsPost basePath="/news" />} />
<Route path="/library" element={<Library />} />
<Route path="/library/:filter" element={<Library />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
@@ -38,6 +38,7 @@ import {
getProviderIconByName,
} from "@storyteller/model-list";
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
import { Viewer3D } from "@storyteller/ui-viewer-3d";
import dayjs from "dayjs";
import useEmblaCarousel from "embla-carousel-react";
import type { EmblaOptionsType } from "embla-carousel";
@@ -84,10 +85,14 @@ const COPY_TIMEOUT = 1500;
const SHARE_URL_BASE = "https://getartcraft.com/media/";
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
const MODEL_3D_EXTENSIONS = [".glb", ".gltf", ".fbx", ".spz"];
const isVideoUrl = (url: string): boolean =>
VIDEO_EXTENSIONS.some((ext) => url.toLowerCase().includes(ext));
const is3DModelUrl = (url: string): boolean =>
MODEL_3D_EXTENSIONS.some((ext) => url.toLowerCase().includes(ext));
// ── Helpers ────────────────────────────────────────────────────────────────
const useCopyFeedback = () => {
@@ -362,6 +367,7 @@ export function Lightbox({
}, [batchTokens, propMediaTokens, selectedIndex, mediaToken]);
const isVideo = selectedImageUrl ? isVideoUrl(selectedImageUrl) : (propMediaClass === "video");
const is3D = selectedImageUrl ? is3DModelUrl(selectedImageUrl) : (propMediaClass === "dimensional");
// Keyboard navigation
useEffect(() => {
@@ -423,6 +429,13 @@ export function Lightbox({
<div className="flex h-full w-full items-center justify-center">
<span className="text-base-fg/60">Media not available</span>
</div>
) : is3D ? (
<Viewer3D
key={selectedImageUrl}
modelUrl={addCorsParam(selectedImageUrl) || selectedImageUrl}
isActive
className="h-full w-full"
/>
) : isVideo ? (
<video
key={selectedImageUrl}
@@ -507,7 +520,7 @@ export function Lightbox({
</div>
)}
{!mediaLoaded && selectedImageUrl && !isVideo && (
{!mediaLoaded && selectedImageUrl && !isVideo && !is3D && (
<div className="absolute inset-0 flex items-center justify-center bg-ui-panel">
<LoadingSpinner className="h-12 w-12 text-base-fg" />
</div>
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useParams, useNavigate } from "react-router-dom";
import { Button } from "@storyteller/ui-button";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import {
@@ -10,12 +10,14 @@ import {
} from "@storyteller/api";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowsRotate,
faBorderAll,
faCube,
faImage,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import {
getThumbnailUrl,
getMediaThumbnail,
THUMBNAIL_SIZES,
PLACEHOLDER_IMAGES,
} from "@storyteller/common";
@@ -36,19 +38,28 @@ interface GalleryItem {
const PAGE_SIZE = 60;
const FILTERS = [
{ id: "all", label: "All", icon: faBorderAll },
{ id: "image", label: "Images", icon: faImage },
{ id: "video", label: "Videos", icon: faVideo },
{ id: "all", label: "All", icon: faBorderAll, route: "/library" },
{ id: "image", label: "Images", icon: faImage, route: "/library/images" },
{ id: "video", label: "Videos", icon: faVideo, route: "/library/videos" },
{ id: "meshes", label: "Meshes", icon: faCube, route: "/library/meshes" },
];
const ROUTE_TO_FILTER: Record<string, string> = {
images: "image",
videos: "video",
meshes: "meshes",
};
const getFilterMediaClass = (filter: string): FilterMediaClasses[] | undefined => {
switch (filter) {
case "image":
return [FilterMediaClasses.IMAGE];
case "video":
return [FilterMediaClasses.VIDEO];
case "meshes":
return [FilterMediaClasses.DIMENSIONAL];
default:
return [FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO];
return [FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO, FilterMediaClasses.DIMENSIONAL];
}
};
@@ -68,6 +79,8 @@ const getLabel = (item: any) => {
return "Image Generation";
case "video":
return "Video Generation";
case "dimensional":
return "3D Mesh";
default:
return "Generation";
}
@@ -76,6 +89,10 @@ const getLabel = (item: any) => {
// ── Component ──────────────────────────────────────────────────────────────
export default function Library() {
const { filter: filterParam } = useParams<{ filter?: string }>();
const navigate = useNavigate();
const activeFilter = filterParam ? (ROUTE_TO_FILTER[filterParam] ?? "all") : "all";
const [username, setUsername] = useState<string | null>(null);
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
const [allItems, setAllItems] = useState<GalleryItem[]>([]);
@@ -83,7 +100,6 @@ export default function Library() {
const [initialLoading, setInitialLoading] = useState(true);
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [activeFilter, setActiveFilter] = useState("all");
const isLoadingRef = useRef(false);
// Lightbox state
@@ -113,13 +129,14 @@ export default function Library() {
// Map API item to GalleryItem
const mapApiItem = useCallback((item: any): GalleryItem => {
const isVideo = item.media_class === "video";
// Video thumbnails are broken — use null so we show a video icon instead
const thumbnail = isVideo
const isDimensional = item.media_class === "dimensional";
// Meshes don't have image thumbnails — show cube icon instead
// For videos, getMediaThumbnail tries animated preview first, then template, then cdn_url
const thumbnail = isDimensional
? null
: getThumbnailUrl(item.media_links?.maybe_thumbnail_template, {
width: THUMBNAIL_SIZES.MEDIUM,
}) || item.media_links?.cdn_url || null;
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.MEDIUM,
});
return {
id: item.token,
@@ -297,14 +314,33 @@ export default function Library() {
{/* Header — sticky below navbar */}
<div className="sticky top-16 z-10 -mx-4 md:-mx-8 lg:-mx-12 px-4 md:px-8 lg:px-12 pb-4 pt-4 bg-[#101014]">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">My Library</h1>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-white">My Library</h1>
<button
onClick={() => {
setAllItems([]);
setPageIndex(0);
setHasMore(true);
setInitialLoading(true);
isLoadingRef.current = false;
loadItems(true);
}}
className="h-8 w-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="Refresh library"
>
<FontAwesomeIcon
icon={faArrowsRotate}
className={`text-sm ${initialLoading ? "animate-spin" : ""}`}
/>
</button>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-lg p-1">
{FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => setActiveFilter(filter.id)}
onClick={() => navigate(filter.route)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${activeFilter === filter.id
? "bg-ui-controls text-white"
: "text-white/60 hover:text-white"
@@ -404,7 +440,11 @@ export default function Library() {
<div className="flex h-full w-full items-center justify-center">
<FontAwesomeIcon
icon={
item.mediaClass === "video" ? faVideo : faImage
item.mediaClass === "video"
? faVideo
: item.mediaClass === "dimensional"
? faCube
: faImage
}
className="text-xl text-white/20"
/>
@@ -420,6 +460,16 @@ export default function Library() {
Video
</div>
)}
{/* Mesh badge */}
{item.mediaClass === "dimensional" && (
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
<FontAwesomeIcon
icon={faCube}
className="text-[8px]"
/>
3D
</div>
)}
</button>
))}
</div>