From 0c68e504e462302945cd85ae70587fe53fd3601c Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 7 Feb 2026 07:08:03 +0000 Subject: [PATCH 1/3] feat(preview): add Preview Studio page with export functionality - Add PreviewStudioPage with device size selection (story, reels, feed) - Implement PreviewCanvas with grid and safe zone overlays - Add batch export to multiple formats (PNG, JPG, WebP) - Include DeviceSizePicker and DeviceTabs components - Add ExportPanel for configurable export options - Add auth migration for database setup --- .../migration.sql | 50 +++ .../src/components/preview/device-frame.tsx | 55 +++ .../components/preview/device-size-picker.tsx | 133 ++++++++ .../src/components/preview/device-tabs.tsx | 55 +++ .../src/components/preview/export-panel.tsx | 212 ++++++++++++ packages/web/src/components/preview/index.ts | 9 + .../src/components/preview/preview-canvas.tsx | 131 ++++++++ .../components/preview/preview-toolbar.tsx | 109 ++++++ .../components/preview/safe-zone-overlay.tsx | 81 +++++ .../src/components/preview/zoom-controls.tsx | 83 +++++ .../routes/_authenticated/preview-studio.tsx | 318 ++++++++++++++++++ 11 files changed, 1236 insertions(+) create mode 100644 packages/db/prisma/migrations/20260206064407_add_auth_models/migration.sql create mode 100644 packages/web/src/components/preview/device-frame.tsx create mode 100644 packages/web/src/components/preview/device-size-picker.tsx create mode 100644 packages/web/src/components/preview/device-tabs.tsx create mode 100644 packages/web/src/components/preview/export-panel.tsx create mode 100644 packages/web/src/components/preview/index.ts create mode 100644 packages/web/src/components/preview/preview-canvas.tsx create mode 100644 packages/web/src/components/preview/preview-toolbar.tsx create mode 100644 packages/web/src/components/preview/safe-zone-overlay.tsx create mode 100644 packages/web/src/components/preview/zoom-controls.tsx create mode 100644 packages/web/src/routes/_authenticated/preview-studio.tsx diff --git a/packages/db/prisma/migrations/20260206064407_add_auth_models/migration.sql b/packages/db/prisma/migrations/20260206064407_add_auth_models/migration.sql new file mode 100644 index 0000000..4c10424 --- /dev/null +++ b/packages/db/prisma/migrations/20260206064407_add_auth_models/migration.sql @@ -0,0 +1,50 @@ +-- CreateEnum +CREATE TYPE "ListingStatus" AS ENUM ('active', 'sold', 'delisted'); + +-- CreateEnum +CREATE TYPE "AssetType" AS ENUM ('ad_kit', 'branding', 'character', 'ui_kit', 'background', 'template', 'logo', 'scene_3d'); + +-- CreateEnum +CREATE TYPE "LicenseType" AS ENUM ('standard', 'extended', 'exclusive'); + +-- CreateTable +CREATE TABLE "MarketplaceListing" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "imageUrl" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "priceAicc" DECIMAL(18,2) NOT NULL, + "assetType" "AssetType" NOT NULL, + "licenseType" "LicenseType" NOT NULL DEFAULT 'standard', + "rating" DECIMAL(2,1) NOT NULL DEFAULT 0, + "reviewCount" INTEGER NOT NULL DEFAULT 0, + "isPremium" BOOLEAN NOT NULL DEFAULT false, + "tags" TEXT[], + "status" "ListingStatus" NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MarketplaceListing_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MarketplacePurchase" ( + "id" TEXT NOT NULL, + "listingId" TEXT NOT NULL, + "buyerId" TEXT NOT NULL, + "priceAicc" DECIMAL(18,2) NOT NULL, + "txHash" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MarketplacePurchase_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "MarketplaceListing" ADD CONSTRAINT "MarketplaceListing_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketplacePurchase" ADD CONSTRAINT "MarketplacePurchase_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "MarketplaceListing"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketplacePurchase" ADD CONSTRAINT "MarketplacePurchase_buyerId_fkey" FOREIGN KEY ("buyerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/web/src/components/preview/device-frame.tsx b/packages/web/src/components/preview/device-frame.tsx new file mode 100644 index 0000000..baa1e27 --- /dev/null +++ b/packages/web/src/components/preview/device-frame.tsx @@ -0,0 +1,55 @@ +import { forwardRef } from "react"; +import { cn } from "@/lib/utils"; +import type { PlacementSpec } from "../../../../shared/src/placementSpecs"; + +type DeviceFrameProps = { + spec: PlacementSpec; + zoom: number; + showNotch?: boolean; + children: React.ReactNode; + className?: string; +}; + +export const DeviceFrame = forwardRef( + ({ spec, zoom, showNotch = true, children, className }, ref) => { + // Calculate scaled dimensions + const scaledWidth = spec.width * zoom; + const scaledHeight = spec.height * zoom; + + return ( +
+ {/* Notch / Dynamic Island for Mobile */} + {spec.category === "mobile" && showNotch && ( +
+ )} + + {/* Content Container */} +
+ {children} +
+
+ ); + } +); + +DeviceFrame.displayName = "DeviceFrame"; diff --git a/packages/web/src/components/preview/device-size-picker.tsx b/packages/web/src/components/preview/device-size-picker.tsx new file mode 100644 index 0000000..511d7c7 --- /dev/null +++ b/packages/web/src/components/preview/device-size-picker.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { PLACEMENT_SPECS, type DeviceCategory, type PlacementSpecKey } from "../../../../shared/src/placementSpecs"; +import { cn } from "@/lib/utils"; + +const DEVICE_TABS: { key: DeviceCategory; label: string; icon: string }[] = [ + { key: "mobile", label: "Mobile", icon: "smartphone" }, + { key: "web", label: "Web", icon: "desktop_windows" }, + { key: "tv", label: "TV", icon: "tv" }, +]; + +type DeviceSizePickerProps = { + selectedSpecKey: PlacementSpecKey; + onSpecSelect: (key: PlacementSpecKey) => void; + selectedSpecs: PlacementSpecKey[]; + onBatchSelect: (keys: PlacementSpecKey[]) => void; + className?: string; +}; + +export function DeviceSizePicker({ + selectedSpecKey, + onSpecSelect, + selectedSpecs, + onBatchSelect, + className, +}: DeviceSizePickerProps) { + const [activeCategory, setActiveCategory] = useState("mobile"); + + const specsByCategory = PLACEMENT_SPECS.filter((spec) => spec.category === activeCategory); + + const handleToggle = (key: PlacementSpecKey) => { + if (key === selectedSpecKey) return; + onSpecSelect(key); + }; + + const handleBatchToggle = (key: PlacementSpecKey, event: React.MouseEvent) => { + event.stopPropagation(); + const newSelected = selectedSpecs.includes(key) + ? selectedSpecs.filter((k) => k !== key) + : [...selectedSpecs, key]; + onBatchSelect(newSelected); + }; + + return ( +
+ {/* Device Category Tabs */} +
+
+

+ Device Selection +

+

Preview layouts

+
+ +
+ {DEVICE_TABS.map((tab) => ( + + ))} +
+
+ + {/* Size List */} +
+
+

+ Dimensions +

+
+ +
+ {specsByCategory.map((spec) => ( + +

{spec.width} × {spec.height}

+
+ + {spec.aspectRatio} + + + ))} +
+
+ + {/* Custom Size Button */} +
+ +
+
+ ); +} diff --git a/packages/web/src/components/preview/device-tabs.tsx b/packages/web/src/components/preview/device-tabs.tsx new file mode 100644 index 0000000..b50dab1 --- /dev/null +++ b/packages/web/src/components/preview/device-tabs.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; +import type { PlacementSpec } from "../../../../shared/src/placementSpecs"; + +type DeviceTabsProps = { + specs: PlacementSpec[]; + activeSpecKey: string; + onSpecSelect: (key: string) => void; + className?: string; +}; + +export function DeviceTabs({ + specs, + activeSpecKey, + onSpecSelect, + className, +}: DeviceTabsProps) { + return ( +
+
+ {specs.map((spec) => ( + + ))} + + {/* Custom Device Option */} + +
+
+ ); +} diff --git a/packages/web/src/components/preview/export-panel.tsx b/packages/web/src/components/preview/export-panel.tsx new file mode 100644 index 0000000..0ba8017 --- /dev/null +++ b/packages/web/src/components/preview/export-panel.tsx @@ -0,0 +1,212 @@ +import { useState } from "react"; +import type { PlacementSpec, PlacementSpecKey } from "../../../../shared/src/placementSpecs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type ExportFormat = "png" | "jpg" | "webp"; + +type ExportOptions = { + format: ExportFormat; + quality: number; + scale: number; + backgroundColor: string | null; +}; + +type ExportPanelProps = { + selectedSpecs: PlacementSpec[]; + isExporting: boolean; + exportProgress: number; + onExport: (options: ExportOptions) => void; + onCancel: () => void; + className?: string; +}; + +const FORMAT_OPTIONS: { value: ExportFormat; label: string; ext: string }[] = [ + { value: "png", label: "PNG", ext: ".png" }, + { value: "jpg", label: "JPEG", ext: ".jpg" }, + { value: "webp", label: "WebP", ext: ".webp" }, +]; + +const SCALE_OPTIONS = [ + { value: 1, label: "1x (原生)" }, + { value: 2, label: "2x (高清)" }, + { value: 3, label: "3x (超高清)" }, +]; + +export function ExportPanel({ + selectedSpecs, + isExporting, + exportProgress, + onExport, + onCancel, + className, +}: ExportPanelProps) { + const [format, setFormat] = useState("png"); + const [scale, setScale] = useState(1); + const [backgroundColor, setBackgroundColor] = useState<"#ffffff" | "transparent" | "#000000">("#ffffff"); + + const handleExport = () => { + onExport({ + format, + quality: 0.95, + scale, + backgroundColor, + }); + }; + + if (isExporting) { + return ( +
+
+
+

导出中...

+ + {exportProgress}/{selectedSpecs.length} + +
+ + {/* Progress Bar */} +
+
+
+ + {/* Current Spec */} +

+ 正在导出: {selectedSpecs[exportProgress - 1]?.label || "完成"} +

+ + +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

导出预览

+ + {selectedSpecs.length} 个尺寸 + +
+ + {/* Specs List */} +
+

将导出:

