ly0303521

first commit 20251222

  1 +"""FastAPI proxy for the Z-Image generator frontend."""
  2 +import json
  3 +import os
  4 +import secrets
  5 +import time
  6 +from pathlib import Path
  7 +from threading import Lock
  8 +from typing import List, Literal, Optional
  9 +
  10 +import httpx
  11 +from fastapi import FastAPI, HTTPException, Query
  12 +from fastapi.middleware.cors import CORSMiddleware
  13 +from pydantic import BaseModel, Field, ConfigDict
  14 +import logging
  15 +
  16 +logger = logging.getLogger("uvicorn.error")
  17 +logging.basicConfig(level=logging.INFO)
  18 +logger.info("your message %s", "hello")
  19 +Z_IMAGE_BASE_URL = os.getenv("Z_IMAGE_BASE_URL", "http://106.120.52.146:39009").rstrip("/")
  20 +REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120"))
  21 +GALLERY_DATA_PATH = Path(os.getenv("GALLERY_DATA_PATH", Path(__file__).with_name("gallery_data.json")))
  22 +GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500"))
  23 +
  24 +
  25 +class ImageGenerationPayload(BaseModel):
  26 + model_config = ConfigDict(populate_by_name=True)
  27 +
  28 + prompt: str = Field(..., min_length=1, max_length=2048)
  29 + height: int = Field(1024, ge=64, le=2048)
  30 + width: int = Field(1024, ge=64, le=2048)
  31 + num_inference_steps: int = Field(8, ge=1, le=200)
  32 + guidance_scale: float = Field(0.0, ge=0.0, le=20.0)
  33 + seed: Optional[int] = Field(default=None, ge=0)
  34 + negative_prompt: Optional[str] = Field(default=None, max_length=2048)
  35 + output_format: Literal["base64", "url"] = "base64"
  36 + author_id: Optional[str] = Field(default=None, alias="authorId", min_length=1, max_length=64)
  37 +
  38 +
  39 +class ImageGenerationResponse(BaseModel):
  40 + image: Optional[str] = None
  41 + url: Optional[str] = None
  42 + time_taken: float = 0.0
  43 + error: Optional[str] = None
  44 + request_params: ImageGenerationPayload
  45 + gallery_item: Optional["GalleryImage"] = None
  46 +
  47 +
  48 +class GalleryImage(BaseModel):
  49 + model_config = ConfigDict(populate_by_name=True)
  50 +
  51 + id: str
  52 + prompt: str = Field(..., min_length=1, max_length=2048)
  53 + height: int = Field(..., ge=64, le=2048)
  54 + width: int = Field(..., ge=64, le=2048)
  55 + num_inference_steps: int = Field(..., ge=1, le=200)
  56 + guidance_scale: float = Field(..., ge=0.0, le=20.0)
  57 + seed: int = Field(..., ge=0)
  58 + url: str
  59 + created_at: float = Field(default_factory=lambda: time.time() * 1000, alias="createdAt")
  60 + author_id: Optional[str] = Field(default=None, alias="authorId")
  61 + likes: int = 0
  62 + is_mock: bool = Field(default=False, alias="isMock")
  63 + negative_prompt: Optional[str] = None
  64 +
  65 +
  66 +ImageGenerationResponse.model_rebuild()
  67 +
  68 +
  69 +class GalleryStore:
  70 + """Simple JSON file backed store for generated images."""
  71 +
  72 + def __init__(self, path: Path, max_items: int = 500) -> None:
  73 + self.path = path
  74 + self.max_items = max_items
  75 + self.lock = Lock()
  76 + self.enabled = True
  77 + self._memory_cache: List[dict] = []
  78 + try:
  79 + self.path.parent.mkdir(parents=True, exist_ok=True)
  80 + if self.path.exists():
  81 + self._memory_cache = self._read().get("images", [])
  82 + else:
  83 + self._write({"images": []})
  84 + except OSError as exc: # pragma: no cover - filesystem guards
  85 + self.enabled = False
  86 + print(f"[WARN] Gallery store disabled due to filesystem error: {exc}")
  87 +
  88 + def _read(self) -> dict:
  89 + if not self.enabled:
  90 + return {"images": list(self._memory_cache)}
  91 + try:
  92 + with self.path.open("r", encoding="utf-8") as file:
  93 + return json.load(file)
  94 + except (FileNotFoundError, json.JSONDecodeError):
  95 + return {"images": []}
  96 +
  97 + def _write(self, data: dict) -> None:
  98 + if not self.enabled:
  99 + self._memory_cache = list(data.get("images", []))
  100 + return
  101 + payload = json.dumps(data, ensure_ascii=False, indent=2)
  102 + temp_path = self.path.with_suffix(".tmp")
  103 + try:
  104 + with temp_path.open("w", encoding="utf-8") as file:
  105 + file.write(payload)
  106 + temp_path.replace(self.path)
  107 + except OSError as exc:
  108 + # Some filesystems (or permissions) may block atomic replace; fall back to direct write
  109 + print(f"[WARN] Atomic gallery write failed, attempting direct write: {exc}")
  110 + try:
  111 + with self.path.open("w", encoding="utf-8") as file:
  112 + file.write(payload)
  113 + except OSError as direct_exc:
  114 + raise direct_exc
  115 + self._memory_cache = list(data.get("images", []))
  116 +
  117 + def list_images(self) -> List[dict]:
  118 + with self.lock:
  119 + data = self._read()
  120 + return list(data.get("images", []))
  121 +
  122 + def add_image(self, image: GalleryImage) -> dict:
  123 + payload = image.model_dump(by_alias=True)
  124 + with self.lock:
  125 + data = self._read()
  126 + images = data.get("images", [])
  127 + images.insert(0, payload)
  128 + data["images"] = images[: self.max_items]
  129 + self._write(data)
  130 + return payload
  131 +
  132 +
  133 +gallery_store = GalleryStore(GALLERY_DATA_PATH, GALLERY_MAX_ITEMS)
  134 +
  135 +
  136 +app = FastAPI(title="Z-Image Proxy", version="1.0.0")
  137 +
  138 +app.add_middleware(
  139 + CORSMiddleware,
  140 + allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
  141 + allow_credentials=True,
  142 + allow_methods=["*"],
  143 + allow_headers=["*"],
  144 +)
  145 +
  146 +
  147 +@app.on_event("startup")
  148 +async def startup() -> None:
  149 + timeout = httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0)
  150 + app.state.http = httpx.AsyncClient(timeout=timeout)
  151 +
  152 +
  153 +@app.on_event("shutdown")
  154 +async def shutdown() -> None:
  155 + await app.state.http.aclose()
  156 +
  157 +
  158 +@app.get("/health")
  159 +async def health() -> dict:
  160 + return {"status": "ok"}
  161 +
  162 +
  163 +@app.get("/gallery")
  164 +async def gallery(
  165 + limit: int = Query(200, ge=1, le=1000),
  166 + author_id: Optional[str] = Query(default=None, alias="authorId"),
  167 +) -> dict:
  168 + """Return the persisted gallery images, optionally filtered by author."""
  169 + images = gallery_store.list_images()
  170 + if author_id:
  171 + images = [item for item in images if item.get("authorId") == author_id]
  172 + return {"images": images[:limit]}
  173 +
  174 +
  175 +@app.post("/generate", response_model=ImageGenerationResponse)
  176 +async def generate_image(payload: ImageGenerationPayload) -> ImageGenerationResponse:
  177 + request_params_data = payload.model_dump()
  178 + body = {
  179 + key: value
  180 + for key, value in request_params_data.items()
  181 + if value is not None and key != "author_id"
  182 + }
  183 + if "seed" not in body:
  184 + body["seed"] = secrets.randbelow(1_000_000_000)
  185 + request_params_data["seed"] = body["seed"]
  186 + request_params = ImageGenerationPayload(**request_params_data)
  187 + url = f"{Z_IMAGE_BASE_URL}/generate"
  188 +
  189 + try:
  190 + resp = await app.state.http.post(url, json=body)
  191 + except httpx.RequestError as exc: # pragma: no cover - network errors only
  192 + raise HTTPException(status_code=502, detail=f"Z-Image service unreachable: {exc}") from exc
  193 +
  194 + if resp.status_code != 200:
  195 + raise HTTPException(status_code=resp.status_code, detail=f"Z-Image error: {resp.text}")
  196 +
  197 + data = resp.json()
  198 + image = data.get("image")
  199 + image_url = data.get("url")
  200 + if not image and not image_url:
  201 + raise HTTPException(status_code=502, detail=f"Malformed response from Z-Image: {data}")
  202 +
  203 + stored_image: Optional[GalleryImage] = None
  204 + try:
  205 + stored = gallery_store.add_image(
  206 + GalleryImage(
  207 + id=data.get("id") or secrets.token_hex(16),
  208 + prompt=payload.prompt,
  209 + width=payload.width,
  210 + height=payload.height,
  211 + num_inference_steps=payload.num_inference_steps,
  212 + guidance_scale=payload.guidance_scale,
  213 + seed=request_params.seed,
  214 + url=image_url or f"data:image/png;base64,{image}",
  215 + author_id=payload.author_id,
  216 + negative_prompt=payload.negative_prompt,
  217 + )
  218 + )
  219 + stored_image = GalleryImage.model_validate(stored)
  220 + except Exception as exc: # pragma: no cover - diagnostics only
  221 + # Persisting gallery data should not block the response
  222 + print(f"[WARN] Failed to store gallery image: {exc}")
  223 +
  224 + return ImageGenerationResponse(
  225 + image=image,
  226 + url=image_url,
  227 + time_taken=float(data.get("time_taken", 0.0)),
  228 + error=data.get("error"),
  229 + request_params=request_params,
  230 + gallery_item=stored_image,
  231 + )
  1 +#!/usr/bin/env python
  2 +# -*- coding: utf-8 -*-
  3 +"""Client script to test Z-Image server."""
  4 +
  5 +import base64
  6 +import time
  7 +import requests
  8 +from PIL import Image
  9 +import io
  10 +
  11 +
  12 +def test_health_check(base_url="http://localhost:9009"):
  13 + """Test server health check."""
  14 + print("Testing health check...")
  15 + try:
  16 + response = requests.get(f"{base_url}/health", timeout=10)
  17 + print(f"Status code: {response.status_code}")
  18 + print(f"Response text: {response.text}")
  19 + print(f"Response headers: {dict(response.headers)}")
  20 + if response.status_code == 200:
  21 + print(f"Health check: {response.json()}")
  22 + else:
  23 + print(f"Error: HTTP {response.status_code}")
  24 + print(f"Response: {response.text}")
  25 + except requests.exceptions.RequestException as e:
  26 + print(f"Request error: {e}")
  27 + raise
  28 + except ValueError as e:
  29 + print(f"JSON decode error: {e}")
  30 + print(f"Response content: {response.text if 'response' in locals() else 'No response'}")
  31 + raise
  32 + print()
  33 +
  34 +
  35 +def test_generate_image(base_url="http://localhost:9009", save_path="generated_image.png", output_format="base64"):
  36 + """Test image generation with base64 or URL response."""
  37 + format_name = "base64" if output_format == "base64" else "URL"
  38 + print(f"Testing image generation ({format_name})...")
  39 +
  40 + # Prepare request
  41 + request_data = {
  42 + "prompt": "一只可爱的熊猫在竹林里吃竹子,阳光透过树叶洒下,4K高清,摄影作品",
  43 + "height": 1024,
  44 + "width": 1024,
  45 + "num_inference_steps": 8,
  46 + "guidance_scale": 0.0,
  47 + "seed": 42,
  48 + "output_format": output_format,
  49 + }
  50 +
  51 + print(f"Prompt: {request_data['prompt']}")
  52 + print(f"Size: {request_data['width']}x{request_data['height']}")
  53 + print(f"Output format: {output_format}")
  54 +
  55 + # Send request
  56 + start_time = time.time()
  57 + response = requests.post(f"{base_url}/generate", json=request_data)
  58 + end_time = time.time()
  59 +
  60 + if response.status_code == 200:
  61 + result = response.json()
  62 + print(f"Generation successful!")
  63 + print(f"Time taken: {result['time_taken']:.2f}s")
  64 + print(f"Total request time: {end_time - start_time:.2f}s")
  65 +
  66 + if output_format == "url":
  67 + # Handle URL response
  68 + if result.get("url"):
  69 + image_url = result["url"]
  70 + print(f"Image URL: {image_url}")
  71 +
  72 + # Download image from URL and save
  73 + try:
  74 + img_response = requests.get(image_url, timeout=10)
  75 + if img_response.status_code == 200:
  76 + img = Image.open(io.BytesIO(img_response.content))
  77 + img.save(save_path)
  78 + print(f"Image downloaded and saved to: {save_path}")
  79 + else:
  80 + print(f"Failed to download image from URL: HTTP {img_response.status_code}")
  81 + except Exception as e:
  82 + print(f"Error downloading image from URL: {e}")
  83 + else:
  84 + print("Warning: No URL returned in response")
  85 + # Fallback: check if base64 is available
  86 + if result.get("image"):
  87 + print("Fallback: Using base64 image from response")
  88 + img_data = base64.b64decode(result["image"])
  89 + img = Image.open(io.BytesIO(img_data))
  90 + img.save(save_path)
  91 + print(f"Image saved to: {save_path}")
  92 + else:
  93 + # Handle base64 response
  94 + if result.get("image"):
  95 + img_data = base64.b64decode(result["image"])
  96 + img = Image.open(io.BytesIO(img_data))
  97 + img.save(save_path)
  98 + print(f"Image saved to: {save_path}")
  99 + else:
  100 + print("Warning: No image data in response")
  101 + else:
  102 + print(f"Error: {response.status_code}")
  103 + print(response.json())
  104 +
  105 + print()
  106 +
  107 +
  108 +def test_generate_stream(base_url="http://localhost:9009", save_path="generated_stream.png"):
  109 + """Test image generation with stream response."""
  110 + print("Testing image generation (stream)...")
  111 +
  112 + # Prepare request
  113 + request_data = {
  114 + "prompt": "A futuristic cityscape at sunset, flying cars, neon lights, cyberpunk style, highly detailed",
  115 + "height": 1024,
  116 + "width": 1024,
  117 + "num_inference_steps": 8,
  118 + "guidance_scale": 0.0,
  119 + "seed": 123,
  120 + }
  121 +
  122 + print(f"Prompt: {request_data['prompt']}")
  123 + print(f"Size: {request_data['width']}x{request_data['height']}")
  124 +
  125 + # Send request
  126 + start_time = time.time()
  127 + response = requests.post(f"{base_url}/generate_stream", json=request_data)
  128 + end_time = time.time()
  129 +
  130 + if response.status_code == 200:
  131 + print(f"Generation successful!")
  132 + print(f"Total request time: {end_time - start_time:.2f}s")
  133 +
  134 + # Save image
  135 + img = Image.open(io.BytesIO(response.content))
  136 + img.save(save_path)
  137 + print(f"Image saved to: {save_path}")
  138 + else:
  139 + print(f"Error: {response.status_code}")
  140 + print(response.text)
  141 +
  142 + print()
  143 +
  144 +
  145 +def main():
  146 + """Run all tests."""
  147 + base_url = "http://106.120.52.146:39009"
  148 +
  149 + print("=" * 60)
  150 + print("Z-Image Server Client Test")
  151 + print("=" * 60)
  152 + print()
  153 +
  154 + # Test health check
  155 + try:
  156 + test_health_check(base_url)
  157 + except Exception as e:
  158 + print(f"Health check failed: {e}")
  159 + print("Make sure the server is running!")
  160 + return
  161 +
  162 + # Test image generation (base64)
  163 + try:
  164 + test_generate_image(base_url, "test_output_base64.png", output_format="base64")
  165 + except Exception as e:
  166 + print(f"Image generation test (base64) failed: {e}")
  167 +
  168 + # Test image generation (URL)
  169 + try:
  170 + test_generate_image(base_url, "test_output_url.png", output_format="url")
  171 + except Exception as e:
  172 + print(f"Image generation test (URL) failed: {e}")
  173 +
  174 + # Test image generation (stream)
  175 + try:
  176 + test_generate_stream(base_url, "test_output_stream.png")
  177 + except Exception as e:
  178 + print(f"Stream generation test failed: {e}")
  179 +
  180 + print("=" * 60)
  181 + print("All tests completed!")
  182 + print("=" * 60)
  183 +
  184 +
  185 +if __name__ == "__main__":
  186 + main()
  187 +
  1 +# Logs
  2 +logs
  3 +*.log
  4 +npm-debug.log*
  5 +yarn-debug.log*
  6 +yarn-error.log*
  7 +pnpm-debug.log*
  8 +lerna-debug.log*
  9 +
  10 +node_modules
  11 +dist
  12 +dist-ssr
  13 +*.local
  14 +
  15 +# Editor directories and files
  16 +.vscode/*
  17 +!.vscode/extensions.json
  18 +.idea
  19 +.DS_Store
  20 +*.suo
  21 +*.ntvs*
  22 +*.njsproj
  23 +*.sln
  24 +*.sw?
  1 +import React, { useState, useEffect, useMemo, useCallback } from 'react';
  2 +import { ImageItem, ImageGenerationParams, UserProfile } from './types';
  3 +import { SHOWCASE_IMAGES, ADMIN_ID } from './constants';
  4 +import { generateImage } from './services/imageService';
  5 +import { fetchGallery } from './services/galleryService';
  6 +import MasonryGrid from './components/MasonryGrid';
  7 +import InputBar from './components/InputBar';
  8 +import HistoryBar from './components/HistoryBar';
  9 +import DetailModal from './components/DetailModal';
  10 +import AdminModal from './components/AdminModal';
  11 +import AuthModal from './components/AuthModal';
  12 +import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles } from 'lucide-react';
  13 +
  14 +const STORAGE_KEY_DATA = 'z-image-gallery-data-v2';
  15 +const STORAGE_KEY_USER = 'z-image-user-profile';
  16 +const MIN_GALLERY_ITEMS = 8;
  17 +
  18 +const App: React.FC = () => {
  19 + // --- State: User ---
  20 + const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
  21 + const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
  22 +
  23 + // --- State: Data ---
  24 + const [images, setImages] = useState<ImageItem[]>(() => {
  25 + try {
  26 + const saved = localStorage.getItem(STORAGE_KEY_DATA);
  27 + if (saved) return JSON.parse(saved);
  28 + } catch (e) { console.error(e); }
  29 + return SHOWCASE_IMAGES;
  30 + });
  31 +
  32 + const [isGenerating, setIsGenerating] = useState(false);
  33 + const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
  34 + const [error, setError] = useState<string | null>(null);
  35 + const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null);
  36 +
  37 + // --- State: Admin/Edit ---
  38 + const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
  39 + const [editingImage, setEditingImage] = useState<ImageItem | null>(null);
  40 +
  41 + const isAdmin = currentUser?.employeeId === ADMIN_ID;
  42 +
  43 + // GLOBAL GALLERY: Everyone sees everything, sorted by likes
  44 + const sortedImages = useMemo(() => {
  45 + return [...images].sort((a, b) => (b.likes || 0) - (a.likes || 0));
  46 + }, [images]);
  47 +
  48 + // USER HISTORY: Only current user's generations
  49 + const userHistory = useMemo(() => {
  50 + if (!currentUser) return [];
  51 + return images
  52 + .filter(img => img.authorId === currentUser.employeeId)
  53 + .sort((a, b) => b.createdAt - a.createdAt);
  54 + }, [images, currentUser]);
  55 +
  56 + useEffect(() => {
  57 + const savedUser = localStorage.getItem(STORAGE_KEY_USER);
  58 + if (savedUser) setCurrentUser(JSON.parse(savedUser));
  59 + else setIsAuthModalOpen(true);
  60 + }, []);
  61 +
  62 + useEffect(() => {
  63 + try { localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(images)); }
  64 + catch (e) { console.error("Storage full", e); }
  65 + }, [images]);
  66 +
  67 + const handleLogin = (employeeId: string) => {
  68 + const user: UserProfile = { employeeId, hasAccess: true };
  69 + setCurrentUser(user);
  70 + localStorage.setItem(STORAGE_KEY_USER, JSON.stringify(user));
  71 + setIsAuthModalOpen(false);
  72 + };
  73 +
  74 + const handleLogout = () => {
  75 + if(confirm("确定要退出登录吗?")) {
  76 + localStorage.removeItem(STORAGE_KEY_USER);
  77 + setCurrentUser(null);
  78 + setIsAuthModalOpen(true);
  79 + }
  80 + };
  81 +
  82 + const syncGallery = useCallback(async () => {
  83 + try {
  84 + const remoteImages = await fetchGallery();
  85 + setImages(prev => {
  86 + const localState = new Map(
  87 + prev.map(img => [
  88 + img.id,
  89 + { likes: img.likes, isLikedByCurrentUser: !!img.isLikedByCurrentUser },
  90 + ])
  91 + );
  92 +
  93 + const normalized = remoteImages.map(img => ({
  94 + ...img,
  95 + likes: localState.get(img.id)?.likes ?? img.likes ?? 0,
  96 + isLikedByCurrentUser: localState.get(img.id)?.isLikedByCurrentUser ?? false,
  97 + }));
  98 +
  99 + if (normalized.length >= MIN_GALLERY_ITEMS) {
  100 + return normalized;
  101 + }
  102 +
  103 + const existingIds = new Set(normalized.map(img => img.id));
  104 + const filler = SHOWCASE_IMAGES.filter(img => !existingIds.has(img.id)).slice(
  105 + 0,
  106 + Math.max(0, MIN_GALLERY_ITEMS - normalized.length)
  107 + );
  108 +
  109 + return [...normalized, ...filler];
  110 + });
  111 + } catch (err) {
  112 + console.error("Failed to sync gallery", err);
  113 + setImages(prev => {
  114 + if (prev.length >= MIN_GALLERY_ITEMS) return prev;
  115 + const existingIds = new Set(prev.map(img => img.id));
  116 + const filler = SHOWCASE_IMAGES.filter(img => !existingIds.has(img.id)).slice(
  117 + 0,
  118 + Math.max(0, MIN_GALLERY_ITEMS - prev.length)
  119 + );
  120 + return [...prev, ...filler];
  121 + });
  122 + }
  123 + }, []);
  124 +
  125 + useEffect(() => {
  126 + syncGallery();
  127 + const interval = setInterval(syncGallery, 30000);
  128 + return () => clearInterval(interval);
  129 + }, [syncGallery]);
  130 +
  131 + const handleGenerate = async (uiParams: ImageGenerationParams) => {
  132 + if (!currentUser) {
  133 + setIsAuthModalOpen(true);
  134 + return;
  135 + }
  136 +
  137 + setIsGenerating(true);
  138 + setError(null);
  139 + setIncomingParams(null); // Reset syncing state once action starts
  140 +
  141 + try {
  142 + const result = await generateImage(uiParams, currentUser.employeeId);
  143 + const serverImage = result.galleryItem
  144 + ? { ...result.galleryItem, isLikedByCurrentUser: false }
  145 + : null;
  146 +
  147 + const newImage: ImageItem = serverImage || {
  148 + id: Date.now().toString(),
  149 + url: result.imageUrl,
  150 + createdAt: Date.now(),
  151 + authorId: currentUser.employeeId,
  152 + likes: 0,
  153 + isLikedByCurrentUser: false,
  154 + ...uiParams
  155 + };
  156 +
  157 + setImages(prev => {
  158 + const existing = prev.filter(img => img.id !== newImage.id);
  159 + return [newImage, ...existing];
  160 + });
  161 +
  162 + if (!serverImage) {
  163 + await syncGallery();
  164 + }
  165 + } catch (err: any) {
  166 + console.error(err);
  167 + setError("生成失败。请确保服务器正常运行。");
  168 + } finally {
  169 + setIsGenerating(false);
  170 + }
  171 + };
  172 +
  173 + const handleLike = (image: ImageItem) => {
  174 + if (!currentUser) {
  175 + setIsAuthModalOpen(true);
  176 + return;
  177 + }
  178 + setImages(prev => prev.map(img => {
  179 + if (img.id === image.id) {
  180 + const isLiked = !!img.isLikedByCurrentUser;
  181 + return {
  182 + ...img,
  183 + isLikedByCurrentUser: !isLiked,
  184 + likes: isLiked ? Math.max(0, (img.likes || 0) - 1) : (img.likes || 0) + 1
  185 + };
  186 + }
  187 + return img;
  188 + }));
  189 + };
  190 +
  191 + const handleGenerateSimilar = (params: ImageGenerationParams) => {
  192 + setIncomingParams(params);
  193 + // Visual feedback
  194 + const banner = document.getElementById('similar-feedback');
  195 + if (banner) {
  196 + banner.style.display = 'flex';
  197 + setTimeout(() => { banner.style.display = 'none'; }, 3000);
  198 + }
  199 + // Scroll to bottom where input is
  200 + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
  201 + };
  202 +
  203 + // --- Management (ADMIN) ---
  204 + const handleOpenCreateModal = () => { if (isAdmin) { setEditingImage(null); setIsAdminModalOpen(true); } };
  205 + const handleOpenEditModal = (image: ImageItem) => { if (isAdmin) { setEditingImage(image); setSelectedImage(null); setIsAdminModalOpen(true); } };
  206 + const handleSaveImage = (savedImage: ImageItem) => {
  207 + setImages(prev => {
  208 + const exists = prev.some(img => img.id === savedImage.id);
  209 + if (exists) return prev.map(img => img.id === savedImage.id ? savedImage : img);
  210 + return [{ ...savedImage, authorId: currentUser?.employeeId || 'ADMIN', likes: savedImage.likes || 0 }, ...prev];
  211 + });
  212 + };
  213 + const handleDeleteImage = (id: string) => { if (isAdmin) setImages(prev => prev.filter(img => img.id !== id)); };
  214 + const handleResetData = () => { if (isAdmin && confirm('警告:确定要重置为初始演示数据吗?')) { setImages(SHOWCASE_IMAGES); } };
  215 +
  216 + const handleExportShowcase = () => {
  217 + const exportData = images.map(img => ({ ...img, isLikedByCurrentUser: false, isMock: true }));
  218 + const fileContent = `import { ImageItem } from './types';
  219 +export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009";
  220 +const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim();
  221 +const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0
  222 + ? ENV_PROXY_URL
  223 + : "http://localhost:9009";
  224 +export const API_BASE_URL = DEFAULT_PROXY_URL;
  225 +export const API_ENDPOINTS = API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL
  226 + ? [Z_IMAGE_DIRECT_BASE_URL]
  227 + : [API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL];
  228 +export const ADMIN_ID = '${ADMIN_ID}';
  229 +export const DEFAULT_PARAMS = { height: 1024, width: 1024, num_inference_steps: 20, guidance_scale: 7.5 };
  230 +export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2)};`;
  231 + const blob = new Blob([fileContent], { type: 'text/typescript' });
  232 + const a = document.createElement('a');
  233 + a.href = URL.createObjectURL(blob);
  234 + a.download = 'constants.ts';
  235 + a.click();
  236 + };
  237 +
  238 + return (
  239 + <div className="min-h-screen bg-white text-gray-900 font-sans pb-40">
  240 + <AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} />
  241 +
  242 + {/* Sync Feedback Banner */}
  243 + <div id="similar-feedback" className="fixed top-24 left-1/2 -translate-x-1/2 z-[60] bg-purple-600 text-white px-6 py-2 rounded-full shadow-lg hidden items-center gap-2 animate-bounce">
  244 + <Sparkles size={16} />
  245 + <span className="text-sm font-bold tracking-wide">参数已同步到输入框</span>
  246 + </div>
  247 +
  248 + <header className="px-6 py-6 md:px-12 md:py-8 flex justify-between items-end sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-gray-100">
  249 + <div>
  250 + <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight mb-2">艺云-DESIGN</h1>
  251 + <p className="text-gray-400 text-sm md:text-base">东方设计 · 全局创作灵感库</p>
  252 + </div>
  253 +
  254 + <div className="flex items-center gap-3">
  255 + {currentUser && (
  256 + <div className="hidden md:flex flex-col items-end mr-2">
  257 + <span className={`text-xs uppercase tracking-wider px-2 py-0.5 rounded font-bold ${isAdmin ? 'text-purple-600 bg-purple-50' : 'text-gray-400 bg-gray-50'}`}>
  258 + {isAdmin ? 'Administrator' : '设计师'}
  259 + </span>
  260 + <span className="font-mono font-bold text-gray-800">{currentUser.employeeId}</span>
  261 + </div>
  262 + )}
  263 +
  264 + {isAdmin && (
  265 + <div className="flex gap-2">
  266 + <button onClick={handleExportShowcase} className="hidden md:flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-full hover:bg-purple-700 transition-colors text-sm font-medium shadow-sm"><Save size={16} /><span>导出库</span></button>
  267 + <button onClick={handleResetData} className="p-2 md:p-3 rounded-full hover:bg-red-50 text-gray-300 hover:text-red-500 transition-colors"><Trash2 size={20} /></button>
  268 + <button onClick={handleOpenCreateModal} className="p-2 md:p-3 rounded-full bg-black text-white hover:bg-gray-800 transition-colors shadow-sm"><Settings size={20} /></button>
  269 + </div>
  270 + )}
  271 +
  272 + {currentUser && <button onClick={handleLogout} className="p-2 md:p-3 rounded-full hover:bg-gray-100 transition-colors text-gray-600"><UserIcon size={20} /></button>}
  273 + </div>
  274 + </header>
  275 +
  276 + {error && <div className="mx-6 md:mx-12 mt-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-lg text-sm flex justify-between"><span>{error}</span><button onClick={() => setError(null)}><Trash2 size={14}/></button></div>}
  277 +
  278 + <main>
  279 + {isGenerating && (
  280 + <div className="w-full flex justify-center py-12">
  281 + <div className="flex flex-col items-center animate-pulse">
  282 + <Loader2 className="animate-spin text-purple-600 mb-3" size={40} />
  283 + <span className="text-gray-500 font-medium">绘图引擎全力启动中...</span>
  284 + </div>
  285 + </div>
  286 + )}
  287 +
  288 + <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/>
  289 + </main>
  290 +
  291 + {/* History Album (Bottom Left) */}
  292 + <HistoryBar images={userHistory} onSelect={setSelectedImage} />
  293 +
  294 + <InputBar onGenerate={handleGenerate} isGenerating={isGenerating} incomingParams={incomingParams} />
  295 +
  296 + {selectedImage && (
  297 + <DetailModal
  298 + image={selectedImage}
  299 + onClose={() => setSelectedImage(null)}
  300 + onEdit={isAdmin ? handleOpenEditModal : undefined}
  301 + onGenerateSimilar={handleGenerateSimilar}
  302 + />
  303 + )}
  304 +
  305 + <AdminModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} onSave={handleSaveImage} onDelete={handleDeleteImage} initialData={editingImage} />
  306 + </div>
  307 + );
  308 +};
  309 +
  310 +export default App;
  1 +<div align="center">
  2 +<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
  3 +</div>
  4 +
  5 +# Run and deploy your AI Studio app
  6 +
  7 +This contains everything you need to run your app locally.
  8 +
  9 +View your app in AI Studio: https://ai.studio/apps/drive/1JB2dpJngXxwHJXkKx61iGeCNPDb5zdd7
  10 +
  11 +## Run Locally
  12 +
  13 +**Prerequisites:** Node.js
  14 +
  15 +
  16 +1. Install dependencies:
  17 + `npm install`
  18 +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
  19 +3. Run the app:
  20 + `npm run dev`
  1 +import React, { useState, useEffect, useRef } from 'react';
  2 +import { ImageItem } from '../types';
  3 +import { X, Upload, Save, Trash2, ThumbsUp } from 'lucide-react';
  4 +
  5 +interface AdminModalProps {
  6 + isOpen: boolean;
  7 + onClose: () => void;
  8 + onSave: (image: ImageItem) => void;
  9 + onDelete?: (id: string) => void;
  10 + initialData?: ImageItem | null;
  11 +}
  12 +
  13 +const AdminModal: React.FC<AdminModalProps> = ({ isOpen, onClose, onSave, onDelete, initialData }) => {
  14 + const fileInputRef = useRef<HTMLInputElement>(null);
  15 +
  16 + const [formData, setFormData] = useState<Partial<ImageItem>>({
  17 + prompt: '',
  18 + width: 1024,
  19 + height: 1024,
  20 + num_inference_steps: 20,
  21 + guidance_scale: 7.5,
  22 + seed: 12345,
  23 + url: '',
  24 + likes: 0,
  25 + });
  26 +
  27 + useEffect(() => {
  28 + if (isOpen) {
  29 + if (initialData) {
  30 + setFormData({ ...initialData });
  31 + } else {
  32 + // Reset form for new entry
  33 + setFormData({
  34 + prompt: '',
  35 + width: 1024,
  36 + height: 1024,
  37 + num_inference_steps: 20,
  38 + guidance_scale: 7.5,
  39 + seed: Math.floor(Math.random() * 1000000),
  40 + url: '',
  41 + likes: 0,
  42 + });
  43 + }
  44 + }
  45 + }, [isOpen, initialData]);
  46 +
  47 + if (!isOpen) return null;
  48 +
  49 + const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
  50 + const file = e.target.files?.[0];
  51 + if (file) {
  52 + const reader = new FileReader();
  53 + reader.onloadend = () => {
  54 + setFormData(prev => ({ ...prev, url: reader.result as string }));
  55 + };
  56 + reader.readAsDataURL(file);
  57 + }
  58 + };
  59 +
  60 + const handleSubmit = (e: React.FormEvent) => {
  61 + e.preventDefault();
  62 + if (!formData.url) {
  63 + alert("Please upload an image or provide a URL");
  64 + return;
  65 + }
  66 +
  67 + const imageItem: ImageItem = {
  68 + id: initialData?.id || Date.now().toString(),
  69 + createdAt: initialData?.createdAt || Date.now(),
  70 + prompt: formData.prompt || 'Untitled',
  71 + width: Number(formData.width),
  72 + height: Number(formData.height),
  73 + num_inference_steps: Number(formData.num_inference_steps),
  74 + guidance_scale: Number(formData.guidance_scale),
  75 + seed: Number(formData.seed),
  76 + url: formData.url,
  77 + isMock: true,
  78 + likes: Number(formData.likes || 0), // Admin Override Likes
  79 + authorId: initialData?.authorId || 'ADMIN',
  80 + isLikedByCurrentUser: initialData?.isLikedByCurrentUser,
  81 + };
  82 +
  83 + onSave(imageItem);
  84 + onClose();
  85 + };
  86 +
  87 + return (
  88 + <div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
  89 + <div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose} />
  90 +
  91 + <div className="relative bg-white dark:bg-gray-900 w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
  92 + <div className="flex justify-between items-center p-6 border-b border-gray-100 dark:border-gray-800">
  93 + <h2 className="text-xl font-bold text-gray-800 dark:text-white">
  94 + {initialData ? 'Edit Image Details' : 'Upload New Image'}
  95 + </h2>
  96 + <button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full">
  97 + <X size={20} />
  98 + </button>
  99 + </div>
  100 +
  101 + <div className="overflow-y-auto p-6">
  102 + <form id="admin-form" onSubmit={handleSubmit} className="space-y-6">
  103 + {/* Image Upload/URL Section */}
  104 + <div className="space-y-3">
  105 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Image Source</label>
  106 +
  107 + {/* Preview */}
  108 + <div className="flex flex-col items-center justify-center">
  109 + <div
  110 + className="relative w-full h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-xl flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 transition-colors overflow-hidden group"
  111 + onClick={() => fileInputRef.current?.click()}
  112 + >
  113 + {formData.url ? (
  114 + <img src={formData.url} alt="Preview" className="w-full h-full object-contain" />
  115 + ) : (
  116 + <div className="flex flex-col items-center text-gray-400">
  117 + <Upload size={32} className="mb-2" />
  118 + <p className="text-sm">Click to upload or paste URL below</p>
  119 + </div>
  120 + )}
  121 + <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
  122 + <p className="text-white font-medium">Change Image</p>
  123 + </div>
  124 + </div>
  125 + <input
  126 + type="file"
  127 + ref={fileInputRef}
  128 + onChange={handleImageUpload}
  129 + accept="image/*"
  130 + className="hidden"
  131 + />
  132 + </div>
  133 +
  134 + {/* URL Input */}
  135 + <input
  136 + type="text"
  137 + value={formData.url}
  138 + onChange={(e) => setFormData({...formData, url: e.target.value})}
  139 + placeholder="Or paste image URL here..."
  140 + className="w-full p-2 text-sm bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg"
  141 + />
  142 + </div>
  143 +
  144 + {/* Prompt */}
  145 + <div>
  146 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Prompt</label>
  147 + <textarea
  148 + value={formData.prompt}
  149 + onChange={(e) => setFormData({...formData, prompt: e.target.value})}
  150 + className="w-full p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-black dark:focus:ring-white outline-none min-h-[100px]"
  151 + placeholder="Enter the prompt used for this image..."
  152 + required
  153 + />
  154 + </div>
  155 +
  156 + {/* Parameters Grid */}
  157 + <div className="grid grid-cols-2 gap-4">
  158 + <div>
  159 + <label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Width</label>
  160 + <input
  161 + type="number"
  162 + value={formData.width}
  163 + onChange={(e) => setFormData({...formData, width: Number(e.target.value)})}
  164 + className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
  165 + />
  166 + </div>
  167 + <div>
  168 + <label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Height</label>
  169 + <input
  170 + type="number"
  171 + value={formData.height}
  172 + onChange={(e) => setFormData({...formData, height: Number(e.target.value)})}
  173 + className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
  174 + />
  175 + </div>
  176 + <div>
  177 + <label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Steps</label>
  178 + <input
  179 + type="number"
  180 + value={formData.num_inference_steps}
  181 + onChange={(e) => setFormData({...formData, num_inference_steps: Number(e.target.value)})}
  182 + className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
  183 + />
  184 + </div>
  185 + <div>
  186 + <label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Guidance Scale</label>
  187 + <input
  188 + type="number"
  189 + step="0.1"
  190 + value={formData.guidance_scale}
  191 + onChange={(e) => setFormData({...formData, guidance_scale: Number(e.target.value)})}
  192 + className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
  193 + />
  194 + </div>
  195 + <div>
  196 + <label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Seed</label>
  197 + <input
  198 + type="number"
  199 + value={formData.seed}
  200 + onChange={(e) => setFormData({...formData, seed: Number(e.target.value)})}
  201 + className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
  202 + />
  203 + </div>
  204 +
  205 + {/* ADMIN ONLY: Likes Editor */}
  206 + <div>
  207 + <label className="block text-xs font-bold text-purple-600 uppercase mb-1 flex items-center gap-1">
  208 + <ThumbsUp size={12} /> Likes (Admin Override)
  209 + </label>
  210 + <input
  211 + type="number"
  212 + value={formData.likes}
  213 + onChange={(e) => setFormData({...formData, likes: Number(e.target.value)})}
  214 + className="w-full p-2 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded font-bold text-purple-700 dark:text-purple-300"
  215 + />
  216 + </div>
  217 + </div>
  218 + </form>
  219 + </div>
  220 +
  221 + <div className="p-6 border-t border-gray-100 dark:border-gray-800 flex justify-between bg-gray-50 dark:bg-gray-900">
  222 + {initialData && onDelete ? (
  223 + <button
  224 + type="button"
  225 + onClick={() => {
  226 + if(confirm('Are you sure you want to delete this image?')) {
  227 + onDelete(initialData.id);
  228 + onClose();
  229 + }
  230 + }}
  231 + className="flex items-center gap-2 px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
  232 + >
  233 + <Trash2 size={18} />
  234 + <span>Delete</span>
  235 + </button>
  236 + ) : <div />}
  237 +
  238 + <div className="flex gap-3">
  239 + <button
  240 + type="button"
  241 + onClick={onClose}
  242 + className="px-6 py-2 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors"
  243 + >
  244 + Cancel
  245 + </button>
  246 + <button
  247 + type="submit"
  248 + form="admin-form"
  249 + className="flex items-center gap-2 px-6 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg hover:opacity-90 transition-opacity font-medium"
  250 + >
  251 + <Save size={18} />
  252 + <span>Save Image</span>
  253 + </button>
  254 + </div>
  255 + </div>
  256 + </div>
  257 + </div>
  258 + );
  259 +};
  260 +
  261 +export default AdminModal;
  1 +import React, { useState } from 'react';
  2 +import { User, Lock } from 'lucide-react';
  3 +
  4 +interface AuthModalProps {
  5 + isOpen: boolean;
  6 + onLogin: (employeeId: string) => void;
  7 +}
  8 +
  9 +const AuthModal: React.FC<AuthModalProps> = ({ isOpen, onLogin }) => {
  10 + const [inputId, setInputId] = useState('');
  11 + const [error, setError] = useState('');
  12 +
  13 + if (!isOpen) return null;
  14 +
  15 + const handleSubmit = (e: React.FormEvent) => {
  16 + e.preventDefault();
  17 + // Validate 8-digit requirement
  18 + const regex = /^\d{8}$/;
  19 + if (!regex.test(inputId)) {
  20 + setError('工号必须是8位数字');
  21 + return;
  22 + }
  23 + onLogin(inputId);
  24 + };
  25 +
  26 + return (
  27 + <div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
  28 + {/* Non-dismissible backdrop */}
  29 + <div className="absolute inset-0 bg-black/90 backdrop-blur-md" />
  30 +
  31 + <div className="relative bg-white dark:bg-gray-900 w-full max-w-md rounded-2xl shadow-2xl p-8 animate-fade-in text-center">
  32 + <div className="w-16 h-16 bg-black dark:bg-white rounded-full flex items-center justify-center mx-auto mb-6">
  33 + <Lock className="text-white dark:text-black" size={32} />
  34 + </div>
  35 +
  36 + <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
  37 + 欢迎来到 艺云-DESIGN
  38 + </h2>
  39 + <p className="text-gray-500 mb-8">
  40 + 请输入您的8位工号以访问平台功能
  41 + </p>
  42 +
  43 + <form onSubmit={handleSubmit} className="space-y-4">
  44 + <div className="relative">
  45 + <User className="absolute left-3 top-3 text-gray-400" size={20} />
  46 + <input
  47 + type="text"
  48 + value={inputId}
  49 + onChange={(e) => {
  50 + setInputId(e.target.value);
  51 + setError('');
  52 + }}
  53 + placeholder="请输入8位工号 (例如: 10023456)"
  54 + className="w-full pl-10 pr-4 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-black dark:focus:ring-white outline-none transition-all font-mono"
  55 + maxLength={8}
  56 + />
  57 + </div>
  58 +
  59 + {error && (
  60 + <p className="text-red-500 text-sm text-left pl-2">{error}</p>
  61 + )}
  62 +
  63 + <button
  64 + type="submit"
  65 + className="w-full py-3 bg-black dark:bg-white text-white dark:text-black rounded-xl font-bold hover:opacity-90 transition-opacity"
  66 + >
  67 + 进入系统
  68 + </button>
  69 + </form>
  70 + </div>
  71 + </div>
  72 + );
  73 +};
  74 +
  75 +export default AuthModal;
  1 +import React from 'react';
  2 +import { ImageItem, ImageGenerationParams } from '../types';
  3 +import { X, Copy, Download, Edit3, Heart, Zap } from 'lucide-react';
  4 +
  5 +interface DetailModalProps {
  6 + image: ImageItem | null;
  7 + onClose: () => void;
  8 + onEdit?: (image: ImageItem) => void;
  9 + onGenerateSimilar?: (params: ImageGenerationParams) => void;
  10 +}
  11 +
  12 +const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGenerateSimilar }) => {
  13 + if (!image) return null;
  14 +
  15 + const copyToClipboard = (text: string) => {
  16 + navigator.clipboard.writeText(text);
  17 + };
  18 +
  19 + const handleGenerateSimilar = () => {
  20 + if (onGenerateSimilar) {
  21 + const params: ImageGenerationParams = {
  22 + prompt: image.prompt,
  23 + width: image.width,
  24 + height: image.height,
  25 + num_inference_steps: image.num_inference_steps,
  26 + guidance_scale: image.guidance_scale,
  27 + seed: image.seed
  28 + };
  29 + onGenerateSimilar(params);
  30 + onClose();
  31 + }
  32 + };
  33 +
  34 + return (
  35 + <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
  36 + <div className="absolute inset-0 bg-black/90 backdrop-blur-sm transition-opacity" onClick={onClose}/>
  37 +
  38 + <div className="relative bg-white dark:bg-gray-900 w-full max-w-6xl max-h-[90vh] rounded-2xl overflow-hidden shadow-2xl flex flex-col md:flex-row animate-fade-in">
  39 + <button onClick={onClose} className="absolute top-4 right-4 z-10 p-2 bg-black/50 rounded-full text-white md:hidden"><X size={20} /></button>
  40 +
  41 + <div className="w-full md:w-2/3 bg-black flex items-center justify-center overflow-hidden h-[50vh] md:h-auto relative group">
  42 + <img src={image.url} alt={image.prompt} className="max-w-full max-h-full object-contain" />
  43 + </div>
  44 +
  45 + <div className="w-full md:w-1/3 p-6 md:p-8 flex flex-col overflow-y-auto">
  46 + <div className="flex justify-between items-start mb-6">
  47 + <h2 className="text-2xl font-bold text-gray-800 dark:text-white">参数详情</h2>
  48 + <div className="flex gap-2">
  49 + {onEdit && (
  50 + <button
  51 + onClick={() => onEdit(image)}
  52 + className="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 rounded-full transition-colors"
  53 + title="管理"
  54 + >
  55 + <Edit3 size={18} />
  56 + </button>
  57 + )}
  58 + <button onClick={onClose} className="hidden md:block p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"><X size={24} /></button>
  59 + </div>
  60 + </div>
  61 +
  62 + <div className="space-y-6">
  63 + <div className="flex items-center gap-2 text-red-500 font-medium">
  64 + <Heart size={18} fill="currentColor" />
  65 + <span>{image.likes || 0} Likes</span>
  66 + </div>
  67 +
  68 + <div>
  69 + <label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">提示词 (Prompt)</label>
  70 + <div className="relative group">
  71 + <p className="text-gray-700 dark:text-gray-200 text-sm leading-relaxed p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700">
  72 + {image.prompt}
  73 + </p>
  74 + <button
  75 + onClick={() => copyToClipboard(image.prompt)}
  76 + className="absolute top-2 right-2 p-1.5 bg-white dark:bg-gray-700 rounded-md shadow-sm opacity-0 group-hover:opacity-100 transition-opacity text-gray-500 hover:text-blue-500"
  77 + title="复制提示词"
  78 + >
  79 + <Copy size={14} />
  80 + </button>
  81 + </div>
  82 + </div>
  83 +
  84 + <div className="grid grid-cols-2 gap-4">
  85 + <div>
  86 + <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">分辨率</label>
  87 + <p className="text-gray-800 dark:text-gray-200 font-mono">{image.width} x {image.height}</p>
  88 + </div>
  89 + <div>
  90 + <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">随机种子</label>
  91 + <p className="text-gray-800 dark:text-gray-200 font-mono">{image.seed}</p>
  92 + </div>
  93 + <div>
  94 + <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">生成步数</label>
  95 + <p className="text-gray-800 dark:text-gray-200 font-mono">{image.num_inference_steps}</p>
  96 + </div>
  97 + <div>
  98 + <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">引导系数</label>
  99 + <p className="text-gray-800 dark:text-gray-200 font-mono">{image.guidance_scale.toFixed(1)}</p>
  100 + </div>
  101 + </div>
  102 +
  103 + <div className="pt-6 mt-auto space-y-3">
  104 + <button
  105 + onClick={handleGenerateSimilar}
  106 + className="flex items-center justify-center w-full py-3 bg-purple-600 text-white rounded-xl font-bold hover:bg-purple-700 transition-colors gap-2 shadow-lg shadow-purple-200 dark:shadow-none"
  107 + >
  108 + <Zap size={18} fill="currentColor" />
  109 + 生成同款
  110 + </button>
  111 +
  112 + <a
  113 + href={image.url}
  114 + target="_blank"
  115 + rel="noopener noreferrer"
  116 + download={`z-image-${image.id}.png`}
  117 + className="flex items-center justify-center w-full py-3 bg-black dark:bg-white text-white dark:text-black rounded-xl font-medium hover:opacity-90 transition-opacity gap-2"
  118 + >
  119 + <Download size={18} />
  120 + 下载原图
  121 + </a>
  122 + </div>
  123 + </div>
  124 + </div>
  125 + </div>
  126 + </div>
  127 + );
  128 +};
  129 +
  130 +export default DetailModal;
  1 +import React from 'react';
  2 +import { ImageItem } from '../types';
  3 +import { Clock } from 'lucide-react';
  4 +
  5 +interface HistoryBarProps {
  6 + images: ImageItem[];
  7 + onSelect: (image: ImageItem) => void;
  8 +}
  9 +
  10 +const HistoryBar: React.FC<HistoryBarProps> = ({ images, onSelect }) => {
  11 + if (images.length === 0) return null;
  12 +
  13 + return (
  14 + <div className="fixed bottom-24 left-6 z-40 max-w-[calc(100vw-48px)] md:max-w-xs animate-fade-in pointer-events-auto">
  15 + <div className="flex items-center gap-2 mb-2 px-1">
  16 + <Clock size={14} className="text-gray-400" />
  17 + <span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">我的生成记录</span>
  18 + </div>
  19 + <div className="flex gap-2 overflow-x-auto no-scrollbar pb-2 mask-linear-right">
  20 + {images.map((img) => (
  21 + <button
  22 + key={img.id}
  23 + onClick={() => onSelect(img)}
  24 + className="flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 border-white dark:border-gray-800 shadow-sm hover:scale-105 hover:border-purple-400 transition-all"
  25 + >
  26 + <img
  27 + src={img.url}
  28 + alt="History"
  29 + className="w-full h-full object-cover"
  30 + loading="lazy"
  31 + />
  32 + </button>
  33 + ))}
  34 + </div>
  35 + </div>
  36 + );
  37 +};
  38 +
  39 +export default HistoryBar;
  1 +import React, { useState } from 'react';
  2 +import { ImageItem } from '../types';
  3 +import { Download, Heart } from 'lucide-react';
  4 +
  5 +interface ImageCardProps {
  6 + image: ImageItem;
  7 + onClick: (image: ImageItem) => void;
  8 + onLike: (image: ImageItem) => void;
  9 + currentUser?: string;
  10 +}
  11 +
  12 +const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUser }) => {
  13 + const [isLoaded, setIsLoaded] = useState(false);
  14 + const [isHovered, setIsHovered] = useState(false);
  15 +
  16 + const handleLike = (e: React.MouseEvent) => {
  17 + e.stopPropagation();
  18 + onLike(image);
  19 + };
  20 +
  21 + return (
  22 + <div
  23 + className="group relative mb-4 break-inside-avoid rounded-2xl overflow-hidden bg-gray-200 cursor-zoom-in shadow-sm hover:shadow-xl transition-all duration-300"
  24 + onClick={() => onClick(image)}
  25 + onMouseEnter={() => setIsHovered(true)}
  26 + onMouseLeave={() => setIsHovered(false)}
  27 + >
  28 + {/* Placeholder / Skeleton */}
  29 + {!isLoaded && (
  30 + <div className="absolute inset-0 bg-gray-200 animate-pulse min-h-[200px]" />
  31 + )}
  32 +
  33 + {/* Main Image */}
  34 + <img
  35 + src={image.url}
  36 + alt={image.prompt}
  37 + className={`w-full h-auto object-cover transition-transform duration-700 ease-in-out group-hover:scale-105 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
  38 + onLoad={() => setIsLoaded(true)}
  39 + loading="lazy"
  40 + />
  41 +
  42 + {/* Hover Overlay */}
  43 + <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4 md:p-5">
  44 +
  45 + {/* Top Right: Download */}
  46 + <div className="absolute top-4 right-4 translate-y-[-10px] opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300 delay-75">
  47 + <button
  48 + className="p-2 bg-white/20 backdrop-blur-md hover:bg-white/40 rounded-full text-white transition-colors"
  49 + title="Download"
  50 + onClick={(e) => {
  51 + e.stopPropagation();
  52 + const link = document.createElement('a');
  53 + link.href = image.url;
  54 + link.download = `z-image-${image.id}.png`;
  55 + document.body.appendChild(link);
  56 + link.click();
  57 + document.body.removeChild(link);
  58 + }}
  59 + >
  60 + <Download size={16} />
  61 + </button>
  62 + </div>
  63 +
  64 + {/* Content Info */}
  65 + <div className="text-white transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
  66 + <p className="font-medium text-sm line-clamp-2 mb-2 text-shadow-sm">
  67 + {image.prompt}
  68 + </p>
  69 +
  70 + <div className="flex justify-between items-end">
  71 + {/* Author Info */}
  72 + <div className="text-xs text-gray-300 font-mono flex flex-col gap-1">
  73 + <span className="opacity-75">ID: {image.authorId || 'UNKNOWN'}</span>
  74 + <span className="bg-white/10 px-1.5 py-0.5 rounded w-fit">{image.width}x{image.height}</span>
  75 + </div>
  76 +
  77 + {/* Like Button */}
  78 + <button
  79 + onClick={handleLike}
  80 + className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full backdrop-blur-md transition-all duration-200 ${image.isLikedByCurrentUser ? 'bg-red-500/80 text-white' : 'bg-white/20 text-white hover:bg-white/30'}`}
  81 + title={currentUser ? "Like this image" : "Login to like"}
  82 + >
  83 + <Heart size={14} fill={image.isLikedByCurrentUser ? "currentColor" : "none"} />
  84 + <span className="text-xs font-bold">{image.likes}</span>
  85 + </button>
  86 + </div>
  87 + </div>
  88 + </div>
  89 + </div>
  90 + );
  91 +};
  92 +
  93 +export default ImageCard;
  1 +import React, { useState, useEffect, KeyboardEvent } from 'react';
  2 +import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw } from 'lucide-react';
  3 +import { ImageGenerationParams } from '../types';
  4 +
  5 +interface InputBarProps {
  6 + onGenerate: (params: ImageGenerationParams) => void;
  7 + isGenerating: boolean;
  8 + incomingParams?: ImageGenerationParams | null; // For "Generate Similar"
  9 +}
  10 +
  11 +const ASPECT_RATIOS = [
  12 + { label: '1:1', w: 1024, h: 1024 },
  13 + { label: '16:9', w: 1024, h: 576 },
  14 + { label: '9:16', w: 576, h: 1024 },
  15 + { label: '4:3', w: 1024, h: 768 },
  16 + { label: '3:4', w: 768, h: 1024 },
  17 + { label: 'Custom', w: 0, h: 0 },
  18 +];
  19 +
  20 +const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams }) => {
  21 + const [prompt, setPrompt] = useState('');
  22 + const [showSettings, setShowSettings] = useState(false);
  23 +
  24 + // Parameters State
  25 + const [width, setWidth] = useState(1024);
  26 + const [height, setHeight] = useState(1024);
  27 + const [steps, setSteps] = useState(8);
  28 + const [guidance, setGuidance] = useState(0);
  29 + const [seed, setSeed] = useState(12345);
  30 + const [activeRatio, setActiveRatio] = useState('1:1');
  31 +
  32 + // Handle "Generate Similar" incoming data
  33 + useEffect(() => {
  34 + if (incomingParams) {
  35 + setPrompt(incomingParams.prompt);
  36 + setWidth(incomingParams.width);
  37 + setHeight(incomingParams.height);
  38 + setSteps(incomingParams.num_inference_steps);
  39 + setGuidance(incomingParams.guidance_scale);
  40 + setSeed(incomingParams.seed);
  41 +
  42 + // Match active ratio label
  43 + const matched = ASPECT_RATIOS.find(r => r.w === incomingParams.width && r.h === incomingParams.height);
  44 + setActiveRatio(matched ? matched.label : 'Custom');
  45 +
  46 + // Open settings so user can see what's loaded
  47 + setShowSettings(true);
  48 + }
  49 + }, [incomingParams]);
  50 +
  51 + useEffect(() => {
  52 + if (!incomingParams) {
  53 + setSeed(Math.floor(Math.random() * 1000000));
  54 + }
  55 + }, []);
  56 +
  57 + const handleGenerate = () => {
  58 + if (!prompt.trim() || isGenerating) return;
  59 +
  60 + const params: ImageGenerationParams = {
  61 + prompt,
  62 + width,
  63 + height,
  64 + num_inference_steps: steps,
  65 + guidance_scale: guidance,
  66 + seed,
  67 + };
  68 +
  69 + onGenerate(params);
  70 +
  71 + // Note: Prompt is NO LONGER cleared as per user request
  72 + // Regenerate seed for next time ONLY IF not explicit
  73 + // setSeed(Math.floor(Math.random() * 1000000));
  74 + };
  75 +
  76 + const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
  77 + if (e.key === 'Enter' && !e.shiftKey) {
  78 + e.preventDefault();
  79 + handleGenerate();
  80 + }
  81 + };
  82 +
  83 + const handleRatioSelect = (ratio: typeof ASPECT_RATIOS[0]) => {
  84 + setActiveRatio(ratio.label);
  85 + if (ratio.label !== 'Custom') {
  86 + setWidth(ratio.w);
  87 + setHeight(ratio.h);
  88 + }
  89 + };
  90 +
  91 + const randomizeSeed = () => {
  92 + setSeed(Math.floor(Math.random() * 10000000));
  93 + };
  94 +
  95 + return (
  96 + <div className="fixed bottom-0 left-0 right-0 p-4 md:p-6 z-50 flex flex-col items-center pointer-events-none">
  97 +
  98 + {/* Advanced Settings Panel */}
  99 + {showSettings && (
  100 + <div className="pointer-events-auto w-full max-w-2xl bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border border-gray-200 dark:border-gray-700 rounded-2xl shadow-2xl mb-4 p-5 animate-fade-in flex flex-col gap-5">
  101 + <div className="flex justify-between items-center border-b border-gray-200 dark:border-gray-700 pb-3">
  102 + <h3 className="font-bold text-gray-800 dark:text-white flex items-center gap-2">
  103 + <Sliders size={18} className="text-purple-500" />
  104 + 生成参数设置 {incomingParams && <span className="text-xs bg-purple-100 text-purple-600 px-2 py-0.5 rounded-full ml-2">已加载同款参数</span>}
  105 + </h3>
  106 + <button onClick={() => setShowSettings(false)} className="text-gray-500 hover:text-gray-800 dark:hover:text-white p-1">
  107 + <X size={18} />
  108 + </button>
  109 + </div>
  110 +
  111 + <div className="space-y-2">
  112 + <label className="text-xs font-semibold text-gray-500 uppercase">分辨率 (宽高比)</label>
  113 + <div className="flex flex-wrap gap-2">
  114 + {ASPECT_RATIOS.map((r) => (
  115 + <button
  116 + key={r.label}
  117 + onClick={() => handleRatioSelect(r)}
  118 + className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border ${
  119 + activeRatio === r.label
  120 + ? 'bg-black dark:bg-white text-white dark:text-black border-transparent shadow-sm'
  121 + : 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
  122 + }`}
  123 + >
  124 + {r.label}
  125 + </button>
  126 + ))}
  127 + </div>
  128 + {activeRatio === 'Custom' && (
  129 + <div className="flex gap-4 mt-2 animate-fade-in">
  130 + <div className="flex items-center gap-2">
  131 + <span className="text-xs text-gray-400">W:</span>
  132 + <input
  133 + type="number"
  134 + min="64" max="2048"
  135 + value={width}
  136 + onChange={(e) => setWidth(Number(e.target.value))}
  137 + className="w-20 p-1 bg-gray-50 dark:bg-gray-800 border dark:border-gray-700 rounded text-center text-sm"
  138 + />
  139 + </div>
  140 + <div className="flex items-center gap-2">
  141 + <span className="text-xs text-gray-400">H:</span>
  142 + <input
  143 + type="number"
  144 + min="64" max="2048"
  145 + value={height}
  146 + onChange={(e) => setHeight(Number(e.target.value))}
  147 + className="w-20 p-1 bg-gray-50 dark:bg-gray-800 border dark:border-gray-700 rounded text-center text-sm"
  148 + />
  149 + </div>
  150 + </div>
  151 + )}
  152 + </div>
  153 +
  154 + <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  155 + <div className="space-y-2">
  156 + <div className="flex justify-between">
  157 + <label className="text-xs font-semibold text-gray-500 uppercase">生成步数 (Steps)</label>
  158 + <span className="text-xs font-mono text-gray-800 dark:text-gray-200">{steps}</span>
  159 + </div>
  160 + <input
  161 + type="range"
  162 + min="6"
  163 + max="12"
  164 + step="1"
  165 + value={steps}
  166 + onChange={(e) => setSteps(Number(e.target.value))}
  167 + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-black dark:accent-white"
  168 + />
  169 + <div className="flex justify-between text-[10px] text-gray-400">
  170 + <span>6</span>
  171 + <span>12</span>
  172 + </div>
  173 + </div>
  174 +
  175 + <div className="space-y-2">
  176 + <label className="text-xs font-semibold text-gray-500 uppercase block">引导系数 (Guidance)</label>
  177 + <div className="flex items-center gap-2">
  178 + <input
  179 + type="number"
  180 + min="0"
  181 + max="10"
  182 + step="0.1"
  183 + value={guidance}
  184 + onChange={(e) => setGuidance(Number(e.target.value))}
  185 + className="w-full p-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
  186 + />
  187 + </div>
  188 + </div>
  189 + </div>
  190 +
  191 + <div className="space-y-2">
  192 + <label className="text-xs font-semibold text-gray-500 uppercase">随机种子 (Seed)</label>
  193 + <div className="flex gap-2">
  194 + <input
  195 + type="number"
  196 + value={seed}
  197 + onChange={(e) => setSeed(Number(e.target.value))}
  198 + className="flex-1 p-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg font-mono text-sm"
  199 + />
  200 + <button
  201 + onClick={randomizeSeed}
  202 + className="p-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors text-gray-600 dark:text-gray-300"
  203 + title="随机生成"
  204 + >
  205 + <Dices size={20} />
  206 + </button>
  207 + </div>
  208 + </div>
  209 + </div>
  210 + )}
  211 +
  212 + {/* Main Input Capsule */}
  213 + <div className="pointer-events-auto w-full max-w-2xl transition-all duration-300 ease-in-out transform hover:scale-[1.01]">
  214 + <div className="relative group">
  215 + <div className="absolute inset-0 bg-white/80 dark:bg-black/80 backdrop-blur-2xl rounded-full shadow-2xl border border-white/20 dark:border-white/10" />
  216 +
  217 + <div className="relative flex items-center p-2 pr-2">
  218 + <button
  219 + onClick={() => setShowSettings(!showSettings)}
  220 + className={`flex-shrink-0 w-10 h-10 md:w-12 md:h-12 flex items-center justify-center rounded-full ml-1 transition-all ${
  221 + showSettings
  222 + ? 'bg-purple-600 text-white shadow-lg rotate-90'
  223 + : 'bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
  224 + }`}
  225 + >
  226 + <Sliders size={20} />
  227 + </button>
  228 +
  229 + <input
  230 + type="text"
  231 + value={prompt}
  232 + onChange={(e) => setPrompt(e.target.value)}
  233 + onKeyDown={handleKeyDown}
  234 + disabled={isGenerating}
  235 + placeholder="描述您的创意内容..."
  236 + className="flex-grow bg-transparent border-none outline-none px-4 py-3 text-gray-800 dark:text-white placeholder-gray-400 text-base md:text-lg"
  237 + />
  238 +
  239 + <div className="flex items-center gap-1">
  240 + {/* Optional: Quick Regenerate button if prompt exists */}
  241 + {prompt.trim() && !isGenerating && (
  242 + <button
  243 + onClick={handleGenerate}
  244 + className="p-3 text-gray-400 hover:text-purple-500 transition-colors"
  245 + title="重新生成"
  246 + >
  247 + <RefreshCw size={18} />
  248 + </button>
  249 + )}
  250 +
  251 + <button
  252 + onClick={handleGenerate}
  253 + disabled={!prompt.trim() || isGenerating}
  254 + className={`
  255 + flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full transition-all duration-200
  256 + ${prompt.trim() && !isGenerating
  257 + ? 'bg-black dark:bg-white text-white dark:text-black hover:scale-105 active:scale-95 shadow-md'
  258 + : 'bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed'}
  259 + `}
  260 + >
  261 + {isGenerating ? (
  262 + <Loader2 className="animate-spin" size={20} />
  263 + ) : (
  264 + <ArrowUp size={20} strokeWidth={3} />
  265 + )}
  266 + </button>
  267 + </div>
  268 + </div>
  269 + </div>
  270 + </div>
  271 + </div>
  272 + );
  273 +};
  274 +
  275 +export default InputBar;
  1 +import React from 'react';
  2 +import { ImageItem } from '../types';
  3 +import ImageCard from './ImageCard';
  4 +
  5 +interface MasonryGridProps {
  6 + images: ImageItem[];
  7 + onImageClick: (image: ImageItem) => void;
  8 + onLike: (image: ImageItem) => void;
  9 + currentUser?: string;
  10 +}
  11 +
  12 +const MasonryGrid: React.FC<MasonryGridProps> = ({ images, onImageClick, onLike, currentUser }) => {
  13 + return (
  14 + <div className="w-full px-4 md:px-8 py-6">
  15 + <div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4">
  16 + {images.map((img) => (
  17 + <ImageCard
  18 + key={img.id}
  19 + image={img}
  20 + onClick={onImageClick}
  21 + onLike={onLike}
  22 + currentUser={currentUser}
  23 + />
  24 + ))}
  25 + </div>
  26 +
  27 + {images.length === 0 && (
  28 + <div className="flex flex-col items-center justify-center h-64 text-gray-400">
  29 + <p>暂无图片,快来生成第一张吧!</p>
  30 + </div>
  31 + )}
  32 + </div>
  33 + );
  34 +};
  35 +
  36 +export default MasonryGrid;
  1 +import { ImageItem } from './types';
  2 +
  3 +export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009";
  4 +
  5 +const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim();
  6 +const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0
  7 + ? ENV_PROXY_URL
  8 + : "http://localhost:9009";
  9 +
  10 +export const API_BASE_URL = DEFAULT_PROXY_URL;
  11 +
  12 +export const API_ENDPOINTS = API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL
  13 + ? [Z_IMAGE_DIRECT_BASE_URL]
  14 + : [API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL];
  15 +
  16 +// This is the specific Administrator ID requested
  17 +export const ADMIN_ID = '86427531';
  18 +
  19 +export const DEFAULT_PARAMS = {
  20 + height: 1024,
  21 + width: 1024,
  22 + num_inference_steps: 20,
  23 + guidance_scale: 7.5,
  24 +};
  25 +
  26 +// ==============================================================================
  27 +// VIRTUAL FOLDER (DATABASE)
  28 +// ==============================================================================
  29 +// This array represents the "Folder" of images on your server.
  30 +// The app sorts these by 'likes' automatically.
  31 +// To update this "Folder":
  32 +// 1. Login as 86427531.
  33 +// 2. Add/Delete/Edit images in the UI.
  34 +// 3. Click "Export Config" and overwrite this file.
  35 +// ==============================================================================
  36 +
  37 +export const SHOWCASE_IMAGES: ImageItem[] = [
  38 + {
  39 + "id": "showcase-3",
  40 + "url": "https://images.unsplash.com/photo-1655720357761-f18ea9e5e7e6?q=80&w=1000&auto=format&fit=crop",
  41 + "prompt": "3D render of a futuristic glass architecture, parametric design, minimal, white background",
  42 + "width": 1024,
  43 + "height": 1024,
  44 + "num_inference_steps": 20,
  45 + "guidance_scale": 6,
  46 + "seed": 1003,
  47 + "createdAt": 1715000000000,
  48 + "authorId": "OFFICIAL",
  49 + "likes": 3,
  50 + "isMock": true
  51 + },
  52 + {
  53 + "id": "showcase-4",
  54 + "url": "https://images.unsplash.com/photo-1614730341194-75c60740a0d3?q=80&w=1000&auto=format&fit=crop",
  55 + "prompt": "Macro photography of a soap bubble, iridescent colors, intricate patterns",
  56 + "width": 800,
  57 + "height": 800,
  58 + "num_inference_steps": 50,
  59 + "guidance_scale": 5,
  60 + "seed": 1004,
  61 + "createdAt": 1715000000000,
  62 + "authorId": "OFFICIAL",
  63 + "likes": 4,
  64 + "isMock": true
  65 + },
  66 + {
  67 + "id": "showcase-5",
  68 + "url": "https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?q=80&w=1000&auto=format&fit=crop",
  69 + "prompt": "Digital art, surreal landscape with floating islands, dreamy pastel colors",
  70 + "width": 800,
  71 + "height": 1200,
  72 + "num_inference_steps": 20,
  73 + "guidance_scale": 7.5,
  74 + "seed": 1005,
  75 + "createdAt": 1715000000000,
  76 + "authorId": "OFFICIAL",
  77 + "likes": 5,
  78 + "isMock": true
  79 + }
  80 +];
  1 +<!DOCTYPE html>
  2 +<html lang="en">
  3 + <head>
  4 + <meta charset="UTF-8" />
  5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6 + <title>Z-Image Generator</title>
  7 + <script src="https://cdn.tailwindcss.com"></script>
  8 + <script>
  9 + tailwind.config = {
  10 + theme: {
  11 + extend: {
  12 + fontFamily: {
  13 + sans: ['Inter', 'sans-serif'],
  14 + },
  15 + animation: {
  16 + 'fade-in': 'fadeIn 0.5s ease-out',
  17 + },
  18 + keyframes: {
  19 + fadeIn: {
  20 + '0%': { opacity: '0', transform: 'translateY(10px)' },
  21 + '100%': { opacity: '1', transform: 'translateY(0)' },
  22 + },
  23 + },
  24 + },
  25 + },
  26 + }
  27 + </script>
  28 + <style>
  29 + /* Hide scrollbar for Chrome, Safari and Opera */
  30 + .no-scrollbar::-webkit-scrollbar {
  31 + display: none;
  32 + }
  33 + /* Hide scrollbar for IE, Edge and Firefox */
  34 + .no-scrollbar {
  35 + -ms-overflow-style: none; /* IE and Edge */
  36 + scrollbar-width: none; /* Firefox */
  37 + }
  38 + </style>
  39 + <script type="importmap">
  40 +{
  41 + "imports": {
  42 + "lucide-react": "https://esm.sh/lucide-react@^0.561.0",
  43 + "react/": "https://esm.sh/react@^19.2.3/",
  44 + "react": "https://esm.sh/react@^19.2.3",
  45 + "react-dom/": "https://esm.sh/react-dom@^19.2.3/"
  46 + }
  47 +}
  48 +</script>
  49 +<link rel="stylesheet" href="/index.css">
  50 +</head>
  51 + <body class="bg-gray-50 text-gray-900 antialiased">
  52 + <div id="root"></div>
  53 + <script type="module" src="/index.tsx"></script>
  54 +</body>
  55 +</html>
  1 +import React from 'react';
  2 +import ReactDOM from 'react-dom/client';
  3 +import App from './App';
  4 +
  5 +const rootElement = document.getElementById('root');
  6 +if (!rootElement) {
  7 + throw new Error("Could not find root element to mount to");
  8 +}
  9 +
  10 +const root = ReactDOM.createRoot(rootElement);
  11 +root.render(
  12 + <React.StrictMode>
  13 + <App />
  14 + </React.StrictMode>
  15 +);
  1 +{
  2 + "name": "Z-Image Generator",
  3 + "description": "A masonry-style AI image gallery and generator interacting with a custom Z-Image server.",
  4 + "requestFramePermissions": []
  5 +}
  1 +{
  2 + "name": "z-image-generator",
  3 + "private": true,
  4 + "version": "0.0.0",
  5 + "type": "module",
  6 + "scripts": {
  7 + "dev": "vite",
  8 + "build": "vite build",
  9 + "preview": "vite preview"
  10 + },
  11 + "dependencies": {
  12 + "lucide-react": "^0.561.0",
  13 + "react": "^19.2.3",
  14 + "react-dom": "^19.2.3"
  15 + },
  16 + "devDependencies": {
  17 + "@types/node": "^22.14.0",
  18 + "@vitejs/plugin-react": "^5.0.0",
  19 + "typescript": "~5.8.2",
  20 + "vite": "^6.2.0"
  21 + }
  22 +}
  1 +import { API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL } from '../constants';
  2 +import { GalleryResponse, ImageItem } from '../types';
  3 +
  4 +export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => {
  5 + if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) {
  6 + return [];
  7 + }
  8 + const params = new URLSearchParams();
  9 + if (authorId) {
  10 + params.set('authorId', authorId);
  11 + }
  12 + const query = params.toString();
  13 + const response = await fetch(`${API_BASE_URL}/gallery${query ? `?${query}` : ''}`);
  14 +
  15 + if (!response.ok) {
  16 + const errorText = await response.text();
  17 + throw new Error(`Gallery fetch failed (${response.status}): ${errorText}`);
  18 + }
  19 +
  20 + const data: GalleryResponse = await response.json();
  21 + return data.images ?? [];
  22 +};
  1 +import { API_ENDPOINTS } from '../constants';
  2 +import { ImageGenerationParams, APIResponse, ImageItem } from '../types';
  3 +
  4 +export interface GenerateImageResult {
  5 + imageUrl: string;
  6 + galleryItem?: ImageItem;
  7 +}
  8 +
  9 +/**
  10 + * Calls the /generate endpoint of the Z-Image server.
  11 + * Returns the image URL (http link or base64 data URI) or throws an error.
  12 + */
  13 +export const generateImage = async (
  14 + params: ImageGenerationParams,
  15 + authorId?: string
  16 +): Promise<GenerateImageResult> => {
  17 + const payload = {
  18 + ...params,
  19 + output_format: 'url',
  20 + authorId,
  21 + };
  22 +
  23 + let lastError: unknown = null;
  24 +
  25 + for (const baseUrl of API_ENDPOINTS) {
  26 + try {
  27 + const response = await fetch(`${baseUrl}/generate`, {
  28 + method: 'POST',
  29 + headers: {
  30 + 'Content-Type': 'application/json',
  31 + },
  32 + body: JSON.stringify(payload),
  33 + });
  34 +
  35 + if (!response.ok) {
  36 + const errorText = await response.text();
  37 + throw new Error(`Server Error ${response.status}: ${errorText}`);
  38 + }
  39 +
  40 + const data: APIResponse = await response.json();
  41 +
  42 + const imageUrl = data.url
  43 + ? data.url
  44 + : data.image
  45 + ? `data:image/png;base64,${data.image}`
  46 + : null;
  47 +
  48 + if (!imageUrl) {
  49 + throw new Error('No image URL or data received in response');
  50 + }
  51 +
  52 + return {
  53 + imageUrl,
  54 + galleryItem: data.gallery_item,
  55 + };
  56 + } catch (error) {
  57 + console.warn(`Image generation via ${baseUrl} failed`, error);
  58 + lastError = error;
  59 + }
  60 + }
  61 +
  62 + console.error("Image generation failed after trying all endpoints.", lastError);
  63 + throw lastError instanceof Error ? lastError : new Error('Image generation failed');
  64 +};
  1 +{
  2 + "compilerOptions": {
  3 + "target": "ES2022",
  4 + "experimentalDecorators": true,
  5 + "useDefineForClassFields": false,
  6 + "module": "ESNext",
  7 + "lib": [
  8 + "ES2022",
  9 + "DOM",
  10 + "DOM.Iterable"
  11 + ],
  12 + "skipLibCheck": true,
  13 + "types": [
  14 + "node"
  15 + ],
  16 + "moduleResolution": "bundler",
  17 + "isolatedModules": true,
  18 + "moduleDetection": "force",
  19 + "allowJs": true,
  20 + "jsx": "react-jsx",
  21 + "paths": {
  22 + "@/*": [
  23 + "./*"
  24 + ]
  25 + },
  26 + "allowImportingTsExtensions": true,
  27 + "noEmit": true
  28 + }
  29 +}
  1 +export interface ImageGenerationParams {
  2 + prompt: string;
  3 + height: number;
  4 + width: number;
  5 + num_inference_steps: number;
  6 + guidance_scale: number;
  7 + seed: number;
  8 + output_format?: 'url' | 'base64';
  9 + authorId?: string;
  10 +}
  11 +
  12 +export interface ImageItem extends ImageGenerationParams {
  13 + id: string;
  14 + url: string; // base64 data URI or http URL
  15 + createdAt: number;
  16 +
  17 + // New fields for community features
  18 + authorId?: string; // The 8-digit employee ID
  19 + likes: number;
  20 + isLikedByCurrentUser?: boolean; // UI state
  21 +
  22 + isMock?: boolean; // True if it's a static showcase image
  23 +}
  24 +
  25 +export interface APIResponse {
  26 + image?: string; // Base64 string
  27 + url?: string; // Direct URL
  28 + time_taken: number;
  29 + error?: string;
  30 + gallery_item?: ImageItem;
  31 +}
  32 +
  33 +export interface UserProfile {
  34 + employeeId: string;
  35 + hasAccess: boolean;
  36 +}
  37 +
  38 +export interface GalleryResponse {
  39 + images: ImageItem[];
  40 +}
  1 +import path from 'path';
  2 +import { defineConfig, loadEnv } from 'vite';
  3 +import react from '@vitejs/plugin-react';
  4 +
  5 +export default defineConfig(({ mode }) => {
  6 + const env = loadEnv(mode, '.', '');
  7 + return {
  8 + server: {
  9 + port: 3000,
  10 + host: '0.0.0.0',
  11 + },
  12 + plugins: [react()],
  13 + define: {
  14 + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
  15 + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
  16 + },
  17 + resolve: {
  18 + alias: {
  19 + '@': path.resolve(__dirname, '.'),
  20 + }
  21 + }
  22 + };
  23 +});