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/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/shared/src/placementSpecs.ts b/packages/shared/src/placementSpecs.ts index d644aa4..37ed946 100644 --- a/packages/shared/src/placementSpecs.ts +++ b/packages/shared/src/placementSpecs.ts @@ -1,17 +1,25 @@ export type PlacementSpecKey = - | "square_1_1" + | "iphone_14_pro" + | "galaxy_s23" + | "ipad_air" | "feed_4_5" | "story_9_16" | "landscape_16_9" | "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: { @@ -25,13 +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, - 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", @@ -39,6 +74,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,7 +86,9 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ category: "mobile", width: 1080, height: 1920, - // extra bottom safe-area for UI overlays + aspectRatio: "9:16", + icon: "phone_iphone", + shortLabel: "9:16", safeArea: { top: 120, right: 80, bottom: 220, left: 80 }, rules: { minTitleFontSize: 52, minBodyFontSize: 30, maxTitleLines: 3, maxBodyLines: 4 } }, @@ -58,6 +98,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 +110,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,7 +122,9 @@ export const PLACEMENT_SPECS: PlacementSpec[] = [ category: "tv", width: 3840, height: 2160, - // TV overscan-ish margins + aspectRatio: "16:9", + icon: "tv", + shortLabel: "4K", 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/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/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 bcc4189..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) {

- CreativeAI + Creative Store

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..4db5120 --- /dev/null +++ b/packages/web/src/components/preview/device-size-picker.tsx @@ -0,0 +1,135 @@ +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; + onCategoryChange?: (category: DeviceCategory, firstSpecKey: PlacementSpecKey) => void; + className?: string; +}; + +export function DeviceSizePicker({ + selectedSpecKey, + 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); + // 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) => { + 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) => ( + + ))} +
+
+
+ ); +} 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..44efe88 --- /dev/null +++ b/packages/web/src/components/preview/device-tabs.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import type { PlacementSpec } from "../../../../shared/src/placementSpecs"; + +type DeviceTabsProps = { + specs: PlacementSpec[]; + activeSpecKey: string; + onSpecSelect: (key: string) => void; + onCustomClick?: () => void; + className?: string; +}; + +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) => ( + + ))} + + {/* 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 new file mode 100644 index 0000000..bc8d82e --- /dev/null +++ b/packages/web/src/components/preview/export-panel.tsx @@ -0,0 +1,166 @@ +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 handleExport = () => { + onExport({ + format, + quality: 0.95, + scale, + backgroundColor: null, + }); + }; + + 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 */} +
+ + +
+ + {/* 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..5300ab9 --- /dev/null +++ b/packages/web/src/components/preview/preview-canvas.tsx @@ -0,0 +1,111 @@ +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); + // 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.25; + + const container = containerRef.current; + 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, 0.5); // Max zoom 50% + }, [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); + }, []); + + const actualZoom = zoom === "fit" ? calculateFitZoom() : zoom; + + const handleZoomIn = () => { + const currentZoom = zoom === "fit" ? calculateFitZoom() : zoom; + const newZoom = Math.min(currentZoom + 0.25, 1); + 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 ( +
+ {/* 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..70bd374 --- /dev/null +++ b/packages/web/src/components/preview/preview-toolbar.tsx @@ -0,0 +1,86 @@ +import { useNavigate } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type CreativeStatus = "draft" | "published"; + +type PreviewToolbarProps = { + title: string; + onTitleChange: (title: string) => void; + version: string; + status: CreativeStatus; + onSave: () => void; + onPublish?: () => void; + canPublish?: boolean; + isSaving?: 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" }, +}; + +export function PreviewToolbar({ + title, + onTitleChange, + version, + status, + onSave, + onPublish, + canPublish = false, + isSaving = false, + className, +}: PreviewToolbarProps) { + return ( +
+ {/* Left Section */} +
+ {/* Title Input */} + 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" + /> + +
+ + {/* Status Badge */} + + {STATUS_CONFIG[status].label} + +
+ + {/* Right Section */} +
+ {/* Actions */} + + + {canPublish && onPublish && ( + + )} +
+
+ ); +} 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/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..ff680d1 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -10,8 +10,10 @@ 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 AuthenticatedMarketRouteImport } from "./routes/_authenticated/market" @@ -24,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, @@ -33,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", @@ -73,6 +84,7 @@ export interface FileRoutesByFullPath { "/market": typeof AuthenticatedMarketRoute "/projects": typeof AuthenticatedProjectsRoute "/wallet": typeof AuthenticatedWalletRoute + "/preview-studio": typeof PreviewPreviewStudioRoute "/market/$id": typeof AuthenticatedMarketIdRoute } export interface FileRoutesByTo { @@ -83,18 +95,21 @@ export interface FileRoutesByTo { "/market": typeof AuthenticatedMarketRoute "/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/projects": typeof AuthenticatedProjectsRoute "/_authenticated/wallet": typeof AuthenticatedWalletRoute + "/_preview/preview-studio": typeof PreviewPreviewStudioRoute "/_authenticated/market_/$id": typeof AuthenticatedMarketIdRoute } export interface FileRouteTypes { @@ -107,6 +122,7 @@ export interface FileRouteTypes { | "/market" | "/projects" | "/wallet" + | "/preview-studio" | "/market/$id" fileRoutesByTo: FileRoutesByTo to: @@ -117,23 +133,27 @@ export interface FileRouteTypes { | "/market" | "/projects" | "/wallet" + | "/preview-studio" | "/market/$id" id: | "__root__" | "/" | "/_authenticated" + | "/_preview" | "/login" | "/_authenticated/creative-studio" | "/_authenticated/dashboard" | "/_authenticated/market" | "/_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 } @@ -146,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: "" @@ -160,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" @@ -227,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/_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/_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/_preview/preview-studio.tsx b/packages/web/src/routes/_preview/preview-studio.tsx new file mode 100644 index 0000000..ba6ae26 --- /dev/null +++ b/packages/web/src/routes/_preview/preview-studio.tsx @@ -0,0 +1,604 @@ +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 (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 () => { + 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 */} +
+ 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 */} + + + {/* 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/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 } }