+
+ {selectedSpecs.map((spec) => ( + + {spec.shortLabel} ({spec.width}×{spec.height}) + + ))} +
+
+ + {/* Format Selection */} +
+ +
+ {FORMAT_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Scale Selection */} +
+ + +
+ + {/* Background */} +
+ +
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/packages/web/src/components/preview/index.ts b/packages/web/src/components/preview/index.ts new file mode 100644 index 0000000..aaca1ad --- /dev/null +++ b/packages/web/src/components/preview/index.ts @@ -0,0 +1,9 @@ +export { DeviceSizePicker } from "./device-size-picker"; +export { PreviewToolbar } from "./preview-toolbar"; +export { PreviewCanvas } from "./preview-canvas"; +export { DeviceFrame } from "./device-frame"; +export { SafeZoneOverlay } from "./safe-zone-overlay"; +export { ZoomControls } from "./zoom-controls"; +export { DeviceTabs } from "./device-tabs"; +export { ExportPanel } from "./export-panel"; + diff --git a/packages/web/src/components/preview/preview-canvas.tsx b/packages/web/src/components/preview/preview-canvas.tsx new file mode 100644 index 0000000..fabcfe3 --- /dev/null +++ b/packages/web/src/components/preview/preview-canvas.tsx @@ -0,0 +1,131 @@ +import { useCallback, useRef, useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import type { PlacementSpec, PlacementSpecKey } from "../../../../shared/src/placementSpecs"; +import { DeviceFrame } from "./device-frame"; +import { SafeZoneOverlay } from "./safe-zone-overlay"; +import { ZoomControls } from "./zoom-controls"; + +type PreviewCanvasProps = { + spec: PlacementSpec; + children: React.ReactNode; + showGrid?: boolean; + showSafeZone?: boolean; + backgroundType?: "checkerboard" | "solid" | "transparent"; + className?: string; +}; + +export function PreviewCanvas({ + spec, + children, + showGrid = false, + showSafeZone = true, + backgroundType = "checkerboard", + className, +}: PreviewCanvasProps) { + const containerRef = useRef(null); + const [zoom, setZoom] = useState<"fit" | number>("fit"); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + + // Calculate zoom to fit + const calculateFitZoom = useCallback(() => { + if (!containerRef.current) return 0.5; + + const container = containerRef.current; + const padding = 96; // 24px padding on each side + const availableWidth = container.clientWidth - padding; + const availableHeight = container.clientHeight - padding; + + const scaleX = availableWidth / spec.width; + const scaleY = availableHeight / spec.height; + + return Math.min(scaleX, scaleY, 1); // Max zoom 100% + }, [spec.height, spec.width]); + + // Update container size for zoom calculations + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + setContainerSize({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight, + }); + } + }; + + updateSize(); + window.addEventListener("resize", updateSize); + return () => window.removeEventListener("resize", updateSize); + }, []); + + // Set initial fit zoom + useEffect(() => { + setZoom("fit"); + }, [spec.key]); + + const actualZoom = zoom === "fit" ? calculateFitZoom() : zoom; + + const handleZoomIn = () => { + const currentZoom = zoom === "fit" ? calculateFitZoom() : zoom; + const newZoom = Math.min(currentZoom + 0.25, 2); + setZoom(newZoom); + }; + + const handleZoomOut = () => { + const currentZoom = zoom === "fit" ? calculateFitZoom() : zoom; + const newZoom = Math.max(currentZoom - 0.25, 0.25); + setZoom(newZoom); + }; + + const handleZoomChange = (newZoom: "fit" | number) => { + setZoom(newZoom); + }; + + return ( +
+ {/* Background Toggle */} +
+ +
+ + {/* Device Frame with Preview */} + + {children} + + {/* Safe Zone Overlay */} + {showSafeZone && ( + + )} + + + {/* Zoom Controls */} +
+ +
+
+ ); +} diff --git a/packages/web/src/components/preview/preview-toolbar.tsx b/packages/web/src/components/preview/preview-toolbar.tsx new file mode 100644 index 0000000..1d5aa9e --- /dev/null +++ b/packages/web/src/components/preview/preview-toolbar.tsx @@ -0,0 +1,109 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +export type CreativeStatus = "draft" | "published" | "purchased"; + +type PreviewToolbarProps = { + title: string; + onTitleChange: (title: string) => void; + version: string; + status: CreativeStatus; + onSave: () => void; + onPublish?: () => void; + onPurchase?: () => void; + canPublish?: boolean; + canPurchase?: boolean; + className?: string; +}; + +const STATUS_CONFIG: Record< + CreativeStatus, + { label: string; className: string } +> = { + draft: { label: "Draft", className: "bg-slate-500/20 text-slate-400" }, + published: { label: "Published", className: "bg-green-500/20 text-green-400" }, + purchased: { label: "Purchased", className: "bg-primary/20 text-primary" }, +}; + +export function PreviewToolbar({ + title, + onTitleChange, + version, + status, + onSave, + onPublish, + onPurchase, + canPublish = false, + canPurchase = false, + className, +}: PreviewToolbarProps) { + return ( +
+ {/* Left Section */} +
+ {/* Back Button */} + + +
+ + {/* Title Input */} +
+ onTitleChange(e.target.value)} + className="bg-transparent text-lg font-bold leading-tight tracking-tight focus:outline-none focus:border-b-2 focus:border-primary px-1" + placeholder="Untitled Creative" + /> +
+ + {/* Version Badge */} + + {version} + + + {/* Status Badge */} + + {STATUS_CONFIG[status].label} + +
+ + {/* Right Section */} +
+ {/* Actions */} + + + {canPublish && onPublish && ( + + )} + + {canPurchase && onPurchase && ( + + )} +
+
+ ); +} diff --git a/packages/web/src/components/preview/safe-zone-overlay.tsx b/packages/web/src/components/preview/safe-zone-overlay.tsx new file mode 100644 index 0000000..acccde8 --- /dev/null +++ b/packages/web/src/components/preview/safe-zone-overlay.tsx @@ -0,0 +1,81 @@ +import { cn } from "@/lib/utils"; +import type { PlacementSpec } from "../../../../shared/src/placementSpecs"; + +type SafeZoneOverlayProps = { + spec: PlacementSpec; + zoom: number; + showLabels?: boolean; + className?: string; +}; + +export function SafeZoneOverlay({ + spec, + zoom, + showLabels = true, + className, +}: SafeZoneOverlayProps) { + const { safeArea } = spec; + + // Calculate scaled margins + const top = safeArea.top * zoom; + const right = safeArea.right * zoom; + const bottom = safeArea.bottom * zoom; + const left = safeArea.left * zoom; + + return ( +
+ {/* Top Safe Zone */} +
+ {showLabels && ( + + Safe Zone + + )} +
+ + {/* Bottom Safe Zone */} +
+ + {/* Left Safe Zone */} +
+ + {/* Right Safe Zone */} +
+ + {/* Corner Indicators */} + {showLabels && ( + <> + + {safeArea.top}px + + + {safeArea.bottom}px + + + )} +
+ ); +} diff --git a/packages/web/src/components/preview/zoom-controls.tsx b/packages/web/src/components/preview/zoom-controls.tsx new file mode 100644 index 0000000..56827fa --- /dev/null +++ b/packages/web/src/components/preview/zoom-controls.tsx @@ -0,0 +1,83 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type ZoomLevel = "fit" | number; + +type ZoomControlsProps = { + zoom: ZoomLevel; + onZoomChange: (zoom: ZoomLevel) => void; + onZoomIn: () => void; + onZoomOut: () => void; + className?: string; +}; + +const ZOOM_OPTIONS = [ + { label: "Fit", value: "fit" }, + { label: "25%", value: 0.25 }, + { label: "50%", value: 0.5 }, + { label: "75%", value: 0.75 }, + { label: "100%", value: 1 }, + { label: "150%", value: 1.5 }, + { label: "200%", value: 2 }, +]; + +export function ZoomControls({ + zoom, + onZoomChange, + onZoomIn, + onZoomOut, + className, +}: ZoomControlsProps) { + const zoomPercentage = zoom === "fit" ? "Fit" : `${Math.round((zoom as number) * 100)}%`; + + return ( +
+ {/* Zoom Out */} + + + {/* Zoom Percentage Dropdown */} +
+ + + {/* Dropdown Menu */} +
+ {ZOOM_OPTIONS.map((option) => ( + + ))} +
+
+ + {/* Zoom In */} + +
+ ); +} diff --git a/packages/web/src/routes/_authenticated/preview-studio.tsx b/packages/web/src/routes/_authenticated/preview-studio.tsx new file mode 100644 index 0000000..1c8c179 --- /dev/null +++ b/packages/web/src/routes/_authenticated/preview-studio.tsx @@ -0,0 +1,318 @@ +import { useState, useMemo, useRef } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { PLACEMENT_SPECS, type PlacementSpecKey } from "../../../../shared/src/placementSpecs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + DeviceSizePicker, + PreviewToolbar, + PreviewCanvas, + DeviceTabs, + ExportPanel, +} from "@/components/preview"; +import html2canvas from "html2canvas"; + +type CreativeStatus = "draft" | "published" | "purchased"; +type ExportFormat = "png" | "jpg" | "webp"; + +type ExportOptions = { + format: ExportFormat; + quality: number; + scale: number; + backgroundColor: string | null; +}; + +function PreviewStudioPage() { + // State + const [title, setTitle] = useState("Untitled Creative"); + const [status, setStatus] = useState("draft"); + const [selectedSpecKey, setSelectedSpecKey] = useState("story_9_16"); + const [selectedSpecs, setSelectedSpecs] = useState(["story_9_16"]); + const [showGrid, setShowGrid] = useState(true); + const [showSafeZone, setShowSafeZone] = useState(true); + const [backgroundType, setBackgroundType] = useState<"checkerboard" | "solid">("checkerboard"); + + // Export state + const [showExportPanel, setShowExportPanel] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(0); + + // Refs for export + const previewContainerRef = useRef(null); + + // Get selected spec + const selectedSpec = useMemo( + () => PLACEMENT_SPECS.find((s) => s.key === selectedSpecKey)!, + [selectedSpecKey] + ); + + // Get all specs for tabs + const tabsSpecs = useMemo(() => PLACEMENT_SPECS.filter((s) => s.category === selectedSpec.category), [selectedSpec.category]); + + // Get specs objects + const selectedSpecObjects = useMemo( + () => PLACEMENT_SPECS.filter((s) => selectedSpecs.includes(s.key)), + [selectedSpecs] + ); + + // Handlers + const handleSave = () => { + console.log("Saving creative:", { title, spec: selectedSpecKey }); + // TODO: API call to save + setStatus("draft"); + }; + + const handlePublish = () => { + console.log("Publishing creative:", { title, spec: selectedSpecKey }); + // TODO: API call to publish + setStatus("published"); + }; + + const handlePurchase = () => { + console.log("Purchasing creative:", { title, spec: selectedSpecKey }); + // TODO: API call to purchase + setStatus("purchased"); + }; + + // Export handler + const handleExport = async (options: ExportOptions) => { + setIsExporting(true); + setExportProgress(0); + setShowExportPanel(false); + + try { + // Get the preview container + const container = previewContainerRef.current; + if (!container) { + throw new Error("Preview container not found"); + } + + // Get device frames within the container + const deviceFrames = container.querySelectorAll(".device-frame-export"); + + const downloadedFiles: string[] = []; + + for (let i = 0; i < selectedSpecObjects.length; i++) { + const spec = selectedSpecObjects[i]; + setExportProgress(i + 1); + + try { + // Create a temporary container for this export + const tempContainer = document.createElement("div"); + tempContainer.style.width = `${spec.width}px`; + tempContainer.style.height = `${spec.height}px`; + tempContainer.style.position = "fixed"; + tempContainer.style.left = "-9999px"; + tempContainer.style.top = "0"; + document.body.appendChild(tempContainer); + + // Clone the preview content + const originalContent = container.querySelector(".preview-content"); + if (originalContent) { + const clonedContent = originalContent.cloneNode(true) as HTMLElement; + + // Apply background + if (options.backgroundColor) { + clonedContent.style.backgroundColor = options.backgroundColor; + clonedContent.style.backgroundImage = "none"; + } + + tempContainer.appendChild(clonedContent); + + // Generate canvas + const canvas = await html2canvas(tempContainer, { + width: spec.width, + height: spec.height, + scale: options.scale, + backgroundColor: options.backgroundColor, + logging: false, + }); + + // Download file + const link = document.createElement("a"); + link.download = `${title.replace(/\s+/g, "_")}_${spec.key}_${spec.width}x${spec.height}.${options.format}`; + link.href = canvas.toDataURL(`image/${options.format}`, options.quality); + link.click(); + + downloadedFiles.push(link.download); + } + + // Clean up + document.body.removeChild(tempContainer); + } catch (err) { + console.error(`Failed to export ${spec.key}:`, err); + } + } + + // Show success message + alert(`成功导出 ${downloadedFiles.length} 个文件!\n\n${downloadedFiles.join("\n")}`); + } catch (error) { + console.error("Export failed:", error); + alert("导出失败,请重试"); + } finally { + setIsExporting(false); + setExportProgress(0); + } + }; + + const handleCancelExport = () => { + setIsExporting(false); + setExportProgress(0); + }; + + // Mock preview content + const PreviewContent = ( +
+
+
+ LIVE AD + more_vert +
+
+

+ Elevate Your Creative Workflow. +

+

+ Generate stunning NFT assets in seconds with AI-powered optimization. +

