mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
website library filter links, added 3d, and video thumbnails to prope… (#1174)
…rly show
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user