Replit prompt — Add “My Uploads” on Creator Dashboard

Goal: On /creator, show the creator’s assets (Photos, Mockups, Icons) with thumbnail, filename, type, created date, and actions:

Copy link (preview URL for marketing / listing)

Delete (soft delete via API)

Filter by type, search by name, and pagination.

Uses existing /api/creator/assets/list and /api/creator/assets/:id endpoints we added earlier; access permitted for roles: owner, manager, creator.

A) Frontend

1) Create component: client/src/pages/creator/components/MyUploadsGrid.tsx

import React, { useEffect, useMemo, useState } from "react";
import { Copy, Trash2, Image as ImageIcon, PackageSearch } from "lucide-react";

type CreatorAsset = {
  id: string;
  name: string;
  kind: "photo" | "mockup" | "icon";
  previewUrl?: string;   // watermarked/preview image (photos/mockups) or icon preview
  downloadUrl?: string;  // original (served by entitlement)
  createdAt: number;
};

type ListResp = {
  items: CreatorAsset[];
  nextCursor?: string | null;
};

function formatDate(ts: number) {
  try { return new Date(ts).toLocaleDateString(); } catch { return ""; }
}

export default function MyUploadsGrid() {
  const [items, setItems] = useState<CreatorAsset[]>([]);
  const [kind, setKind] = useState<"" | "photo" | "mockup" | "icon">("");
  const [q, setQ] = useState("");
  const [cursor, setCursor] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(false);
  const [loading, setLoading] = useState(true);

  async function load(reset = false) {
    setLoading(true);
    try {
      const params = new URLSearchParams();
      if (kind) params.set("kind", kind);
      if (q.trim()) params.set("q", q.trim());
      if (!reset && cursor) params.set("cursor", cursor);
      const res = await fetch(`/api/creator/assets/list?${params.toString()}`, { credentials: "include" });
      const data: ListResp = await res.json();
      setItems(prev => reset ? data.items : [...prev, ...data.items]);
      setHasMore(!!data.nextCursor);
      setCursor(data.nextCursor || null);
    } catch (e) {
      console.error("list error", e);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => { load(true); /* initial */ }, []);
  // refetch when filters change (debounced search)
  useEffect(() => {
    const t = setTimeout(() => load(true), 300);
    return () => clearTimeout(t);
  }, [kind, q]);

  const filtered = useMemo(() => items, [items]);

  const copy = async (text: string | undefined) => {
    if (!text) return;
    try { await navigator.clipboard.writeText(text); alert("Link copied!"); }
    catch { alert("Copy failed"); }
  };

  const remove = async (id: string) => {
    if (!confirm("Delete this asset? This can’t be undone.")) return;
    const res = await fetch(`/api/creator/assets/${id}`, { method: "DELETE", credentials: "include" });
    if (res.ok) setItems(prev => prev.filter(x => x.id !== id));
    else alert("Delete failed");
  };

  return (
    <div className="space-y-4">
      {/* Filters */}
      <div className="flex flex-wrap items-center gap-3">
        <div className="flex items-center gap-2">
          <label className="text-sm text-muted-foreground">Type</label>
          <select
            value={kind}
            onChange={e => setKind(e.target.value as any)}
            className="border rounded px-2 py-1 text-sm"
          >
            <option value="">All</option>
            <option value="photo">Stock Photos</option>
            <option value="mockup">Mockups</option>
            <option value="icon">Icons</option>
          </select>
        </div>
        <div className="relative flex-1 min-w-[220px] max-w-[360px]">
          <input
            value={q}
            onChange={e => setQ(e.target.value)}
            placeholder="Search by name…"
            className="w-full border rounded px-3 py-2 text-sm"
          />
          <PackageSearch className="w-4 h-4 absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
        </div>
      </div>

      {/* Grid */}
      {loading && items.length === 0 ? (
        <div className="flex items-center justify-center h-40">
          <div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
        </div>
      ) : filtered.length === 0 ? (
        <div className="text-sm text-muted-foreground">No uploads yet.</div>
      ) : (
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
          {filtered.map((a) => (
            <div key={a.id} className="border rounded-lg overflow-hidden bg-card">
              <div className="aspect-video bg-white flex items-center justify-center relative">
                {a.previewUrl ? (
                  <img
                    src={a.previewUrl}
                    alt={a.name}
                    className="w-full h-full object-cover"
                    onError={(e) => ((e.target as HTMLImageElement).style.display = "none")}
                  />
                ) : (
                  <div className="flex items-center justify-center text-muted-foreground">
                    <ImageIcon className="w-8 h-8" />
                  </div>
                )}
              </div>
              <div className="p-3">
                <div className="text-sm font-medium truncate" title={a.name}>{a.name}</div>
                <div className="text-xs text-muted-foreground flex items-center justify-between mt-1">
                  <span className="capitalize">{a.kind}</span>
                  <span>{formatDate(a.createdAt)}</span>
                </div>
                <div className="mt-3 grid grid-cols-2 gap-2">
                  <button
                    onClick={() => copy(a.previewUrl)}
                    className="inline-flex items-center justify-center gap-2 text-sm border rounded px-2 py-1 hover:bg-muted"
                    title="Copy preview link"
                  >
                    <Copy className="w-4 h-4" /> Copy link
                  </button>
                  <button
                    onClick={() => remove(a.id)}
                    className="inline-flex items-center justify-center gap-2 text-sm border rounded px-2 py-1 hover:bg-red-50 text-red-600 border-red-200"
                    title="Delete"
                  >
                    <Trash2 className="w-4 h-4" /> Delete
                  </button>
                </div>
              </div>
            </div>
          ))}
        </div>
      )}

      {hasMore && (
        <div className="flex justify-center">
          <button onClick={() => load(false)} className="text-sm border rounded px-4 py-2 hover:bg-muted">
            Load more
          </button>
        </div>
      )}
    </div>
  );
}


2) Add panel to Creator Dashboard: client/src/pages/creator/CreatorDashboard.tsx
(append under the existing sections)

import MyUploadsGrid from "./components/MyUploadsGrid";

// …inside the return:
<section className="mt-8">
  <h2 className="text-lg font-semibold mb-2">My Uploads</h2>
  <p className="text-sm text-muted-foreground mb-4">
    Your uploaded Stock Photos, Mockups, and Icons. Copy preview links or delete items.
  </p>
  <MyUploadsGrid />
</section>


3) (Optional) Add a direct route if you want a standalone page:
/creator/uploads → client/src/pages/creator/MyUploadsPage.tsx

import { DashboardTemplatePage } from "@/components/DashboardTemplatePage";
import MyUploadsGrid from "./components/MyUploadsGrid";
export default function MyUploadsPage() {
  return (
    <DashboardTemplatePage title="Creator – My Uploads">
      <MyUploadsGrid />
    </DashboardTemplatePage>
  );
}


…and wire it in client/src/App.tsx under your protected creator routes.

B) Backend (only if not merged earlier)

Ensure server/routes/creatorAssetRoutes.js exposes:

GET /api/creator/assets/list → returns { items, nextCursor } scoped to req.user.id

DELETE /api/creator/assets/:id → verifies ownership, marks deleted

Those routes already mirror the watermark + storage paths we used for admin stock, i.e., previews in stock/preview/{id} and mockups/preview/{id}, icons icons/preview/{id}.