+ +
+
+
+ ); + + return ( +
+ {/* Top Toolbar */} + + +
+ {/* Left Sidebar - Device & Size Picker */} + + + {/* Main Content */} +
+ {/* Top Controls Bar */} +
+ {/* Left Controls */} +
+ + +
+
+ + {/* Right Controls */} +
+ {/* Batch Export Button */} + + + {/* AI Auto-Fix Button */} + +
+
+ + {/* Device Tabs */} + + + {/* Preview Canvas */} +
+ + {PreviewContent} + +
+ + {/* Export Panel Modal */} + {showExportPanel && ( +
+ setShowExportPanel(false)} + className="w-96" + /> +
+ )} + + {/* Export Progress Overlay */} + {isExporting && ( +
+ {}} + onCancel={handleCancelExport} + className="w-80" + /> +
+ )} +
+
+
+ ); +} + +export const Route = createFileRoute("/_authenticated/preview-studio")({ + component: PreviewStudioPage, +}); From 9adb5ef91a9c29f5df6aff77039d0971bb8394eb Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 9 Feb 2026 03:54:52 +0000 Subject: [PATCH 2/3] feat: Preview Studio multi-platform multi-size preview (#22) - Add device size picker with mobile/web/tv categories - Implement multi-size preview canvas with zoom controls - Add grid overlay and safe zone indicators - Export functionality with html2canvas - Add back button navigation to dashboard - Remove AI Auto-Fix feature (not ready) - Fix LIVE AD badge styling in exports --- README.md | 10 + package-lock.json | 50 +++ package.json | 1 + packages/api/src/app.ts | 126 ++++++ packages/shared/src/placementSpecs.ts | 26 +- packages/web/package.json | 2 + .../web/src/components/layout/top-nav.tsx | 2 +- .../components/preview/device-size-picker.tsx | 34 +- .../src/components/preview/device-tabs.tsx | 138 +++++-- .../src/components/preview/export-panel.tsx | 48 +-- .../components/preview/preview-toolbar.tsx | 31 +- packages/web/src/hooks/use-current-user.ts | 13 + packages/web/src/index.css | 19 + packages/web/src/routeTree.gen.ts | 22 + packages/web/src/routes/_authenticated.tsx | 19 + .../routes/_authenticated/preview-studio.tsx | 376 ++++++++++++++---- packages/web/vite.config.ts | 2 +- 17 files changed, 722 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index fd422bc..2e16f10 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,16 @@ bun run dev:web Web runs on http://localhost:5173 +## Development with Auth Bypass (for testing without Auth service) + +If you want to preview features without setting up full Auth: + +```bash +bun run dev:web:bypass +``` + +This will automatically log you in as a test user so you can access all authenticated pages like Preview Studio. + ## Troubleshooting - If the API complains about `DATABASE_URL`, confirm it is set in `.env`. diff --git a/package-lock.json b/package-lock.json index 8329d15..5c8b459 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3220,6 +3220,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -3617,6 +3626,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -4670,6 +4688,19 @@ "node": ">=18" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6591,6 +6622,15 @@ "node": ">=8.10.0" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6995,6 +7035,15 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -8760,6 +8809,7 @@ "better-auth": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html2canvas": "^1.4.1", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/package.json b/package.json index 73f752c..dbf01ff 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev:api": "bun --cwd packages/api run dev", "dev:worker": "bun --cwd packages/worker run dev", "dev:web": "bun --cwd packages/web run dev", + "dev:web:bypass": "bun --cwd packages/web run dev:bypass", "dev:frontend": "npm --prefix frontend run dev", "build:frontend": "npm --prefix frontend run build", diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 6a8cf5c..40f5084 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -282,6 +282,132 @@ export function createApp({ prisma, getSession }: AppDeps) { return c.json({ drafts }); }); + // --- Creative / Preview Studio API --- + + // Mock creative data storage (in-memory for demo) + const mockCreatives: Record = {}; + + app.get("/api/creatives", async (c) => { + const user = c.get("user") as SessionUser | null; + if (!user) return c.json({ error: "unauthorized" }, 401); + + const userCreatives = Object.values(mockCreatives).filter((c) => c.userId === user.id); + return c.json({ creatives: userCreatives }); + }); + + app.get("/api/creatives/:id", async (c) => { + const user = c.get("user") as SessionUser | null; + if (!user) return c.json({ error: "unauthorized" }, 401); + const { id } = c.req.param(); + const creative = mockCreatives[id]; + if (!creative) return c.json({ error: "not_found" }, 404); + return c.json({ creative }); + }); + + app.post("/api/creatives", async (c) => { + const user = c.get("user") as SessionUser | null; + if (!user) return c.json({ error: "unauthorized" }, 401); + + const body = await c.req.json(); + const id = `creative_${Date.now()}`; + const now = new Date().toISOString(); + + mockCreatives[id] = { + id, + title: body.title || "Untitled Creative", + specKey: body.specKey || "story_9_16", + status: "draft", + content: body.content || {}, + userId: user.id, + createdAt: now, + updatedAt: now + }; + + return c.json({ creative: mockCreatives[id] }); + }); + + app.patch("/api/creatives/:id", async (c) => { + const user = c.get("user") as SessionUser | null; + if (!user) return c.json({ error: "unauthorized" }, 401); + const { id } = c.req.param(); + const creative = mockCreatives[id]; + if (!creative) return c.json({ error: "not_found" }, 404); + + const body = await c.req.json(); + mockCreatives[id] = { + ...creative, + ...(body.title !== undefined && { title: body.title }), + ...(body.specKey !== undefined && { specKey: body.specKey }), + ...(body.status !== undefined && { status: body.status }), + ...(body.content !== undefined && { content: body.content }), + updatedAt: new Date().toISOString() + }; + + return c.json({ creative: mockCreatives[id] }); + }); + + app.post("/api/creatives/:id/publish", async (c) => { + const user = c.get("user") as SessionUser | null; + if (!user) return c.json({ error: "unauthorized" }, 401); + const { id } = c.req.param(); + const creative = mockCreatives[id]; + if (!creative) return c.json({ error: "not_found" }, 404); + + mockCreatives[id] = { + ...creative, + status: "published", + updatedAt: new Date().toISOString() + }; + + return c.json({ creative: mockCreatives[id] }); + }); + + // --- Independent AI Fix API (works without creative ID) --- + app.post("/api/ai/optimize", async (c) => { + const user = c.get("user") as SessionUser | null; + if (!user) return c.json({ error: "unauthorized" }, 401); + + const body = await c.req.json(); + const { specKey, content } = body; + + // Mock AI optimization response + return c.json({ + ok: true, + message: "AI optimization completed", + suggestions: [ + "Adjusted text size for better readability", + "Optimized color contrast for accessibility", + "Balanced layout composition", + "Enhanced visual hierarchy with font weight adjustments" + ], + optimizedContent: { + ...content, + aiOptimized: true, + optimizedAt: new Date().toISOString() + } + }); + }); + + // AI fix for existing creative (requires creative ID) + app.post("/api/creatives/:id/ai-fix", async (c) => { + const user = c.get("user") as SessionUser | null; + if (!user) return c.json({ error: "unauthorized" }, 401); + const { id } = c.req.param(); + const creative = mockCreatives[id]; + if (!creative) return c.json({ error: "not_found" }, 404); + + // Mock AI auto-fix response + return c.json({ + ok: true, + message: "AI optimization applied", + suggestions: [ + "Adjusted text size for better readability", + "Optimized color contrast", + "Balanced layout composition" + ] + }); + }); + // --- Marketplace API --- const marketplaceQuerySchema = z.object({ search: z.string().optional(), diff --git a/packages/shared/src/placementSpecs.ts b/packages/shared/src/placementSpecs.ts index d644aa4..fed2fcd 100644 --- a/packages/shared/src/placementSpecs.ts +++ b/packages/shared/src/placementSpecs.ts @@ -6,12 +6,18 @@ export type PlacementSpecKey = | "banner_ultrawide" | "tv_4k"; +export type DeviceCategory = "mobile" | "web" | "tv"; + export type PlacementSpec = { key: PlacementSpecKey; label: string; - category: "mobile" | "web" | "tv"; + category: DeviceCategory; width: number; height: number; + aspectRatio: string; + // UI fields + icon: string; + shortLabel: string; // safe-area margins in pixels safeArea: { top: number; right: number; bottom: number; left: number }; rules: { @@ -30,6 +36,9 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ category: "mobile", width: 1080, height: 1080, + aspectRatio: "1:1", + icon: "square", + shortLabel: "1:1", safeArea: { top: 64, right: 64, bottom: 64, left: 64 }, rules: { minTitleFontSize: 44, minBodyFontSize: 28, maxTitleLines: 2, maxBodyLines: 4 } }, @@ -39,6 +48,9 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ category: "mobile", width: 1080, height: 1350, + aspectRatio: "4:5", + icon: "rectangle", + shortLabel: "4:5", safeArea: { top: 64, right: 64, bottom: 80, left: 64 }, rules: { minTitleFontSize: 44, minBodyFontSize: 28, maxTitleLines: 2, maxBodyLines: 4 } }, @@ -48,6 +60,9 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ category: "mobile", width: 1080, height: 1920, + aspectRatio: "9:16", + icon: "phone_iphone", + shortLabel: "9:16", // extra bottom safe-area for UI overlays safeArea: { top: 120, right: 80, bottom: 220, left: 80 }, rules: { minTitleFontSize: 52, minBodyFontSize: 30, maxTitleLines: 3, maxBodyLines: 4 } @@ -58,6 +73,9 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ category: "web", width: 1920, height: 1080, + aspectRatio: "16:9", + icon: "desktop_windows", + shortLabel: "16:9", safeArea: { top: 64, right: 96, bottom: 64, left: 96 }, rules: { minTitleFontSize: 52, minBodyFontSize: 30, maxTitleLines: 2, maxBodyLines: 3 } }, @@ -67,6 +85,9 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ category: "web", width: 2560, height: 720, + aspectRatio: "ultra", + icon: "width_full", + shortLabel: "ULTRA", safeArea: { top: 48, right: 120, bottom: 48, left: 120 }, rules: { minTitleFontSize: 56, minBodyFontSize: 32, maxTitleLines: 1, maxBodyLines: 2 } }, @@ -76,6 +97,9 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ category: "tv", width: 3840, height: 2160, + aspectRatio: "16:9", + icon: "tv", + shortLabel: "4K", // TV overscan-ish margins safeArea: { top: 160, right: 200, bottom: 160, left: 200 }, rules: { minTitleFontSize: 96, minBodyFontSize: 56, maxTitleLines: 2, maxBodyLines: 3 } diff --git a/packages/web/package.json b/packages/web/package.json index d286442..f53d61a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:bypass": "VITE_BYPASS_AUTH=true vite", "build": "vite build", "preview": "vite preview", "test": "vitest" @@ -17,6 +18,7 @@ "better-auth": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html2canvas": "^1.4.1", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/web/src/components/layout/top-nav.tsx b/packages/web/src/components/layout/top-nav.tsx index bcc4189..fd59049 100644 --- a/packages/web/src/components/layout/top-nav.tsx +++ b/packages/web/src/components/layout/top-nav.tsx @@ -42,7 +42,7 @@ export function TopNav({ user }: TopNavProps) {

- CreativeAI + Preview Studio

diff --git a/packages/web/src/components/preview/device-size-picker.tsx b/packages/web/src/components/preview/device-size-picker.tsx index 511d7c7..1dcb3be 100644 --- a/packages/web/src/components/preview/device-size-picker.tsx +++ b/packages/web/src/components/preview/device-size-picker.tsx @@ -13,6 +13,7 @@ type DeviceSizePickerProps = { onSpecSelect: (key: PlacementSpecKey) => void; selectedSpecs: PlacementSpecKey[]; onBatchSelect: (keys: PlacementSpecKey[]) => void; + onCategoryChange?: (category: DeviceCategory, firstSpecKey: PlacementSpecKey) => void; className?: string; }; @@ -21,14 +22,19 @@ export function DeviceSizePicker({ onSpecSelect, selectedSpecs, onBatchSelect, + onCategoryChange, className, }: DeviceSizePickerProps) { const [activeCategory, setActiveCategory] = useState("mobile"); const specsByCategory = PLACEMENT_SPECS.filter((spec) => spec.category === activeCategory); + const handleCategoryClick = (category: DeviceCategory) => { + setActiveCategory(category); + // Don't auto-select, let user choose + }; + const handleToggle = (key: PlacementSpecKey) => { - if (key === selectedSpecKey) return; onSpecSelect(key); }; @@ -41,9 +47,9 @@ export function DeviceSizePicker({ }; return ( -
+
{/* Device Category Tabs */} -
+

Device Selection @@ -55,7 +61,7 @@ export function DeviceSizePicker({ {DEVICE_TABS.map((tab) => (

{/* Size List */} -
-
+
+

Dimensions

-
+
{specsByCategory.map((spec) => ( +

{spec.width} × {spec.height}

- - {/* Custom Size Button */} -
- -
); } diff --git a/packages/web/src/components/preview/device-tabs.tsx b/packages/web/src/components/preview/device-tabs.tsx index b50dab1..44efe88 100644 --- a/packages/web/src/components/preview/device-tabs.tsx +++ b/packages/web/src/components/preview/device-tabs.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { cn } from "@/lib/utils"; import type { PlacementSpec } from "../../../../shared/src/placementSpecs"; @@ -5,6 +6,7 @@ type DeviceTabsProps = { specs: PlacementSpec[]; activeSpecKey: string; onSpecSelect: (key: string) => void; + onCustomClick?: () => void; className?: string; }; @@ -12,44 +14,120 @@ export function DeviceTabs({ specs, activeSpecKey, onSpecSelect, + onCustomClick, className, }: DeviceTabsProps) { + const [showCustomDialog, setShowCustomDialog] = useState(false); + const [customWidth, setCustomWidth] = useState(1080); + const [customHeight, setCustomHeight] = useState(1080); + + const handleCustomClick = () => { + setShowCustomDialog(true); + }; + + const handleCustomConfirm = () => { + if (customWidth > 0 && customHeight > 0) { + onCustomClick?.(customWidth, customHeight); + setShowCustomDialog(false); + } + }; + return ( -
-
- {specs.map((spec) => ( + <> +
+
+ {specs.map((spec) => ( + + ))} + + {/* Custom Device Option */} - ))} - - {/* Custom Device Option */} - +
-
+ + {/* Custom Size Dialog */} + {showCustomDialog && ( +
+
+

Custom Size

+ +
+
+ + { + const val = e.target.value.replace(/[^0-9]/g, ""); + setCustomWidth(val ? Number(val) : 0); + }} + className="w-full px-3 py-2 rounded border border-slate-700 bg-slate-900 text-white" + placeholder="1080" + /> +
+
+ + { + const val = e.target.value.replace(/[^0-9]/g, ""); + setCustomHeight(val ? Number(val) : 0); + }} + className="w-full px-3 py-2 rounded border border-slate-700 bg-slate-900 text-white" + placeholder="1920" + /> +
+
+ +
+ + +
+
+
+ )} + ); } diff --git a/packages/web/src/components/preview/export-panel.tsx b/packages/web/src/components/preview/export-panel.tsx index 0ba8017..bc8d82e 100644 --- a/packages/web/src/components/preview/export-panel.tsx +++ b/packages/web/src/components/preview/export-panel.tsx @@ -43,14 +43,13 @@ export function ExportPanel({ }: ExportPanelProps) { const [format, setFormat] = useState("png"); const [scale, setScale] = useState(1); - const [backgroundColor, setBackgroundColor] = useState<"#ffffff" | "transparent" | "#000000">("#ffffff"); const handleExport = () => { onExport({ format, quality: 0.95, scale, - backgroundColor, + backgroundColor: null, }); }; @@ -151,51 +150,6 @@ export function ExportPanel({
- {/* Background */} -
- -
-
-
- {/* Actions */}
@@ -85,22 +85,15 @@ export function PreviewToolbar({ {/* Right Section */}
{/* Actions */} - {canPublish && onPublish && ( - - )} - - {canPurchase && onPurchase && ( - )}
diff --git a/packages/web/src/hooks/use-current-user.ts b/packages/web/src/hooks/use-current-user.ts index 2d4a0cb..41dc94a 100644 --- a/packages/web/src/hooks/use-current-user.ts +++ b/packages/web/src/hooks/use-current-user.ts @@ -9,6 +9,19 @@ type CurrentUser = { }; export function useCurrentUser(apiClient?: Pick) { + // Check for auth bypass mode + if (import.meta.env.VITE_BYPASS_AUTH === "true") { + return { + data: { + id: "test-user", + email: "test@example.com", + name: "Test User" + } as CurrentUser, + isLoading: false, + isError: false + }; + } + const api = apiClient ?? createApiClient(); return useQuery({ queryKey: ["currentUser"], diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 71fab10..59febff 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -30,3 +30,22 @@ font-family: "Space Grotesk", "SF Pro Display", "Helvetica Neue", Arial, sans-serif; } } + +/* Grid Overlay */ +.canvas-grid { + background-image: + linear-gradient(to right, rgba(99, 102, 241, 0.1) 1px, transparent 1px), + linear-gradient(to bottom, rgba(99, 102, 241, 0.1) 1px, transparent 1px); + background-size: 20px 20px; +} + +.canvas-grid::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgba(99, 102, 241, 0.15) 1px, transparent 1px), + linear-gradient(to bottom, rgba(99, 102, 241, 0.15) 1px, transparent 1px); + background-size: 100px 100px; + pointer-events: none; +} diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 1c95ba1..858e0ec 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as AuthenticatedRouteImport } from "./routes/_authenticated" import { Route as IndexRouteImport } from "./routes/index" import { Route as AuthenticatedWalletRouteImport } from "./routes/_authenticated/wallet" import { Route as AuthenticatedProjectsRouteImport } from "./routes/_authenticated/projects" +import { Route as AuthenticatedPreviewStudioRouteImport } from "./routes/_authenticated/preview-studio" import { Route as AuthenticatedMarketRouteImport } from "./routes/_authenticated/market" import { Route as AuthenticatedDashboardRouteImport } from "./routes/_authenticated/dashboard" import { Route as AuthenticatedCreativeStudioRouteImport } from "./routes/_authenticated/creative-studio" @@ -43,6 +44,12 @@ const AuthenticatedProjectsRoute = AuthenticatedProjectsRouteImport.update({ path: "/projects", getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedPreviewStudioRoute = + AuthenticatedPreviewStudioRouteImport.update({ + id: "/preview-studio", + path: "/preview-studio", + getParentRoute: () => AuthenticatedRoute, + } as any) const AuthenticatedMarketRoute = AuthenticatedMarketRouteImport.update({ id: "/market", path: "/market", @@ -71,6 +78,7 @@ export interface FileRoutesByFullPath { "/creative-studio": typeof AuthenticatedCreativeStudioRoute "/dashboard": typeof AuthenticatedDashboardRoute "/market": typeof AuthenticatedMarketRoute + "/preview-studio": typeof AuthenticatedPreviewStudioRoute "/projects": typeof AuthenticatedProjectsRoute "/wallet": typeof AuthenticatedWalletRoute "/market/$id": typeof AuthenticatedMarketIdRoute @@ -81,6 +89,7 @@ export interface FileRoutesByTo { "/creative-studio": typeof AuthenticatedCreativeStudioRoute "/dashboard": typeof AuthenticatedDashboardRoute "/market": typeof AuthenticatedMarketRoute + "/preview-studio": typeof AuthenticatedPreviewStudioRoute "/projects": typeof AuthenticatedProjectsRoute "/wallet": typeof AuthenticatedWalletRoute "/market/$id": typeof AuthenticatedMarketIdRoute @@ -93,6 +102,7 @@ export interface FileRoutesById { "/_authenticated/creative-studio": typeof AuthenticatedCreativeStudioRoute "/_authenticated/dashboard": typeof AuthenticatedDashboardRoute "/_authenticated/market": typeof AuthenticatedMarketRoute + "/_authenticated/preview-studio": typeof AuthenticatedPreviewStudioRoute "/_authenticated/projects": typeof AuthenticatedProjectsRoute "/_authenticated/wallet": typeof AuthenticatedWalletRoute "/_authenticated/market_/$id": typeof AuthenticatedMarketIdRoute @@ -105,6 +115,7 @@ export interface FileRouteTypes { | "/creative-studio" | "/dashboard" | "/market" + | "/preview-studio" | "/projects" | "/wallet" | "/market/$id" @@ -115,6 +126,7 @@ export interface FileRouteTypes { | "/creative-studio" | "/dashboard" | "/market" + | "/preview-studio" | "/projects" | "/wallet" | "/market/$id" @@ -126,6 +138,7 @@ export interface FileRouteTypes { | "/_authenticated/creative-studio" | "/_authenticated/dashboard" | "/_authenticated/market" + | "/_authenticated/preview-studio" | "/_authenticated/projects" | "/_authenticated/wallet" | "/_authenticated/market_/$id" @@ -174,6 +187,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof AuthenticatedProjectsRouteImport parentRoute: typeof AuthenticatedRoute } + "/_authenticated/preview-studio": { + id: "/_authenticated/preview-studio" + path: "/preview-studio" + fullPath: "/preview-studio" + preLoaderRoute: typeof AuthenticatedPreviewStudioRouteImport + parentRoute: typeof AuthenticatedRoute + } "/_authenticated/market": { id: "/_authenticated/market" path: "/market" @@ -209,6 +229,7 @@ interface AuthenticatedRouteChildren { AuthenticatedCreativeStudioRoute: typeof AuthenticatedCreativeStudioRoute AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute AuthenticatedMarketRoute: typeof AuthenticatedMarketRoute + AuthenticatedPreviewStudioRoute: typeof AuthenticatedPreviewStudioRoute AuthenticatedProjectsRoute: typeof AuthenticatedProjectsRoute AuthenticatedWalletRoute: typeof AuthenticatedWalletRoute AuthenticatedMarketIdRoute: typeof AuthenticatedMarketIdRoute @@ -218,6 +239,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedCreativeStudioRoute: AuthenticatedCreativeStudioRoute, AuthenticatedDashboardRoute: AuthenticatedDashboardRoute, AuthenticatedMarketRoute: AuthenticatedMarketRoute, + AuthenticatedPreviewStudioRoute: AuthenticatedPreviewStudioRoute, AuthenticatedProjectsRoute: AuthenticatedProjectsRoute, AuthenticatedWalletRoute: AuthenticatedWalletRoute, AuthenticatedMarketIdRoute: AuthenticatedMarketIdRoute, diff --git a/packages/web/src/routes/_authenticated.tsx b/packages/web/src/routes/_authenticated.tsx index 33b53cf..b245877 100644 --- a/packages/web/src/routes/_authenticated.tsx +++ b/packages/web/src/routes/_authenticated.tsx @@ -4,6 +4,25 @@ import { authClient } from "@/lib/auth-client"; export const Route = createFileRoute("/_authenticated")({ beforeLoad: async () => { + // ============================================================ + // DEV MODE BYPASS - 開発モードバイパス + // ============================================================ + // Purpose: Allows testing without full Auth service setup + // Usage: Set VITE_BYPASS_AUTH=true when running dev server + // Example: VITE_BYPASS_AUTH=true bun run dev:web + // When: Useful for previewing features during development + // Remove: Delete this block before production deployment + // ============================================================ + if (import.meta.env.VITE_BYPASS_AUTH === "true") { + return { + user: { + id: "test-user", + email: "test@example.com", + name: "Test User" + } + }; + } + const session = await authClient.getSession(); if (!session?.data?.user) { throw redirect({ to: "/login" }); diff --git a/packages/web/src/routes/_authenticated/preview-studio.tsx b/packages/web/src/routes/_authenticated/preview-studio.tsx index 1c8c179..45a5c5b 100644 --- a/packages/web/src/routes/_authenticated/preview-studio.tsx +++ b/packages/web/src/routes/_authenticated/preview-studio.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useRef } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { PLACEMENT_SPECS, type PlacementSpecKey } from "../../../../shared/src/placementSpecs"; +import { PLACEMENT_SPECS, type PlacementSpecKey, type DeviceCategory } from "../../../../shared/src/placementSpecs"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { @@ -27,27 +27,64 @@ function PreviewStudioPage() { const [title, setTitle] = useState("Untitled Creative"); const [status, setStatus] = useState("draft"); const [selectedSpecKey, setSelectedSpecKey] = useState("story_9_16"); - const [selectedSpecs, setSelectedSpecs] = useState(["story_9_16"]); + const [selectedSpecs, setSelectedSpecs] = useState([]); const [showGrid, setShowGrid] = useState(true); const [showSafeZone, setShowSafeZone] = useState(true); const [backgroundType, setBackgroundType] = useState<"checkerboard" | "solid">("checkerboard"); - - // Export state + + // Custom size state + const [isCustomSize, setIsCustomSize] = useState(false); + const [customSize, setCustomSize] = useState({ width: 1080, height: 1080 }); + + // UI State const [showExportPanel, setShowExportPanel] = useState(false); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(0); + const [isSaving, setIsSaving] = useState(false); // Refs for export const previewContainerRef = useRef(null); - - // Get selected spec - const selectedSpec = useMemo( - () => PLACEMENT_SPECS.find((s) => s.key === selectedSpecKey)!, - [selectedSpecKey] - ); + const creativeIdRef = useRef(null); // Get all specs for tabs - const tabsSpecs = useMemo(() => PLACEMENT_SPECS.filter((s) => s.category === selectedSpec.category), [selectedSpec.category]); + const tabsSpecs = useMemo(() => { + const currentSpec = isCustomSize + ? { category: "mobile" as DeviceCategory } + : PLACEMENT_SPECS.find((s) => s.key === selectedSpecKey)!; + return PLACEMENT_SPECS.filter((s) => s.category === currentSpec.category); + }, [selectedSpecKey, isCustomSize]); + + // When switching category via sidebar, update preview to that spec + const handleSpecSelect = (key: PlacementSpecKey) => { + setSelectedSpecKey(key); + setIsCustomSize(false); + }; + + // Handle custom size + const handleCustomSize = (width: number, height: number) => { + setCustomSize({ width, height }); + setIsCustomSize(true); + setSelectedSpecKey("custom"); + }; + + // Get selected spec (handle custom size) + const selectedSpec = useMemo(() => { + if (isCustomSize) { + return { + key: "custom" as PlacementSpecKey, + label: `Custom ${customSize.width}×${customSize.height}`, + category: "mobile" as DeviceCategory, + width: customSize.width, + height: customSize.height, + aspectRatio: `${customSize.width}:${customSize.height}`, + icon: "custom_size", + shortLabel: "CUSTOM", + safeArea: { top: 64, right: 64, bottom: 64, left: 64 }, + rules: { minTitleFontSize: 44, minBodyFontSize: 28, maxTitleLines: 2, maxBodyLines: 4 } + }; + } + return PLACEMENT_SPECS.find((s) => s.key === selectedSpecKey)!; + }, [selectedSpecKey, isCustomSize, customSize]); // Get specs objects const selectedSpecObjects = useMemo( @@ -56,22 +93,98 @@ function PreviewStudioPage() { ); // Handlers - const handleSave = () => { - console.log("Saving creative:", { title, spec: selectedSpecKey }); - // TODO: API call to save - setStatus("draft"); - }; + const handleSave = async () => { + setIsSaving(true); + try { + // In bypass mode, just save locally + if (import.meta.env.VITE_BYPASS_AUTH === "true") { + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 800)); + setStatus("draft"); + console.log("Creative saved locally (bypass mode):", { title, spec: selectedSpecKey }); + alert("保存成功!"); + setIsSaving(false); + return; + } - const handlePublish = () => { - console.log("Publishing creative:", { title, spec: selectedSpecKey }); - // TODO: API call to publish - setStatus("published"); + // Normal API call + const response = await fetch("/api/creatives", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + specKey: selectedSpecKey, + content: { title, selectedSpecKey } + }) + }); + + if (response.ok) { + const data = await response.json(); + creativeIdRef.current = data.creative.id; + setStatus("draft"); + console.log("Creative saved:", data.creative); + alert("保存成功!"); + } else { + throw new Error("Failed to save"); + } + } catch (error) { + console.error("Save failed:", error); + alert("保存失败,请重试"); + } finally { + setIsSaving(false); + } }; - const handlePurchase = () => { - console.log("Purchasing creative:", { title, spec: selectedSpecKey }); - // TODO: API call to purchase - setStatus("purchased"); + const handlePublish = async () => { + setIsSaving(true); + try { + // In bypass mode, just update locally + if (import.meta.env.VITE_BYPASS_AUTH === "true") { + await new Promise(resolve => setTimeout(resolve, 800)); + setStatus("published"); + console.log("Creative published locally (bypass mode):", { title, spec: selectedSpecKey }); + alert("发布成功!"); + setIsSaving(false); + return; + } + + // Normal API call + if (creativeIdRef.current) { + await fetch(`/api/creatives/${creativeIdRef.current}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, specKey: selectedSpecKey }) + }); + + await fetch(`/api/creatives/${creativeIdRef.current}/publish`, { + method: "POST" + }); + } else { + const response = await fetch("/api/creatives", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + specKey: selectedSpecKey, + status: "published" + }) + }); + + if (response.ok) { + const data = await response.json(); + creativeIdRef.current = data.creative.id; + } + } + + setStatus("published"); + console.log("Creative published"); + alert("发布成功!"); + } catch (error) { + console.error("Publish failed:", error); + alert("发布失败,请重试"); + } finally { + setIsSaving(false); + } }; // Export handler @@ -81,15 +194,26 @@ function PreviewStudioPage() { setShowExportPanel(false); try { - // Get the preview container const container = previewContainerRef.current; if (!container) { throw new Error("Preview container not found"); } - // Get device frames within the container - const deviceFrames = container.querySelectorAll(".device-frame-export"); + // Get content from preview + const originalContent = container.querySelector(".preview-content") as HTMLElement; + if (!originalContent) { + throw new Error("Preview content not found"); + } + + // Extract actual text content + const titleText = originalContent.querySelector("h3")?.textContent || "Elevate Your Creative Workflow."; + const descText = originalContent.querySelector("p")?.textContent || "Generate stunning NFT assets in seconds with AI-powered optimization."; + const btnText = originalContent.querySelector("button")?.textContent || "MINT NOW"; + // Get background image + const previewStyle = window.getComputedStyle(originalContent); + const bgImage = previewStyle.backgroundImage; + const downloadedFiles: string[] = []; for (let i = 0; i < selectedSpecObjects.length; i++) { @@ -97,54 +221,151 @@ function PreviewStudioPage() { setExportProgress(i + 1); try { - // Create a temporary container for this export - const tempContainer = document.createElement("div"); - tempContainer.style.width = `${spec.width}px`; - tempContainer.style.height = `${spec.height}px`; - tempContainer.style.position = "fixed"; - tempContainer.style.left = "-9999px"; - tempContainer.style.top = "0"; - document.body.appendChild(tempContainer); - - // Clone the preview content - const originalContent = container.querySelector(".preview-content"); - if (originalContent) { - const clonedContent = originalContent.cloneNode(true) as HTMLElement; - - // Apply background - if (options.backgroundColor) { - clonedContent.style.backgroundColor = options.backgroundColor; - clonedContent.style.backgroundImage = "none"; - } - - tempContainer.appendChild(clonedContent); - - // Generate canvas - const canvas = await html2canvas(tempContainer, { - width: spec.width, - height: spec.height, - scale: options.scale, - backgroundColor: options.backgroundColor, - logging: false, - }); - - // Download file - const link = document.createElement("a"); - link.download = `${title.replace(/\s+/g, "_")}_${spec.key}_${spec.width}x${spec.height}.${options.format}`; - link.href = canvas.toDataURL(`image/${options.format}`, options.quality); - link.click(); - - downloadedFiles.push(link.download); - } - - // Clean up - document.body.removeChild(tempContainer); + // Create container with precise inline styles matching preview + const containerEl = document.createElement("div"); + containerEl.style.cssText = ` + position: fixed; + left: -9999px; + top: 0; + width: ${spec.width}px; + height: ${spec.height}px; + background-image: ${bgImage}; + background-size: cover; + background-position: center; + overflow: hidden; + `; + + // Inner wrapper - exact styles from preview + const innerEl = document.createElement("div"); + innerEl.style.cssText = ` + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: ${spec.safeArea.top}px ${spec.safeArea.right}px ${spec.safeArea.bottom}px ${spec.safeArea.left}px; + `; + + // Top bar + const topBar = document.createElement("div"); + topBar.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: flex-start; + `; + + // LIVE AD badge + const liveBadge = document.createElement("span"); + liveBadge.textContent = "LIVE AD"; + liveBadge.style.cssText = ` + color: white; + background: #6366f1; + padding: 4px 8px; + border-radius: 4px; + font-family: 'Space Grotesk', Arial, sans-serif; + font-size: 10px; + font-weight: 700; + line-height: 1; + display: inline-block; + margin: 4px 0 0 4px; + `; + + // More icon (using unicode instead of material icons) + const moreIcon = document.createElement("span"); + moreIcon.textContent = "⋮"; + moreIcon.style.cssText = ` + color: white; + font-size: 20px; + font-family: Arial, sans-serif; + line-height: 1; + `; + + topBar.appendChild(liveBadge); + topBar.appendChild(moreIcon); + innerEl.appendChild(topBar); + + // Bottom content + const bottomContent = document.createElement("div"); + bottomContent.style.cssText = ` + display: flex; + flex-direction: column; + gap: 12px; + `; + + // Title + const titleEl = document.createElement("h3"); + titleEl.textContent = titleText; + titleEl.style.cssText = ` + color: white; + font-family: 'Space Grotesk', Arial, sans-serif; + font-size: ${spec.rules.minTitleFontSize}px; + font-weight: 900; + line-height: 1.2; + margin: 0; + `; + + // Description + const descEl = document.createElement("p"); + descEl.textContent = descText; + descEl.style.cssText = ` + color: #cbd5e1; + font-family: 'Space Grotesk', Arial, sans-serif; + font-size: ${spec.rules.minBodyFontSize}px; + font-weight: 400; + line-height: 1.4; + margin: 0; + `; + + // Button + const btnEl = document.createElement("button"); + btnEl.textContent = btnText; + btnEl.style.cssText = ` + width: 100%; + background: white; + color: black; + font-family: 'Space Grotesk', Arial, sans-serif; + font-size: 16px; + font-weight: 700; + padding: 12px 24px; + border-radius: 12px; + border: none; + cursor: pointer; + margin-top: 8px; + `; + + bottomContent.appendChild(titleEl); + bottomContent.appendChild(descEl); + bottomContent.appendChild(btnEl); + innerEl.appendChild(bottomContent); + + containerEl.appendChild(innerEl); + document.body.appendChild(containerEl); + + // Generate canvas + const canvas = await html2canvas(containerEl, { + width: spec.width, + height: spec.height, + scale: options.scale, + backgroundColor: null, + logging: false, + useCORS: true, + }); + + // Download + const link = document.createElement("a"); + link.download = `${title.replace(/\s+/g, "_")}_${spec.key}_${spec.width}x${spec.height}.${options.format}`; + link.href = canvas.toDataURL(`image/${options.format}`, options.quality); + link.click(); + + downloadedFiles.push(link.download); + + // Cleanup + document.body.removeChild(containerEl); } catch (err) { console.error(`Failed to export ${spec.key}:`, err); } } - // Show success message alert(`成功导出 ${downloadedFiles.length} 个文件!\n\n${downloadedFiles.join("\n")}`); } catch (error) { console.error("Export failed:", error); @@ -200,9 +421,8 @@ function PreviewStudioPage() { status={status} onSave={handleSave} onPublish={status === "draft" ? handlePublish : undefined} - onPurchase={status === "draft" ? handlePurchase : undefined} canPublish={status === "draft"} - canPurchase={status === "draft"} + isSaving={isSaving} />
@@ -210,9 +430,10 @@ function PreviewStudioPage() { @@ -252,12 +473,6 @@ function PreviewStudioPage() { download Export All ({selectedSpecs.length}) - - {/* AI Auto-Fix Button */} -
@@ -265,7 +480,8 @@ function PreviewStudioPage() { {/* Preview Canvas */} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 2e77541..a9723f7 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ port: 5173, proxy: { "/api": { - target: "http://localhost:3000", + target: "http://localhost:8787", changeOrigin: true } } From e6ec34941950700260ed08b1cd15de5a5199ebe4 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 9 Feb 2026 06:44:26 +0000 Subject: [PATCH 3/3] feat: Preview Studio multi-platform multi-size preview (#22) - Add Preview Studio page with device size picker (Mobile/Web/TV) - Mobile category shows device models: iPhone 14 Pro, Galaxy S23, iPad Air - Support custom size with export functionality - Preview canvas with zoom controls, grid overlay, safe zones - Batch export (PNG/JPG/WebP) support - Remove AI Auto-Fix feature - Separate layout without global TopNav - Back button and user avatar in header --- packages/shared/src/placementSpecs.ts | 46 +- .../f6765f25-37d56229f59e0cabf0ddefd7a2c2f1aa | 578 ++++++++++++++++++ .../layout/preview-studio-layout.tsx | 15 + .../web/src/components/layout/top-nav.tsx | 2 +- .../components/preview/device-size-picker.tsx | 8 +- .../src/components/preview/preview-canvas.tsx | 34 +- .../components/preview/preview-toolbar.tsx | 44 +- packages/web/src/routeTree.gen.ts | 68 ++- packages/web/src/routes/_preview.tsx | 17 + .../preview-studio.tsx | 116 +++- 10 files changed, 812 insertions(+), 116 deletions(-) create mode 100644 packages/web/.tanstack/tmp/f6765f25-37d56229f59e0cabf0ddefd7a2c2f1aa create mode 100644 packages/web/src/components/layout/preview-studio-layout.tsx create mode 100644 packages/web/src/routes/_preview.tsx rename packages/web/src/routes/{_authenticated => _preview}/preview-studio.tsx (78%) diff --git a/packages/shared/src/placementSpecs.ts b/packages/shared/src/placementSpecs.ts index fed2fcd..37ed946 100644 --- a/packages/shared/src/placementSpecs.ts +++ b/packages/shared/src/placementSpecs.ts @@ -1,5 +1,7 @@ export type PlacementSpecKey = - | "square_1_1" + | "iphone_14_pro" + | "galaxy_s23" + | "ipad_air" | "feed_4_5" | "story_9_16" | "landscape_16_9" @@ -31,16 +33,40 @@ export type PlacementSpec = { // MVP fixed specs export const PLACEMENT_SPECS: PlacementSpec[] = [ { - key: "square_1_1", - label: "Square 1:1 (1080×1080)", + key: "iphone_14_pro", + label: "iPhone 14 Pro", + category: "mobile", + width: 1179, + height: 2556, + aspectRatio: "9:19.5", + icon: "smartphone", + shortLabel: "iPhone 14 Pro", + safeArea: { top: 120, right: 80, bottom: 220, left: 80 }, + rules: { minTitleFontSize: 52, minBodyFontSize: 30, maxTitleLines: 3, maxBodyLines: 4 } + }, + { + key: "galaxy_s23", + label: "Galaxy S23", category: "mobile", width: 1080, - height: 1080, - aspectRatio: "1:1", - icon: "square", - shortLabel: "1:1", - safeArea: { top: 64, right: 64, bottom: 64, left: 64 }, - rules: { minTitleFontSize: 44, minBodyFontSize: 28, maxTitleLines: 2, maxBodyLines: 4 } + height: 2340, + aspectRatio: "9:19.5", + icon: "smartphone", + shortLabel: "Galaxy S23", + safeArea: { top: 100, right: 60, bottom: 180, left: 60 }, + rules: { minTitleFontSize: 48, minBodyFontSize: 28, maxTitleLines: 3, maxBodyLines: 4 } + }, + { + key: "ipad_air", + label: "iPad Air", + category: "mobile", + width: 1640, + height: 2360, + aspectRatio: "3:4", + icon: "tablet", + shortLabel: "iPad Air", + safeArea: { top: 80, right: 60, bottom: 100, left: 60 }, + rules: { minTitleFontSize: 56, minBodyFontSize: 32, maxTitleLines: 3, maxBodyLines: 4 } }, { key: "feed_4_5", @@ -63,7 +89,6 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ aspectRatio: "9:16", icon: "phone_iphone", shortLabel: "9:16", - // extra bottom safe-area for UI overlays safeArea: { top: 120, right: 80, bottom: 220, left: 80 }, rules: { minTitleFontSize: 52, minBodyFontSize: 30, maxTitleLines: 3, maxBodyLines: 4 } }, @@ -100,7 +125,6 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ aspectRatio: "16:9", icon: "tv", shortLabel: "4K", - // TV overscan-ish margins safeArea: { top: 160, right: 200, bottom: 160, left: 200 }, rules: { minTitleFontSize: 96, minBodyFontSize: 56, maxTitleLines: 2, maxBodyLines: 3 } } diff --git a/packages/web/.tanstack/tmp/f6765f25-37d56229f59e0cabf0ddefd7a2c2f1aa b/packages/web/.tanstack/tmp/f6765f25-37d56229f59e0cabf0ddefd7a2c2f1aa new file mode 100644 index 0000000..0c3be8a --- /dev/null +++ b/packages/web/.tanstack/tmp/f6765f25-37d56229f59e0cabf0ddefd7a2c2f1aa @@ -0,0 +1,578 @@ +import { useState, useMemo, useRef } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { PLACEMENT_SPECS, type PlacementSpecKey, type DeviceCategory } from "../../../../shared/src/placementSpecs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + DeviceSizePicker, + PreviewToolbar, + PreviewCanvas, + DeviceTabs, + ExportPanel, +} from "@/components/preview"; +import html2canvas from "html2canvas"; +import { useCurrentUser } from "@/hooks/use-current-user"; + +type CreativeStatus = "draft" | "published" | "purchased"; +type ExportFormat = "png" | "jpg" | "webp"; + +type ExportOptions = { + format: ExportFormat; + quality: number; + scale: number; + backgroundColor: string | null; +}; + +function PreviewStudioPage() { + // User info + const { data: user } = useCurrentUser(); + + // State + const [title, setTitle] = useState("Untitled Creative"); + const [status, setStatus] = useState("draft"); + const [selectedSpecKey, setSelectedSpecKey] = useState("story_9_16"); + const [selectedSpecs, setSelectedSpecs] = useState([]); + const [showGrid, setShowGrid] = useState(true); + const [showSafeZone, setShowSafeZone] = useState(true); + const [backgroundType, setBackgroundType] = useState<"checkerboard" | "solid">("checkerboard"); + + // Custom size state + const [isCustomSize, setIsCustomSize] = useState(false); + const [customSize, setCustomSize] = useState({ width: 1080, height: 1080 }); + + // UI State + const [showExportPanel, setShowExportPanel] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(0); + const [isSaving, setIsSaving] = useState(false); + + // Refs for export + const previewContainerRef = useRef(null); + const creativeIdRef = useRef(null); + + // Get all specs for tabs + const tabsSpecs = useMemo(() => { + const currentSpec = isCustomSize + ? { category: "mobile" as DeviceCategory } + : PLACEMENT_SPECS.find((s) => s.key === selectedSpecKey)!; + return PLACEMENT_SPECS.filter((s) => s.category === currentSpec.category); + }, [selectedSpecKey, isCustomSize]); + + // When switching category via sidebar, update preview to that spec + const handleSpecSelect = (key: PlacementSpecKey) => { + setSelectedSpecKey(key); + setIsCustomSize(false); + }; + + // Handle custom size + const handleCustomSize = (width: number, height: number) => { + setCustomSize({ width, height }); + setIsCustomSize(true); + setSelectedSpecKey("custom"); + }; + + // Get selected spec (handle custom size) + const selectedSpec = useMemo(() => { + if (isCustomSize) { + return { + key: "custom" as PlacementSpecKey, + label: `Custom ${customSize.width}×${customSize.height}`, + category: "mobile" as DeviceCategory, + width: customSize.width, + height: customSize.height, + aspectRatio: `${customSize.width}:${customSize.height}`, + icon: "custom_size", + shortLabel: "CUSTOM", + safeArea: { top: 64, right: 64, bottom: 64, left: 64 }, + rules: { minTitleFontSize: 44, minBodyFontSize: 28, maxTitleLines: 2, maxBodyLines: 4 } + }; + } + return PLACEMENT_SPECS.find((s) => s.key === selectedSpecKey)!; + }, [selectedSpecKey, isCustomSize, customSize]); + + // Get specs objects + const selectedSpecObjects = useMemo( + () => PLACEMENT_SPECS.filter((s) => selectedSpecs.includes(s.key)), + [selectedSpecs] + ); + + // Handlers + const handleSave = async () => { + setIsSaving(true); + try { + // In bypass mode, just save locally + if (import.meta.env.VITE_BYPASS_AUTH === "true") { + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 800)); + setStatus("draft"); + console.log("Creative saved locally (bypass mode):", { title, spec: selectedSpecKey }); + alert("保存成功!"); + setIsSaving(false); + return; + } + + // Normal API call + const response = await fetch("/api/creatives", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + specKey: selectedSpecKey, + content: { title, selectedSpecKey } + }) + }); + + if (response.ok) { + const data = await response.json(); + creativeIdRef.current = data.creative.id; + setStatus("draft"); + console.log("Creative saved:", data.creative); + alert("保存成功!"); + } else { + throw new Error("Failed to save"); + } + } catch (error) { + console.error("Save failed:", error); + alert("保存失败,请重试"); + } finally { + setIsSaving(false); + } + }; + + const handlePublish = async () => { + setIsSaving(true); + try { + // In bypass mode, just update locally + if (import.meta.env.VITE_BYPASS_AUTH === "true") { + await new Promise(resolve => setTimeout(resolve, 800)); + setStatus("published"); + console.log("Creative published locally (bypass mode):", { title, spec: selectedSpecKey }); + alert("发布成功!"); + setIsSaving(false); + return; + } + + // Normal API call + if (creativeIdRef.current) { + await fetch(`/api/creatives/${creativeIdRef.current}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, specKey: selectedSpecKey }) + }); + + await fetch(`/api/creatives/${creativeIdRef.current}/publish`, { + method: "POST" + }); + } else { + const response = await fetch("/api/creatives", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + specKey: selectedSpecKey, + status: "published" + }) + }); + + if (response.ok) { + const data = await response.json(); + creativeIdRef.current = data.creative.id; + } + } + + setStatus("published"); + console.log("Creative published"); + alert("发布成功!"); + } catch (error) { + console.error("Publish failed:", error); + alert("发布失败,请重试"); + } finally { + setIsSaving(false); + } + }; + + // Export handler + const handleExport = async (options: ExportOptions) => { + setIsExporting(true); + setExportProgress(0); + setShowExportPanel(false); + + try { + const container = previewContainerRef.current; + if (!container) { + throw new Error("Preview container not found"); + } + + // Get content from preview + const originalContent = container.querySelector(".preview-content") as HTMLElement; + if (!originalContent) { + throw new Error("Preview content not found"); + } + + // Extract actual text content + const titleText = originalContent.querySelector("h3")?.textContent || "Elevate Your Creative Workflow."; + const descText = originalContent.querySelector("p")?.textContent || "Generate stunning NFT assets in seconds with AI-powered optimization."; + const btnText = originalContent.querySelector("button")?.textContent || "MINT NOW"; + + // Get background image + const previewStyle = window.getComputedStyle(originalContent); + const bgImage = previewStyle.backgroundImage; + + const downloadedFiles: string[] = []; + + for (let i = 0; i < selectedSpecObjects.length; i++) { + const spec = selectedSpecObjects[i]; + setExportProgress(i + 1); + + try { + // Create container with precise inline styles matching preview + const containerEl = document.createElement("div"); + containerEl.style.cssText = ` + position: fixed; + left: -9999px; + top: 0; + width: ${spec.width}px; + height: ${spec.height}px; + background-image: ${bgImage}; + background-size: cover; + background-position: center; + overflow: hidden; + `; + + // Inner wrapper - exact styles from preview + const innerEl = document.createElement("div"); + innerEl.style.cssText = ` + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: ${spec.safeArea.top}px ${spec.safeArea.right}px ${spec.safeArea.bottom}px ${spec.safeArea.left}px; + `; + + // Top bar + const topBar = document.createElement("div"); + topBar.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: flex-start; + `; + + // LIVE AD badge + const liveBadge = document.createElement("span"); + liveBadge.textContent = "LIVE AD"; + liveBadge.style.cssText = ` + color: white; + background: #6366f1; + padding: 4px 8px; + border-radius: 4px; + font-family: 'Space Grotesk', Arial, sans-serif; + font-size: 10px; + font-weight: 700; + line-height: 1; + display: inline-block; + margin: 4px 0 0 4px; + `; + + // More icon (using unicode instead of material icons) + const moreIcon = document.createElement("span"); + moreIcon.textContent = "⋮"; + moreIcon.style.cssText = ` + color: white; + font-size: 20px; + font-family: Arial, sans-serif; + line-height: 1; + `; + + topBar.appendChild(liveBadge); + topBar.appendChild(moreIcon); + innerEl.appendChild(topBar); + + // Bottom content + const bottomContent = document.createElement("div"); + bottomContent.style.cssText = ` + display: flex; + flex-direction: column; + gap: 12px; + `; + + // Title + const titleEl = document.createElement("h3"); + titleEl.textContent = titleText; + titleEl.style.cssText = ` + color: white; + font-family: 'Space Grotesk', Arial, sans-serif; + font-size: ${spec.rules.minTitleFontSize}px; + font-weight: 900; + line-height: 1.2; + margin: 0; + `; + + // Description + const descEl = document.createElement("p"); + descEl.textContent = descText; + descEl.style.cssText = ` + color: #cbd5e1; + font-family: 'Space Grotesk', Arial, sans-serif; + font-size: ${spec.rules.minBodyFontSize}px; + font-weight: 400; + line-height: 1.4; + margin: 0; + `; + + // Button + const btnEl = document.createElement("button"); + btnEl.textContent = btnText; + btnEl.style.cssText = ` + width: 100%; + background: white; + color: black; + font-family: 'Space Grotesk', Arial, sans-serif; + font-size: 16px; + font-weight: 700; + padding: 12px 24px; + border-radius: 12px; + border: none; + cursor: pointer; + margin-top: 8px; + `; + + bottomContent.appendChild(titleEl); + bottomContent.appendChild(descEl); + bottomContent.appendChild(btnEl); + innerEl.appendChild(bottomContent); + + containerEl.appendChild(innerEl); + document.body.appendChild(containerEl); + + // Generate canvas + const canvas = await html2canvas(containerEl, { + width: spec.width, + height: spec.height, + scale: options.scale, + backgroundColor: null, + logging: false, + useCORS: true, + }); + + // Download + const link = document.createElement("a"); + link.download = `${title.replace(/\s+/g, "_")}_${spec.key}_${spec.width}x${spec.height}.${options.format}`; + link.href = canvas.toDataURL(`image/${options.format}`, options.quality); + link.click(); + + downloadedFiles.push(link.download); + + // Cleanup + document.body.removeChild(containerEl); + } catch (err) { + console.error(`Failed to export ${spec.key}:`, err); + } + } + + alert(`成功导出 ${downloadedFiles.length} 个文件!\n\n${downloadedFiles.join("\n")}`); + } catch (error) { + console.error("Export failed:", error); + alert("导出失败,请重试"); + } finally { + setIsExporting(false); + setExportProgress(0); + } + }; + + const handleCancelExport = () => { + setIsExporting(false); + setExportProgress(0); + }; + + // Mock preview content + const PreviewContent = ( +
+
+
+ LIVE AD + more_vert +
+
+

+ Elevate Your Creative Workflow. +

+

+ Generate stunning NFT assets in seconds with AI-powered optimization. +

+ +
+
+
+ ); + + return ( +
+ {/* Page Header with Logo, Title and User */} +
+
+ {/* Back Button */} + + + {/* Custom Icon */} +
+ + + + +
+

Preview Studio

+
+ + {/* User Avatar */} + {user?.image ? ( +
+ ) : ( +
+ {user?.name?.charAt(0) || "U"} +
+ )} +
+ + {/* Page Description */} +
+

+ Preview your creative across multiple platforms and sizes +

+
+ + {/* Top Toolbar */} + + +
+ {/* Left Sidebar - Device & Size Picker */} + + + {/* Main Content */} +
+ {/* Top Controls Bar */} +
+ {/* Left Controls */} +
+ + +
+
+ + {/* Right Controls */} +
+ {/* Batch Export Button */} + +
+
+ + {/* Device Tabs */} + + + {/* Preview Canvas */} +
+ + {PreviewContent} + +
+ + {/* Export Panel Modal */} + {showExportPanel && ( +
+ setShowExportPanel(false)} + className="w-96" + /> +
+ )} + + {/* Export Progress Overlay */} + {isExporting && ( +
+ {}} + onCancel={handleCancelExport} + className="w-80" + /> +
+ )} +
+
+
+ ); +} + +export const Route = createFileRoute("/_preview/preview-studio")({ + component: PreviewStudioPage, +}); diff --git a/packages/web/src/components/layout/preview-studio-layout.tsx b/packages/web/src/components/layout/preview-studio-layout.tsx new file mode 100644 index 0000000..871f9a8 --- /dev/null +++ b/packages/web/src/components/layout/preview-studio-layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; + +type PreviewStudioLayoutProps = { + children: ReactNode; +}; + +export function PreviewStudioLayout({ children }: PreviewStudioLayoutProps) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/web/src/components/layout/top-nav.tsx b/packages/web/src/components/layout/top-nav.tsx index fd59049..8c69939 100644 --- a/packages/web/src/components/layout/top-nav.tsx +++ b/packages/web/src/components/layout/top-nav.tsx @@ -42,7 +42,7 @@ export function TopNav({ user }: TopNavProps) {

- Preview Studio + Creative Store

diff --git a/packages/web/src/components/preview/device-size-picker.tsx b/packages/web/src/components/preview/device-size-picker.tsx index 1dcb3be..4db5120 100644 --- a/packages/web/src/components/preview/device-size-picker.tsx +++ b/packages/web/src/components/preview/device-size-picker.tsx @@ -31,7 +31,11 @@ export function DeviceSizePicker({ const handleCategoryClick = (category: DeviceCategory) => { setActiveCategory(category); - // Don't auto-select, let user choose + // Auto-select the first spec in this category for preview + const firstSpec = PLACEMENT_SPECS.find((s) => s.category === category); + if (firstSpec) { + onSpecSelect(firstSpec.key); + } }; const handleToggle = (key: PlacementSpecKey) => { @@ -47,7 +51,7 @@ export function DeviceSizePicker({ }; return ( -
+
{/* Device Category Tabs */}
diff --git a/packages/web/src/components/preview/preview-canvas.tsx b/packages/web/src/components/preview/preview-canvas.tsx index fabcfe3..5300ab9 100644 --- a/packages/web/src/components/preview/preview-canvas.tsx +++ b/packages/web/src/components/preview/preview-canvas.tsx @@ -23,22 +23,23 @@ export function PreviewCanvas({ className, }: PreviewCanvasProps) { const containerRef = useRef(null); - const [zoom, setZoom] = useState<"fit" | number>("fit"); + // Default to 0.25 (25%) when container is not ready + const [zoom, setZoom] = useState<"fit" | number>(0.25); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); // Calculate zoom to fit const calculateFitZoom = useCallback(() => { - if (!containerRef.current) return 0.5; + if (!containerRef.current) return 0.25; const container = containerRef.current; - const padding = 96; // 24px padding on each side + const padding = 48; // 12px padding on each side const availableWidth = container.clientWidth - padding; const availableHeight = container.clientHeight - padding; const scaleX = availableWidth / spec.width; const scaleY = availableHeight / spec.height; - return Math.min(scaleX, scaleY, 1); // Max zoom 100% + return Math.min(scaleX, scaleY, 0.5); // Max zoom 50% }, [spec.height, spec.width]); // Update container size for zoom calculations @@ -57,16 +58,11 @@ export function PreviewCanvas({ return () => window.removeEventListener("resize", updateSize); }, []); - // Set initial fit zoom - useEffect(() => { - setZoom("fit"); - }, [spec.key]); - const actualZoom = zoom === "fit" ? calculateFitZoom() : zoom; const handleZoomIn = () => { const currentZoom = zoom === "fit" ? calculateFitZoom() : zoom; - const newZoom = Math.min(currentZoom + 0.25, 2); + const newZoom = Math.min(currentZoom + 0.25, 1); setZoom(newZoom); }; @@ -84,29 +80,13 @@ export function PreviewCanvas({
- {/* Background Toggle */} -
- -
- {/* Device Frame with Preview */} {children} diff --git a/packages/web/src/components/preview/preview-toolbar.tsx b/packages/web/src/components/preview/preview-toolbar.tsx index 3fcb1f1..70bd374 100644 --- a/packages/web/src/components/preview/preview-toolbar.tsx +++ b/packages/web/src/components/preview/preview-toolbar.tsx @@ -1,7 +1,5 @@ import { useNavigate } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; export type CreativeStatus = "draft" | "published"; @@ -37,44 +35,30 @@ export function PreviewToolbar({ isSaving = false, className, }: PreviewToolbarProps) { - const navigate = useNavigate(); - return (
{/* Left Section */} -
- {/* Back Button */} - - -
- +
{/* Title Input */} -
- onTitleChange(e.target.value)} - className="bg-transparent text-lg font-bold leading-tight tracking-tight focus:outline-none focus:border-b-2 focus:border-primary px-1" - placeholder="Untitled Creative" - /> -
+ onTitleChange(e.target.value)} + className="bg-transparent text-base font-bold focus:outline-none focus:border-b-2 focus:border-primary px-1 min-w-[200px]" + placeholder="Untitled Creative" + /> - {/* Version Badge */} - - {version} - +
{/* Status Badge */} @@ -83,15 +67,15 @@ export function PreviewToolbar({
{/* Right Section */} -
+
{/* Actions */} - {canPublish && onPublish && ( - diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 858e0ec..ff680d1 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -10,11 +10,12 @@ import { Route as rootRouteImport } from "./routes/__root" import { Route as LoginRouteImport } from "./routes/login" +import { Route as PreviewRouteImport } from "./routes/_preview" import { Route as AuthenticatedRouteImport } from "./routes/_authenticated" import { Route as IndexRouteImport } from "./routes/index" +import { Route as PreviewPreviewStudioRouteImport } from "./routes/_preview/preview-studio" import { Route as AuthenticatedWalletRouteImport } from "./routes/_authenticated/wallet" import { Route as AuthenticatedProjectsRouteImport } from "./routes/_authenticated/projects" -import { Route as AuthenticatedPreviewStudioRouteImport } from "./routes/_authenticated/preview-studio" import { Route as AuthenticatedMarketRouteImport } from "./routes/_authenticated/market" import { Route as AuthenticatedDashboardRouteImport } from "./routes/_authenticated/dashboard" import { Route as AuthenticatedCreativeStudioRouteImport } from "./routes/_authenticated/creative-studio" @@ -25,6 +26,10 @@ const LoginRoute = LoginRouteImport.update({ path: "/login", getParentRoute: () => rootRouteImport, } as any) +const PreviewRoute = PreviewRouteImport.update({ + id: "/_preview", + getParentRoute: () => rootRouteImport, +} as any) const AuthenticatedRoute = AuthenticatedRouteImport.update({ id: "/_authenticated", getParentRoute: () => rootRouteImport, @@ -34,6 +39,11 @@ const IndexRoute = IndexRouteImport.update({ path: "/", getParentRoute: () => rootRouteImport, } as any) +const PreviewPreviewStudioRoute = PreviewPreviewStudioRouteImport.update({ + id: "/preview-studio", + path: "/preview-studio", + getParentRoute: () => PreviewRoute, +} as any) const AuthenticatedWalletRoute = AuthenticatedWalletRouteImport.update({ id: "/wallet", path: "/wallet", @@ -44,12 +54,6 @@ const AuthenticatedProjectsRoute = AuthenticatedProjectsRouteImport.update({ path: "/projects", getParentRoute: () => AuthenticatedRoute, } as any) -const AuthenticatedPreviewStudioRoute = - AuthenticatedPreviewStudioRouteImport.update({ - id: "/preview-studio", - path: "/preview-studio", - getParentRoute: () => AuthenticatedRoute, - } as any) const AuthenticatedMarketRoute = AuthenticatedMarketRouteImport.update({ id: "/market", path: "/market", @@ -78,9 +82,9 @@ export interface FileRoutesByFullPath { "/creative-studio": typeof AuthenticatedCreativeStudioRoute "/dashboard": typeof AuthenticatedDashboardRoute "/market": typeof AuthenticatedMarketRoute - "/preview-studio": typeof AuthenticatedPreviewStudioRoute "/projects": typeof AuthenticatedProjectsRoute "/wallet": typeof AuthenticatedWalletRoute + "/preview-studio": typeof PreviewPreviewStudioRoute "/market/$id": typeof AuthenticatedMarketIdRoute } export interface FileRoutesByTo { @@ -89,22 +93,23 @@ export interface FileRoutesByTo { "/creative-studio": typeof AuthenticatedCreativeStudioRoute "/dashboard": typeof AuthenticatedDashboardRoute "/market": typeof AuthenticatedMarketRoute - "/preview-studio": typeof AuthenticatedPreviewStudioRoute "/projects": typeof AuthenticatedProjectsRoute "/wallet": typeof AuthenticatedWalletRoute + "/preview-studio": typeof PreviewPreviewStudioRoute "/market/$id": typeof AuthenticatedMarketIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport "/": typeof IndexRoute "/_authenticated": typeof AuthenticatedRouteWithChildren + "/_preview": typeof PreviewRouteWithChildren "/login": typeof LoginRoute "/_authenticated/creative-studio": typeof AuthenticatedCreativeStudioRoute "/_authenticated/dashboard": typeof AuthenticatedDashboardRoute "/_authenticated/market": typeof AuthenticatedMarketRoute - "/_authenticated/preview-studio": typeof AuthenticatedPreviewStudioRoute "/_authenticated/projects": typeof AuthenticatedProjectsRoute "/_authenticated/wallet": typeof AuthenticatedWalletRoute + "/_preview/preview-studio": typeof PreviewPreviewStudioRoute "/_authenticated/market_/$id": typeof AuthenticatedMarketIdRoute } export interface FileRouteTypes { @@ -115,9 +120,9 @@ export interface FileRouteTypes { | "/creative-studio" | "/dashboard" | "/market" - | "/preview-studio" | "/projects" | "/wallet" + | "/preview-studio" | "/market/$id" fileRoutesByTo: FileRoutesByTo to: @@ -126,27 +131,29 @@ export interface FileRouteTypes { | "/creative-studio" | "/dashboard" | "/market" - | "/preview-studio" | "/projects" | "/wallet" + | "/preview-studio" | "/market/$id" id: | "__root__" | "/" | "/_authenticated" + | "/_preview" | "/login" | "/_authenticated/creative-studio" | "/_authenticated/dashboard" | "/_authenticated/market" - | "/_authenticated/preview-studio" | "/_authenticated/projects" | "/_authenticated/wallet" + | "/_preview/preview-studio" | "/_authenticated/market_/$id" fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuthenticatedRoute: typeof AuthenticatedRouteWithChildren + PreviewRoute: typeof PreviewRouteWithChildren LoginRoute: typeof LoginRoute } @@ -159,6 +166,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + "/_preview": { + id: "/_preview" + path: "" + fullPath: "/" + preLoaderRoute: typeof PreviewRouteImport + parentRoute: typeof rootRouteImport + } "/_authenticated": { id: "/_authenticated" path: "" @@ -173,6 +187,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + "/_preview/preview-studio": { + id: "/_preview/preview-studio" + path: "/preview-studio" + fullPath: "/preview-studio" + preLoaderRoute: typeof PreviewPreviewStudioRouteImport + parentRoute: typeof PreviewRoute + } "/_authenticated/wallet": { id: "/_authenticated/wallet" path: "/wallet" @@ -187,13 +208,6 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof AuthenticatedProjectsRouteImport parentRoute: typeof AuthenticatedRoute } - "/_authenticated/preview-studio": { - id: "/_authenticated/preview-studio" - path: "/preview-studio" - fullPath: "/preview-studio" - preLoaderRoute: typeof AuthenticatedPreviewStudioRouteImport - parentRoute: typeof AuthenticatedRoute - } "/_authenticated/market": { id: "/_authenticated/market" path: "/market" @@ -229,7 +243,6 @@ interface AuthenticatedRouteChildren { AuthenticatedCreativeStudioRoute: typeof AuthenticatedCreativeStudioRoute AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute AuthenticatedMarketRoute: typeof AuthenticatedMarketRoute - AuthenticatedPreviewStudioRoute: typeof AuthenticatedPreviewStudioRoute AuthenticatedProjectsRoute: typeof AuthenticatedProjectsRoute AuthenticatedWalletRoute: typeof AuthenticatedWalletRoute AuthenticatedMarketIdRoute: typeof AuthenticatedMarketIdRoute @@ -239,7 +252,6 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedCreativeStudioRoute: AuthenticatedCreativeStudioRoute, AuthenticatedDashboardRoute: AuthenticatedDashboardRoute, AuthenticatedMarketRoute: AuthenticatedMarketRoute, - AuthenticatedPreviewStudioRoute: AuthenticatedPreviewStudioRoute, AuthenticatedProjectsRoute: AuthenticatedProjectsRoute, AuthenticatedWalletRoute: AuthenticatedWalletRoute, AuthenticatedMarketIdRoute: AuthenticatedMarketIdRoute, @@ -249,9 +261,21 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( AuthenticatedRouteChildren, ) +interface PreviewRouteChildren { + PreviewPreviewStudioRoute: typeof PreviewPreviewStudioRoute +} + +const PreviewRouteChildren: PreviewRouteChildren = { + PreviewPreviewStudioRoute: PreviewPreviewStudioRoute, +} + +const PreviewRouteWithChildren = + PreviewRoute._addFileChildren(PreviewRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuthenticatedRoute: AuthenticatedRouteWithChildren, + PreviewRoute: PreviewRouteWithChildren, LoginRoute: LoginRoute, } export const routeTree = rootRouteImport diff --git a/packages/web/src/routes/_preview.tsx b/packages/web/src/routes/_preview.tsx new file mode 100644 index 0000000..4d87274 --- /dev/null +++ b/packages/web/src/routes/_preview.tsx @@ -0,0 +1,17 @@ +import { createFileRoute, redirect, Outlet } from "@tanstack/react-router"; +import { useCurrentUser } from "@/hooks/use-current-user"; + +export const Route = createFileRoute("/_preview")({ + beforeLoad: async () => { + // DEV MODE BYPASS + if (import.meta.env.VITE_BYPASS_AUTH !== "true") { + const { data: session } = await import("@/lib/auth-client").then(m => m.authClient).then(cb => cb.getSession()); + if (!session?.data?.user) { + throw redirect({ to: "/login" }); + } + } + }, + component: () => ( + + ) +}); diff --git a/packages/web/src/routes/_authenticated/preview-studio.tsx b/packages/web/src/routes/_preview/preview-studio.tsx similarity index 78% rename from packages/web/src/routes/_authenticated/preview-studio.tsx rename to packages/web/src/routes/_preview/preview-studio.tsx index 45a5c5b..ba6ae26 100644 --- a/packages/web/src/routes/_authenticated/preview-studio.tsx +++ b/packages/web/src/routes/_preview/preview-studio.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef } from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { PLACEMENT_SPECS, type PlacementSpecKey, type DeviceCategory } from "../../../../shared/src/placementSpecs"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -11,6 +11,7 @@ import { ExportPanel, } from "@/components/preview"; import html2canvas from "html2canvas"; +import { useCurrentUser } from "@/hooks/use-current-user"; type CreativeStatus = "draft" | "published" | "purchased"; type ExportFormat = "png" | "jpg" | "webp"; @@ -23,6 +24,9 @@ type ExportOptions = { }; function PreviewStudioPage() { + // User info + const { data: user } = useCurrentUser(); + // State const [title, setTitle] = useState("Untitled Creative"); const [status, setStatus] = useState("draft"); @@ -86,11 +90,25 @@ function PreviewStudioPage() { return PLACEMENT_SPECS.find((s) => s.key === selectedSpecKey)!; }, [selectedSpecKey, isCustomSize, customSize]); - // Get specs objects - const selectedSpecObjects = useMemo( - () => PLACEMENT_SPECS.filter((s) => selectedSpecs.includes(s.key)), - [selectedSpecs] - ); + // Get specs objects (include custom size if active) + const selectedSpecObjects = useMemo(() => { + const specs = PLACEMENT_SPECS.filter((s) => selectedSpecs.includes(s.key)); + if (isCustomSize) { + specs.push({ + key: "custom" as PlacementSpecKey, + label: `Custom ${customSize.width}×${customSize.height}`, + category: "mobile" as DeviceCategory, + width: customSize.width, + height: customSize.height, + aspectRatio: `${customSize.width}:${customSize.height}`, + icon: "custom_size", + shortLabel: "CUSTOM", + safeArea: { top: 64, right: 64, bottom: 64, left: 64 }, + rules: { minTitleFontSize: 44, minBodyFontSize: 28, maxTitleLines: 2, maxBodyLines: 4 } + }); + } + return specs; + }, [selectedSpecs, isCustomSize, customSize]); // Handlers const handleSave = async () => { @@ -412,22 +430,74 @@ function PreviewStudioPage() { ); return ( -
+
+ {/* Page Header with Logo, Title and User */} +
+
+ {/* Back Button */} + + + {/* Custom Icon */} +
+ + + +
+

Preview Studio

+
+ + {/* User Avatar */} + {user?.image ? ( +
+ ) : ( +
+ {user?.name?.charAt(0) || "U"} +
+ )} +
+ + {/* Page Description */} +
+

+ Preview your creative across multiple platforms and sizes +

+
+ {/* Top Toolbar */} - - -
+
+ setTitle(e.target.value)} + className="bg-transparent text-base font-bold focus:outline-none focus:border-b-2 focus:border-primary px-1 min-w-[200px]" + placeholder="Untitled Creative" + /> +
+ + {status === "draft" ? "Draft" : "Published"} + +
+ + {status === "draft" && handlePublish && ( + + )} +
+
+ +
{/* Left Sidebar - Device & Size Picker */} -