Showing
22 changed files
with
2036 additions
and
0 deletions
backend/main.py
0 → 100644
| 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 | + ) |
test_z_image_client_2.py
0 → 100644
| 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 | + |
z-image-generator/.gitignore
0 → 100644
| 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? |
z-image-generator/App.tsx
0 → 100644
| 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; |
z-image-generator/README.md
0 → 100644
| 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` |
z-image-generator/components/AdminModal.tsx
0 → 100644
| 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; |
z-image-generator/components/AuthModal.tsx
0 → 100644
| 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; |
z-image-generator/components/DetailModal.tsx
0 → 100644
| 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; |
z-image-generator/components/HistoryBar.tsx
0 → 100644
| 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; |
z-image-generator/components/ImageCard.tsx
0 → 100644
| 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; |
z-image-generator/components/InputBar.tsx
0 → 100644
| 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; |
z-image-generator/components/MasonryGrid.tsx
0 → 100644
| 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; |
z-image-generator/constants.ts
0 → 100644
| 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 | +]; |
z-image-generator/index.html
0 → 100644
| 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> |
z-image-generator/index.tsx
0 → 100644
| 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 | +); |
z-image-generator/metadata.json
0 → 100644
z-image-generator/package.json
0 → 100644
| 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 | +} |
z-image-generator/services/galleryService.ts
0 → 100644
| 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 | +}; |
z-image-generator/services/imageService.ts
0 → 100644
| 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 | +}; |
z-image-generator/tsconfig.json
0 → 100644
| 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 | +} |
z-image-generator/types.ts
0 → 100644
| 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 | +} |
z-image-generator/vite.config.ts
0 → 100644
| 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 | +}); |
-
Please register or login to post a comment