Spaces:
Paused
Paused
github-actions[bot] commited on
Commit ·
e6ce630
0
Parent(s):
GitHub deploy: 8ac466cec7cb18a3cdc40223ab11ee9b5f5f569b
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .eslintrc.json +3 -0
- .github/workflows/deploy-to-hf-spaces.yaml +58 -0
- .gitignore +41 -0
- Dockerfile +42 -0
- README.md +50 -0
- app/api/__init__.py +0 -0
- app/api/config.py +17 -0
- app/api/main.py +24 -0
- app/api/models/__init__.py +0 -0
- app/api/models/database.py +13 -0
- app/api/routes/__init__.py +0 -0
- app/api/routes/auth.py +16 -0
- app/api/routes/enhance_prompt.py +13 -0
- app/api/routes/generate_image.py +159 -0
- app/api/services/__init__.py +0 -0
- app/api/services/openai_service.py +13 -0
- app/favicon.ico +0 -0
- app/globals.css +76 -0
- app/layout.tsx +28 -0
- app/page.tsx +150 -0
- app/password/page.tsx +64 -0
- components/Gallery/BatchMenu.tsx +67 -0
- components/Gallery/ImageBatch.tsx +157 -0
- components/Gallery/ImageCard.tsx +82 -0
- components/Gallery/ImageGrid.tsx +20 -0
- components/Header.tsx +47 -0
- components/ImageGenerator/AspectRatioSelector.tsx +34 -0
- components/ImageGenerator/GeneratorForm.tsx +157 -0
- components/ImageGenerator/ImageCountSlider.tsx +29 -0
- components/ImageGenerator/ModelSelector.tsx +33 -0
- components/ImageGenerator/PromptInput.tsx +101 -0
- components/Layout.tsx +15 -0
- components/MainContent.tsx +27 -0
- components/Navbar.tsx +72 -0
- components/SearchInput.tsx +17 -0
- components/Sidebar.tsx +59 -0
- components/ThemeToggle.tsx +31 -0
- components/User/CreditDisplay.tsx +1 -0
- components/ui/button.tsx +56 -0
- components/ui/input.tsx +25 -0
- components/ui/select.tsx +160 -0
- components/ui/slider.tsx +136 -0
- components/ui/tooltip.tsx +30 -0
- lib/utils.ts +6 -0
- middleware.ts +21 -0
- next.config.mjs +37 -0
- nyxbui.json +17 -0
- package-lock.json +0 -0
- package.json +50 -0
- pnpm-lock.yaml +0 -0
.eslintrc.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "next/core-web-vitals"
|
| 3 |
+
}
|
.github/workflows/deploy-to-hf-spaces.yaml
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy to HuggingFace Spaces
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- dev
|
| 7 |
+
- main
|
| 8 |
+
workflow_dispatch:
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
check-secret:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
outputs:
|
| 14 |
+
token-set: ${{ steps.check-key.outputs.defined }}
|
| 15 |
+
steps:
|
| 16 |
+
- id: check-key
|
| 17 |
+
env:
|
| 18 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 19 |
+
if: "${{ env.HF_TOKEN != '' }}"
|
| 20 |
+
run: echo "defined=true" >> $GITHUB_OUTPUT
|
| 21 |
+
|
| 22 |
+
deploy:
|
| 23 |
+
runs-on: ubuntu-latest
|
| 24 |
+
needs: [check-secret]
|
| 25 |
+
if: needs.check-secret.outputs.token-set == 'true'
|
| 26 |
+
env:
|
| 27 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 28 |
+
steps:
|
| 29 |
+
- name: Checkout repository
|
| 30 |
+
uses: actions/checkout@v4
|
| 31 |
+
|
| 32 |
+
- name: Remove git history
|
| 33 |
+
run: rm -rf .git
|
| 34 |
+
|
| 35 |
+
- name: Prepend YAML front matter to README.md
|
| 36 |
+
run: |
|
| 37 |
+
echo "---" > temp_readme.md
|
| 38 |
+
echo "title: Imagine" >> temp_readme.md
|
| 39 |
+
echo "emoji: 🐳" >> temp_readme.md
|
| 40 |
+
echo "colorFrom: purple" >> temp_readme.md
|
| 41 |
+
echo "colorTo: gray" >> temp_readme.md
|
| 42 |
+
echo "sdk: docker" >> temp_readme.md
|
| 43 |
+
echo "pinned: false" >> temp_readme.md
|
| 44 |
+
echo "app_port: 3000" >> temp_readme.md
|
| 45 |
+
echo "---" >> temp_readme.md
|
| 46 |
+
cat README.md >> temp_readme.md
|
| 47 |
+
mv temp_readme.md README.md
|
| 48 |
+
|
| 49 |
+
- name: Configure git
|
| 50 |
+
run: |
|
| 51 |
+
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
| 52 |
+
git config --global user.name "github-actions[bot]"
|
| 53 |
+
- name: Set up Git and push to Space
|
| 54 |
+
run: |
|
| 55 |
+
git init --initial-branch=main
|
| 56 |
+
git add .
|
| 57 |
+
git commit -m "GitHub deploy: ${{ github.sha }}"
|
| 58 |
+
git push --force https://arcticaurora:${HF_TOKEN}@huggingface.co/spaces/arcticaurora/imagine main
|
.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.js
|
| 7 |
+
.yarn/install-state.gz
|
| 8 |
+
|
| 9 |
+
# testing
|
| 10 |
+
/coverage
|
| 11 |
+
|
| 12 |
+
# next.js
|
| 13 |
+
/.next/
|
| 14 |
+
/out/
|
| 15 |
+
|
| 16 |
+
# production
|
| 17 |
+
/build
|
| 18 |
+
|
| 19 |
+
# misc
|
| 20 |
+
.DS_Store
|
| 21 |
+
*.pem
|
| 22 |
+
|
| 23 |
+
# debug
|
| 24 |
+
npm-debug.log*
|
| 25 |
+
yarn-debug.log*
|
| 26 |
+
yarn-error.log*
|
| 27 |
+
|
| 28 |
+
# local env files
|
| 29 |
+
.env*.local
|
| 30 |
+
|
| 31 |
+
# vercel
|
| 32 |
+
.vercel
|
| 33 |
+
|
| 34 |
+
# typescript
|
| 35 |
+
*.tsbuildinfo
|
| 36 |
+
next-env.d.ts
|
| 37 |
+
__pycache__/
|
| 38 |
+
.aider*
|
| 39 |
+
# ignore env file
|
| 40 |
+
.env*
|
| 41 |
+
test.py
|
Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Set up the Python environment
|
| 2 |
+
FROM python:3.12 AS python-base
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install Python dependencies
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Set up Node.js environment
|
| 10 |
+
FROM node:20 AS node-base
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Copy the frontend code
|
| 14 |
+
COPY package*.json ./
|
| 15 |
+
RUN npm install
|
| 16 |
+
COPY . .
|
| 17 |
+
|
| 18 |
+
# Combine Python and Node.js
|
| 19 |
+
FROM python-base
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Copy Node.js files and set up environment
|
| 23 |
+
COPY --from=node-base /app /app
|
| 24 |
+
COPY --from=node-base /usr/local/bin/node /usr/local/bin/
|
| 25 |
+
COPY --from=node-base /usr/local/lib/node_modules /usr/local/lib/node_modules
|
| 26 |
+
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
| 27 |
+
ln -s /usr/local/bin/node /usr/bin/node && \
|
| 28 |
+
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/bin/npm
|
| 29 |
+
|
| 30 |
+
# Copy the backend code
|
| 31 |
+
COPY app ./app
|
| 32 |
+
|
| 33 |
+
# Set up a non-root user
|
| 34 |
+
RUN useradd -m -u 1000 appuser
|
| 35 |
+
RUN chown -R appuser:appuser /app
|
| 36 |
+
USER appuser
|
| 37 |
+
|
| 38 |
+
# Expose the ports the apps run on
|
| 39 |
+
EXPOSE 3000 8000
|
| 40 |
+
|
| 41 |
+
# Start both the FastAPI backend and Next.js frontend in development mode.
|
| 42 |
+
CMD ["sh", "-c", "npm run dev & uvicorn app.api.main:app --host 0.0.0.0 --port 8000"]
|
README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Imagine
|
| 3 |
+
emoji: 🐳
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 3000
|
| 9 |
+
---
|
| 10 |
+
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
| 11 |
+
|
| 12 |
+
## Getting Started
|
| 13 |
+
|
| 14 |
+
First, install the dependencies:
|
| 15 |
+
```bash
|
| 16 |
+
npm install
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
Second, run the development server:
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
npm run dev
|
| 23 |
+
# or
|
| 24 |
+
yarn dev
|
| 25 |
+
# or
|
| 26 |
+
pnpm dev
|
| 27 |
+
# or
|
| 28 |
+
bun dev
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 32 |
+
|
| 33 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 34 |
+
|
| 35 |
+
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
| 36 |
+
|
| 37 |
+
## Learn More
|
| 38 |
+
|
| 39 |
+
To learn more about Next.js, take a look at the following resources:
|
| 40 |
+
|
| 41 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 42 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 43 |
+
|
| 44 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
| 45 |
+
|
| 46 |
+
## Deploy on Vercel
|
| 47 |
+
|
| 48 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 49 |
+
|
| 50 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
class Settings:
|
| 7 |
+
DB_USER = os.getenv("DB_USER")
|
| 8 |
+
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
| 9 |
+
DB_HOST = os.getenv("DB_HOST")
|
| 10 |
+
DB_PORT = os.getenv("DB_PORT")
|
| 11 |
+
DB_NAME = os.getenv("DB_NAME")
|
| 12 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 13 |
+
OPENAI_API_BASE = os.getenv("OPENAI_API_BASE")
|
| 14 |
+
NEXT_PUBLIC_API_URL = os.getenv("NEXT_PUBLIC_API_URL")
|
| 15 |
+
APP_PASSWORD = os.getenv("APP_PASSWORD")
|
| 16 |
+
|
| 17 |
+
settings = Settings()
|
app/api/main.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from .routes import generate_image, enhance_prompt, auth
|
| 4 |
+
|
| 5 |
+
app = FastAPI()
|
| 6 |
+
|
| 7 |
+
# Configure CORS
|
| 8 |
+
app.add_middleware(
|
| 9 |
+
CORSMiddleware,
|
| 10 |
+
allow_origins=["*"], # Adjust this in production
|
| 11 |
+
allow_credentials=True,
|
| 12 |
+
allow_methods=["*"],
|
| 13 |
+
allow_headers=["*"],
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# Include routers
|
| 17 |
+
app.include_router(generate_image.router, prefix="/api")
|
| 18 |
+
app.include_router(enhance_prompt.router, prefix="/api")
|
| 19 |
+
app.include_router(auth.router, prefix="/api")
|
| 20 |
+
|
| 21 |
+
@app.get("/")
|
| 22 |
+
async def root():
|
| 23 |
+
return {"message": "Welcome to the Imagine API"}
|
| 24 |
+
|
app/api/models/__init__.py
ADDED
|
File without changes
|
app/api/models/database.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import psycopg2
|
| 2 |
+
from ..config import settings
|
| 3 |
+
|
| 4 |
+
def get_db_connection():
|
| 5 |
+
conn = psycopg2.connect(
|
| 6 |
+
dbname=settings.DB_NAME,
|
| 7 |
+
user=settings.DB_USER,
|
| 8 |
+
password=settings.DB_PASSWORD,
|
| 9 |
+
host=settings.DB_HOST,
|
| 10 |
+
port=settings.DB_PORT,
|
| 11 |
+
sslmode='require'
|
| 12 |
+
)
|
| 13 |
+
return conn
|
app/api/routes/__init__.py
ADDED
|
File without changes
|
app/api/routes/auth.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from ..config import settings
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
class PasswordRequest(BaseModel):
|
| 8 |
+
password: str
|
| 9 |
+
|
| 10 |
+
@router.post("/check-password")
|
| 11 |
+
async def check_password(request: PasswordRequest):
|
| 12 |
+
correct_password = settings.APP_PASSWORD
|
| 13 |
+
if request.password == correct_password:
|
| 14 |
+
return {"success": True}
|
| 15 |
+
else:
|
| 16 |
+
raise HTTPException(status_code=401, detail="Incorrect password")
|
app/api/routes/enhance_prompt.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from ..services.openai_service import enhance_prompt
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
class PromptRequest(BaseModel):
|
| 8 |
+
prompt: str
|
| 9 |
+
|
| 10 |
+
@router.post("/enhance-prompt")
|
| 11 |
+
async def enhance_prompt_route(request: PromptRequest):
|
| 12 |
+
enhanced_prompt = enhance_prompt(request.prompt)
|
| 13 |
+
return {"enhancedPrompt": enhanced_prompt}
|
app/api/routes/generate_image.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
import requests
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
from ..models.database import get_db_connection
|
| 9 |
+
from ..config import settings
|
| 10 |
+
import psycopg2
|
| 11 |
+
from psycopg2.extras import RealDictCursor
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
class ImageGenerationRequest(BaseModel):
|
| 16 |
+
prompt: str
|
| 17 |
+
width: int
|
| 18 |
+
height: int
|
| 19 |
+
model: str
|
| 20 |
+
number_results: int
|
| 21 |
+
placeholderId: Optional[str] = None
|
| 22 |
+
|
| 23 |
+
class Image(BaseModel):
|
| 24 |
+
url: str
|
| 25 |
+
|
| 26 |
+
class Batch(BaseModel):
|
| 27 |
+
id: int
|
| 28 |
+
prompt: str
|
| 29 |
+
width: int
|
| 30 |
+
height: int
|
| 31 |
+
model: str
|
| 32 |
+
images: List[Image]
|
| 33 |
+
status: str
|
| 34 |
+
tempId: Optional[str] = None
|
| 35 |
+
createdAt: Optional[str] = None
|
| 36 |
+
|
| 37 |
+
async def insert_batch(prompt: str, width: int, height: int, model: str, urls: List[str]) -> int:
|
| 38 |
+
conn = get_db_connection()
|
| 39 |
+
try:
|
| 40 |
+
with conn.cursor() as cur:
|
| 41 |
+
cur.execute('BEGIN')
|
| 42 |
+
cur.execute(
|
| 43 |
+
'INSERT INTO batches (prompt, width, height, model) VALUES (%s, %s, %s, %s) RETURNING id',
|
| 44 |
+
(prompt, width, height, model)
|
| 45 |
+
)
|
| 46 |
+
batch_id = cur.fetchone()[0]
|
| 47 |
+
|
| 48 |
+
for url in urls:
|
| 49 |
+
cur.execute(
|
| 50 |
+
'INSERT INTO images (batch_id, url) VALUES (%s, %s)',
|
| 51 |
+
(batch_id, url)
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
conn.commit()
|
| 55 |
+
return batch_id
|
| 56 |
+
except Exception as e:
|
| 57 |
+
conn.rollback()
|
| 58 |
+
raise e
|
| 59 |
+
finally:
|
| 60 |
+
conn.close()
|
| 61 |
+
|
| 62 |
+
async def generate_images(request: ImageGenerationRequest):
|
| 63 |
+
try:
|
| 64 |
+
response = requests.post(
|
| 65 |
+
f"{settings.NEXT_PUBLIC_API_URL}/generate-image",
|
| 66 |
+
json=request.dict(exclude={'placeholderId'})
|
| 67 |
+
)
|
| 68 |
+
response.raise_for_status()
|
| 69 |
+
data = response.json()
|
| 70 |
+
image_urls = [image['url'] for image in data['batch']['images']]
|
| 71 |
+
batch_id = await insert_batch(request.prompt, request.width, request.height, request.model, image_urls)
|
| 72 |
+
|
| 73 |
+
return Batch(
|
| 74 |
+
id=batch_id,
|
| 75 |
+
prompt=request.prompt,
|
| 76 |
+
width=request.width,
|
| 77 |
+
height=request.height,
|
| 78 |
+
model=request.model,
|
| 79 |
+
images=[Image(url=url) for url in image_urls],
|
| 80 |
+
status='completed',
|
| 81 |
+
tempId=request.placeholderId
|
| 82 |
+
)
|
| 83 |
+
except Exception as e:
|
| 84 |
+
return Batch(id=request.placeholderId, status='error')
|
| 85 |
+
|
| 86 |
+
async def stream_response(request: ImageGenerationRequest):
|
| 87 |
+
batch = await generate_images(request)
|
| 88 |
+
yield json.dumps({"batch": batch.dict()}).encode() + b'\n'
|
| 89 |
+
|
| 90 |
+
@router.post("/generate-image")
|
| 91 |
+
async def generate_image(request: ImageGenerationRequest):
|
| 92 |
+
return StreamingResponse(stream_response(request), media_type="application/json")
|
| 93 |
+
|
| 94 |
+
@router.get("/generate-image")
|
| 95 |
+
async def get_batches(page: int = 1, limit: int = 10):
|
| 96 |
+
offset = (page - 1) * limit
|
| 97 |
+
conn = get_db_connection()
|
| 98 |
+
try:
|
| 99 |
+
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
| 100 |
+
cur.execute("""
|
| 101 |
+
SELECT b.id, b.prompt, b.width, b.height, b.model, array_agg(i.url) as image_urls, b.created_at
|
| 102 |
+
FROM batches b
|
| 103 |
+
JOIN images i ON i.batch_id = b.id
|
| 104 |
+
GROUP BY b.id, b.prompt, b.width, b.height, b.model, b.created_at
|
| 105 |
+
ORDER BY b.created_at DESC
|
| 106 |
+
LIMIT %s OFFSET %s
|
| 107 |
+
""", (limit, offset))
|
| 108 |
+
batches = cur.fetchall()
|
| 109 |
+
|
| 110 |
+
cur.execute('SELECT COUNT(*) FROM batches')
|
| 111 |
+
total_count = cur.fetchone()['count']
|
| 112 |
+
|
| 113 |
+
batches = [
|
| 114 |
+
Batch(
|
| 115 |
+
id=row['id'],
|
| 116 |
+
prompt=row['prompt'],
|
| 117 |
+
width=row['width'],
|
| 118 |
+
height=row['height'],
|
| 119 |
+
model=row['model'],
|
| 120 |
+
images=[Image(url=url) for url in row['image_urls']],
|
| 121 |
+
status='completed',
|
| 122 |
+
createdAt=row['created_at'].isoformat() if row['created_at'] else None
|
| 123 |
+
) for row in batches
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
has_more = total_count > page * limit
|
| 127 |
+
|
| 128 |
+
return {"batches": batches, "hasMore": has_more}
|
| 129 |
+
except Exception as e:
|
| 130 |
+
raise HTTPException(status_code=500, detail=f"Failed to fetch batches: {str(e)}")
|
| 131 |
+
finally:
|
| 132 |
+
conn.close()
|
| 133 |
+
|
| 134 |
+
@router.delete("/generate-image")
|
| 135 |
+
async def delete_batch(id: str):
|
| 136 |
+
conn = get_db_connection()
|
| 137 |
+
try:
|
| 138 |
+
with conn.cursor() as cur:
|
| 139 |
+
# Check if the id is a UUID (tempId) or a number (actual batch id)
|
| 140 |
+
is_uuid = len(id) == 36 and '-' in id
|
| 141 |
+
|
| 142 |
+
if is_uuid:
|
| 143 |
+
# If it's a UUID, it means the batch hasn't been saved to the database yet
|
| 144 |
+
# So we don't need to delete anything from the database
|
| 145 |
+
return {"success": True}
|
| 146 |
+
|
| 147 |
+
# Delete associated images first
|
| 148 |
+
cur.execute('DELETE FROM images WHERE batch_id = %s', (id,))
|
| 149 |
+
|
| 150 |
+
# Then delete the batch
|
| 151 |
+
cur.execute('DELETE FROM batches WHERE id = %s', (id,))
|
| 152 |
+
|
| 153 |
+
conn.commit()
|
| 154 |
+
return {"success": True}
|
| 155 |
+
except Exception as e:
|
| 156 |
+
conn.rollback()
|
| 157 |
+
raise HTTPException(status_code=500, detail=f"Failed to delete batch: {str(e)}")
|
| 158 |
+
finally:
|
| 159 |
+
conn.close()
|
app/api/services/__init__.py
ADDED
|
File without changes
|
app/api/services/openai_service.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from openai import OpenAI
|
| 2 |
+
from ..config import settings
|
| 3 |
+
|
| 4 |
+
def enhance_prompt(prompt: str) -> str:
|
| 5 |
+
client = OpenAI(api_key=settings.OPENAI_API_KEY, base_url=settings.OPENAI_API_BASE)
|
| 6 |
+
completion = client.chat.completions.create(
|
| 7 |
+
model="gemini-1.5-flash-latest",
|
| 8 |
+
messages=[
|
| 9 |
+
{"role": "system", "content": "You are an AI assistant that enhances image generation prompts. Your task is to take a user's prompt and make it more detailed and descriptive, suitable for high-quality image generation."},
|
| 10 |
+
{"role": "user", "content": f"Enhance this image generation prompt: {prompt}. Reply with the enhanced prompt only."}
|
| 11 |
+
]
|
| 12 |
+
)
|
| 13 |
+
return completion.choices[0].message.content
|
app/favicon.ico
ADDED
|
|
app/globals.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@layer base {
|
| 6 |
+
:root {
|
| 7 |
+
--background: 0 0% 100%;
|
| 8 |
+
--foreground: 222.2 84% 4.9%;
|
| 9 |
+
|
| 10 |
+
--card: 0 0% 100%;
|
| 11 |
+
--card-foreground: 222.2 84% 4.9%;
|
| 12 |
+
|
| 13 |
+
--popover: 0 0% 100%;
|
| 14 |
+
--popover-foreground: 222.2 84% 4.9%;
|
| 15 |
+
|
| 16 |
+
--primary: 222.2 47.4% 11.2%;
|
| 17 |
+
--primary-foreground: 210 40% 98%;
|
| 18 |
+
|
| 19 |
+
--secondary: 210 40% 96.1%;
|
| 20 |
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
| 21 |
+
|
| 22 |
+
--muted: 210 40% 96.1%;
|
| 23 |
+
--muted-foreground: 215.4 16.3% 46.9%;
|
| 24 |
+
|
| 25 |
+
--accent: 210 40% 96.1%;
|
| 26 |
+
--accent-foreground: 222.2 47.4% 11.2%;
|
| 27 |
+
|
| 28 |
+
--destructive: 0 84.2% 60.2%;
|
| 29 |
+
--destructive-foreground: 210 40% 98%;
|
| 30 |
+
|
| 31 |
+
--border: 214.3 31.8% 91.4%;
|
| 32 |
+
--input: 214.3 31.8% 91.4%;
|
| 33 |
+
--ring: 222.2 84% 4.9%;
|
| 34 |
+
|
| 35 |
+
--radius: 0.5rem;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.dark {
|
| 39 |
+
--background: 222.2 84% 4.9%;
|
| 40 |
+
--foreground: 210 40% 98%;
|
| 41 |
+
|
| 42 |
+
--card: 222.2 84% 4.9%;
|
| 43 |
+
--card-foreground: 210 40% 98%;
|
| 44 |
+
|
| 45 |
+
--popover: 222.2 84% 4.9%;
|
| 46 |
+
--popover-foreground: 210 40% 98%;
|
| 47 |
+
|
| 48 |
+
--primary: 210 40% 98%;
|
| 49 |
+
--primary-foreground: 222.2 47.4% 11.2%;
|
| 50 |
+
|
| 51 |
+
--secondary: 217.2 32.6% 17.5%;
|
| 52 |
+
--secondary-foreground: 210 40% 98%;
|
| 53 |
+
|
| 54 |
+
--muted: 217.2 32.6% 17.5%;
|
| 55 |
+
--muted-foreground: 215 20.2% 65.1%;
|
| 56 |
+
|
| 57 |
+
--accent: 217.2 32.6% 17.5%;
|
| 58 |
+
--accent-foreground: 210 40% 98%;
|
| 59 |
+
|
| 60 |
+
--destructive: 0 62.8% 30.6%;
|
| 61 |
+
--destructive-foreground: 210 40% 98%;
|
| 62 |
+
|
| 63 |
+
--border: 217.2 32.6% 17.5%;
|
| 64 |
+
--input: 217.2 32.6% 17.5%;
|
| 65 |
+
--ring: 212.7 26.8% 83.9%;
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
@layer base {
|
| 70 |
+
* {
|
| 71 |
+
@apply border-border;
|
| 72 |
+
}
|
| 73 |
+
body {
|
| 74 |
+
@apply bg-background text-foreground;
|
| 75 |
+
}
|
| 76 |
+
}
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import './globals.css'
|
| 2 |
+
import { Inter } from 'next/font/google'
|
| 3 |
+
import Layout from '../components/Layout'
|
| 4 |
+
|
| 5 |
+
const inter = Inter({ subsets: ['latin'],
|
| 6 |
+
variable: "--font-sans",
|
| 7 |
+
})
|
| 8 |
+
|
| 9 |
+
export const metadata = {
|
| 10 |
+
title: 'Imagine - AI Image Generation',
|
| 11 |
+
description: 'Generate amazing images using AI',
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function RootLayout({
|
| 15 |
+
children,
|
| 16 |
+
}: {
|
| 17 |
+
children: React.ReactNode
|
| 18 |
+
}) {
|
| 19 |
+
return (
|
| 20 |
+
<html lang="en" suppressHydrationWarning>
|
| 21 |
+
<body className={`${inter.className} bg-neutral-50 dark:bg-amoled-black text-[#141414] dark:text-white`}>
|
| 22 |
+
<Layout>
|
| 23 |
+
{children}
|
| 24 |
+
</Layout>
|
| 25 |
+
</body>
|
| 26 |
+
</html>
|
| 27 |
+
)
|
| 28 |
+
}
|
app/page.tsx
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 4 |
+
import GeneratorForm from '../components/ImageGenerator/GeneratorForm';
|
| 5 |
+
import ImageBatch from '../components/Gallery/ImageBatch';
|
| 6 |
+
import { Button } from '../components/ui/button';
|
| 7 |
+
import Navbar from '../components/Navbar';
|
| 8 |
+
|
| 9 |
+
interface Image {
|
| 10 |
+
url: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface Batch {
|
| 14 |
+
id: number | string;
|
| 15 |
+
prompt: string;
|
| 16 |
+
width: number;
|
| 17 |
+
height: number;
|
| 18 |
+
model: string;
|
| 19 |
+
images: Image[];
|
| 20 |
+
createdAt?: string;
|
| 21 |
+
status?: 'pending' | 'completed' | 'error';
|
| 22 |
+
tempId?: string;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function Home() {
|
| 26 |
+
const [batches, setBatches] = useState<Batch[]>([]);
|
| 27 |
+
const [remixBatch, setRemixBatch] = useState<Batch | null>(null);
|
| 28 |
+
const [page, setPage] = useState(1);
|
| 29 |
+
const [hasMore, setHasMore] = useState(true);
|
| 30 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
fetchPreviousBatches();
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
const fetchPreviousBatches = async (loadMore = false) => {
|
| 37 |
+
if (isLoading) return;
|
| 38 |
+
setIsLoading(true);
|
| 39 |
+
try {
|
| 40 |
+
const response = await fetch(`/api/generate-image?page=${loadMore ? page + 1 : 1}&limit=10`);
|
| 41 |
+
if (response.ok) {
|
| 42 |
+
const data = await response.json();
|
| 43 |
+
if (loadMore) {
|
| 44 |
+
setBatches(prev => [...prev, ...data.batches]);
|
| 45 |
+
setPage(prev => prev + 1);
|
| 46 |
+
} else {
|
| 47 |
+
setBatches(data.batches);
|
| 48 |
+
}
|
| 49 |
+
setHasMore(data.hasMore);
|
| 50 |
+
}
|
| 51 |
+
} catch (error) {
|
| 52 |
+
console.error('Error fetching previous batches:', error);
|
| 53 |
+
} finally {
|
| 54 |
+
setIsLoading(false);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const handleGenerate = useCallback((newBatch: Batch & { tempId?: string }, isPlaceholder: boolean) => {
|
| 59 |
+
const batchWithTimestamp = {
|
| 60 |
+
...newBatch,
|
| 61 |
+
createdAt: newBatch.createdAt || new Date().toISOString()
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
setBatches((prev) => {
|
| 65 |
+
const index = prev.findIndex(b =>
|
| 66 |
+
(typeof b.id === 'number' && b.id === newBatch.id) ||
|
| 67 |
+
(b.tempId && b.tempId === newBatch.tempId)
|
| 68 |
+
);
|
| 69 |
+
if (index !== -1) {
|
| 70 |
+
// Update existing batch
|
| 71 |
+
const updatedBatches = [...prev];
|
| 72 |
+
updatedBatches[index] = {
|
| 73 |
+
...batchWithTimestamp,
|
| 74 |
+
id: typeof newBatch.id === 'number' ? newBatch.id : prev[index].id, // Keep the numeric id if it exists
|
| 75 |
+
tempId: newBatch.tempId || prev[index].tempId // Keep the tempId for reference
|
| 76 |
+
};
|
| 77 |
+
return updatedBatches;
|
| 78 |
+
} else {
|
| 79 |
+
// Add new batch
|
| 80 |
+
return [batchWithTimestamp, ...prev];
|
| 81 |
+
}
|
| 82 |
+
});
|
| 83 |
+
}, []);
|
| 84 |
+
|
| 85 |
+
const handleDelete = async (id: number | string) => {
|
| 86 |
+
// Optimistically remove the batch from the UI
|
| 87 |
+
setBatches((prev) => prev.filter((batch) =>
|
| 88 |
+
(typeof batch.id === 'number' && batch.id !== id) &&
|
| 89 |
+
(batch.tempId !== id)
|
| 90 |
+
));
|
| 91 |
+
|
| 92 |
+
// Then send the delete request to the server
|
| 93 |
+
try {
|
| 94 |
+
const response = await fetch(`/api/generate-image?id=${id}`, {
|
| 95 |
+
method: 'DELETE',
|
| 96 |
+
});
|
| 97 |
+
if (!response.ok) {
|
| 98 |
+
console.error('Failed to delete batch');
|
| 99 |
+
// If the deletion fails on the server, you might want to add the batch back to the UI
|
| 100 |
+
// This would require keeping a copy of the deleted batch or fetching it again from the server
|
| 101 |
+
}
|
| 102 |
+
} catch (error) {
|
| 103 |
+
console.error('Error deleting batch:', error);
|
| 104 |
+
// Similarly, handle the error case here if you want to revert the UI change
|
| 105 |
+
}
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
const handleRemix = (batch: Batch) => {
|
| 109 |
+
setRemixBatch(batch);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const handleLoadMore = () => {
|
| 113 |
+
fetchPreviousBatches(true);
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const handleReload = useCallback(() => {
|
| 117 |
+
setPage(1);
|
| 118 |
+
fetchPreviousBatches();
|
| 119 |
+
}, []);
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<>
|
| 123 |
+
<Navbar onReload={handleReload} />
|
| 124 |
+
<div className="flex flex-col md:flex-row flex-1 justify-start py-5">
|
| 125 |
+
<div className="w-full md:w-80 px-4 md:px-6 mb-6 md:mb-0">
|
| 126 |
+
<GeneratorForm onGenerate={handleGenerate} remixBatch={remixBatch} />
|
| 127 |
+
</div>
|
| 128 |
+
<div className="flex-1 px-4 md:px-6 overflow-x-auto">
|
| 129 |
+
<div className="layout-content-container flex flex-col w-full">
|
| 130 |
+
{batches.map((batch) => (
|
| 131 |
+
<ImageBatch key={batch.id} batch={batch} onDelete={handleDelete} onRemix={handleRemix} />
|
| 132 |
+
))}
|
| 133 |
+
{hasMore && (
|
| 134 |
+
<div className="flex justify-center mt-4">
|
| 135 |
+
<Button
|
| 136 |
+
onClick={handleLoadMore}
|
| 137 |
+
disabled={isLoading}
|
| 138 |
+
variant="outline"
|
| 139 |
+
className="bg-white dark:bg-gray-800 text-[#141414] dark:text-white font-bold"
|
| 140 |
+
>
|
| 141 |
+
{isLoading ? 'Loading...' : 'Load More'}
|
| 142 |
+
</Button>
|
| 143 |
+
</div>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</>
|
| 149 |
+
);
|
| 150 |
+
}
|
app/password/page.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Input } from '../../components/ui/input';
|
| 7 |
+
|
| 8 |
+
export default function PasswordPage() {
|
| 9 |
+
const [password, setPassword] = useState('');
|
| 10 |
+
const [error, setError] = useState('');
|
| 11 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 12 |
+
const router = useRouter();
|
| 13 |
+
|
| 14 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 15 |
+
e.preventDefault();
|
| 16 |
+
setIsLoading(true);
|
| 17 |
+
setError('');
|
| 18 |
+
|
| 19 |
+
try {
|
| 20 |
+
const response = await fetch('/api/check-password', {
|
| 21 |
+
method: 'POST',
|
| 22 |
+
headers: { 'Content-Type': 'application/json' },
|
| 23 |
+
body: JSON.stringify({ password }),
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
if (response.ok) {
|
| 27 |
+
document.cookie = 'isAuthenticated=true; path=/; max-age=604800'; // Set cookie for 7 days
|
| 28 |
+
router.push('/');
|
| 29 |
+
} else {
|
| 30 |
+
setError('Incorrect password');
|
| 31 |
+
}
|
| 32 |
+
} catch (error) {
|
| 33 |
+
console.error('Error checking password:', error);
|
| 34 |
+
setError('An error occurred. Please try again.');
|
| 35 |
+
} finally {
|
| 36 |
+
setIsLoading(false);
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div className="flex flex-col items-center justify-center min-h-screen bg-neutral-50 dark:bg-amoled-black">
|
| 42 |
+
<form onSubmit={handleSubmit} className="w-full max-w-md p-8 bg-white dark:bg-gray-800 rounded-2xl shadow-lg">
|
| 43 |
+
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800 dark:text-white">Enter Password</h1>
|
| 44 |
+
<div className="mb-6">
|
| 45 |
+
<Input
|
| 46 |
+
type="password"
|
| 47 |
+
value={password}
|
| 48 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 49 |
+
className="w-full p-3 text-lg"
|
| 50 |
+
placeholder="Password"
|
| 51 |
+
/>
|
| 52 |
+
</div>
|
| 53 |
+
{error && <p className="text-red-500 mb-4 text-center">{error}</p>}
|
| 54 |
+
<Button
|
| 55 |
+
type="submit"
|
| 56 |
+
className="w-full p-3 text-lg font-semibold"
|
| 57 |
+
disabled={isLoading}
|
| 58 |
+
>
|
| 59 |
+
{isLoading ? 'Checking...' : 'Submit'}
|
| 60 |
+
</Button>
|
| 61 |
+
</form>
|
| 62 |
+
</div>
|
| 63 |
+
);
|
| 64 |
+
}
|
components/Gallery/BatchMenu.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { MoreHorizontal, Trash2, RefreshCw } from 'lucide-react';
|
| 3 |
+
import { Button } from '../ui/button';
|
| 4 |
+
|
| 5 |
+
interface BatchMenuProps {
|
| 6 |
+
onDelete: () => void;
|
| 7 |
+
onRemix: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function BatchMenu({ onDelete, onRemix }: BatchMenuProps) {
|
| 11 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 12 |
+
const menuRef = useRef<HTMLDivElement>(null);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
function handleClickOutside(event: MouseEvent) {
|
| 16 |
+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
| 17 |
+
setIsOpen(false);
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 22 |
+
return () => {
|
| 23 |
+
document.removeEventListener('mousedown', handleClickOutside);
|
| 24 |
+
};
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div className="relative" ref={menuRef}>
|
| 29 |
+
<Button
|
| 30 |
+
variant="ghost"
|
| 31 |
+
size="icon"
|
| 32 |
+
className="h-8 w-8"
|
| 33 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 34 |
+
>
|
| 35 |
+
<MoreHorizontal className="h-4 w-4" />
|
| 36 |
+
</Button>
|
| 37 |
+
{isOpen && (
|
| 38 |
+
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50">
|
| 39 |
+
<div className="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
|
| 40 |
+
<button
|
| 41 |
+
onClick={() => {
|
| 42 |
+
onRemix();
|
| 43 |
+
setIsOpen(false);
|
| 44 |
+
}}
|
| 45 |
+
className="flex items-center px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900 w-full text-left"
|
| 46 |
+
role="menuitem"
|
| 47 |
+
>
|
| 48 |
+
<RefreshCw className="mr-2 h-4 w-4" />
|
| 49 |
+
<span>Remix</span>
|
| 50 |
+
</button>
|
| 51 |
+
<button
|
| 52 |
+
onClick={() => {
|
| 53 |
+
onDelete();
|
| 54 |
+
setIsOpen(false);
|
| 55 |
+
}}
|
| 56 |
+
className="flex items-center px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900 w-full text-left"
|
| 57 |
+
role="menuitem"
|
| 58 |
+
>
|
| 59 |
+
<Trash2 className="mr-2 h-4 w-4" />
|
| 60 |
+
<span>Delete</span>
|
| 61 |
+
</button>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
)}
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
}
|
components/Gallery/ImageBatch.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import ImageCard from './ImageCard';
|
| 3 |
+
import BatchMenu from './BatchMenu';
|
| 4 |
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
| 5 |
+
import { Copy, Check } from 'lucide-react';
|
| 6 |
+
import { Button } from '../ui/button';
|
| 7 |
+
|
| 8 |
+
const formatTimestamp = (timestamp: string | undefined) => {
|
| 9 |
+
if (!timestamp) return '';
|
| 10 |
+
|
| 11 |
+
const date = new Date(timestamp);
|
| 12 |
+
if (isNaN(date.getTime())) return '';
|
| 13 |
+
|
| 14 |
+
const now = new Date();
|
| 15 |
+
const hours = date.getHours().toString().padStart(2, '0');
|
| 16 |
+
const minutes = date.getMinutes().toString().padStart(2, '0');
|
| 17 |
+
const day = date.getDate().toString().padStart(2, '0');
|
| 18 |
+
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
| 19 |
+
const year = date.getFullYear();
|
| 20 |
+
|
| 21 |
+
const timeString = `${hours}:${minutes}`;
|
| 22 |
+
const dateString = date.getFullYear() === now.getFullYear()
|
| 23 |
+
? `${day}-${month}`
|
| 24 |
+
: `${day}-${month}-${year}`;
|
| 25 |
+
|
| 26 |
+
return `${timeString} ${dateString}`;
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
interface Image {
|
| 30 |
+
url: string;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface Batch {
|
| 34 |
+
id: number;
|
| 35 |
+
prompt: string;
|
| 36 |
+
width: number;
|
| 37 |
+
height: number;
|
| 38 |
+
model: string;
|
| 39 |
+
images: Image[];
|
| 40 |
+
createdAt?: string;
|
| 41 |
+
status?: string;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const modelNames: { [key: string]: string } = {
|
| 45 |
+
'runware:100@1': 'FLUX SCHNELL',
|
| 46 |
+
'runware:101@1': 'FLUX DEV'
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
interface ImageBatchProps {
|
| 50 |
+
batch: Batch;
|
| 51 |
+
onDelete: (id: number) => void;
|
| 52 |
+
onRemix: (batch: Batch) => void;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export default function ImageBatch({ batch, onDelete, onRemix }: ImageBatchProps) {
|
| 56 |
+
const [copied, setCopied] = useState(false);
|
| 57 |
+
const [isPromptTruncated, setIsPromptTruncated] = useState(false);
|
| 58 |
+
const promptRef = useRef<HTMLParagraphElement>(null);
|
| 59 |
+
const modelName = modelNames[batch.model] || batch.model;
|
| 60 |
+
const [elapsedTime, setElapsedTime] = useState(0);
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
const checkTruncation = () => {
|
| 64 |
+
if (promptRef.current) {
|
| 65 |
+
setIsPromptTruncated(
|
| 66 |
+
promptRef.current.scrollWidth > promptRef.current.clientWidth
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
checkTruncation();
|
| 72 |
+
window.addEventListener('resize', checkTruncation);
|
| 73 |
+
|
| 74 |
+
return () => {
|
| 75 |
+
window.removeEventListener('resize', checkTruncation);
|
| 76 |
+
};
|
| 77 |
+
}, [batch.prompt]);
|
| 78 |
+
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
let interval: NodeJS.Timeout;
|
| 81 |
+
if (batch.status === 'pending') {
|
| 82 |
+
const startTime = new Date(batch.createdAt || Date.now()).getTime();
|
| 83 |
+
interval = setInterval(() => {
|
| 84 |
+
const now = Date.now();
|
| 85 |
+
setElapsedTime((now - startTime) / 1000);
|
| 86 |
+
}, 100);
|
| 87 |
+
}
|
| 88 |
+
return () => clearInterval(interval);
|
| 89 |
+
}, [batch.status, batch.createdAt]);
|
| 90 |
+
|
| 91 |
+
const handleDelete = () => {
|
| 92 |
+
onDelete(batch.id);
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const handleRemix = () => {
|
| 96 |
+
onRemix(batch);
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const copyToClipboard = () => {
|
| 100 |
+
navigator.clipboard.writeText(batch.prompt).then(() => {
|
| 101 |
+
setCopied(true);
|
| 102 |
+
setTimeout(() => setCopied(false), 2000);
|
| 103 |
+
});
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
return (
|
| 107 |
+
<div className="mb-4 rounded-xl p-3 bg-[#ededed] dark:bg-gray-900">
|
| 108 |
+
<div className="flex flex-col">
|
| 109 |
+
<div className="flex items-center justify-between">
|
| 110 |
+
<TooltipProvider delayDuration={0}>
|
| 111 |
+
<Tooltip>
|
| 112 |
+
<TooltipTrigger asChild>
|
| 113 |
+
<p ref={promptRef} className="text-[#141414] dark:text-white text-xs font-medium truncate cursor-default max-w-[95%]">{batch.prompt}</p>
|
| 114 |
+
</TooltipTrigger>
|
| 115 |
+
{isPromptTruncated && (
|
| 116 |
+
<TooltipContent side="bottom" align="center" className="max-w-md">
|
| 117 |
+
<p className="text-sm">{batch.prompt}</p>
|
| 118 |
+
</TooltipContent>
|
| 119 |
+
)}
|
| 120 |
+
</Tooltip>
|
| 121 |
+
</TooltipProvider>
|
| 122 |
+
<div className="flex items-center gap-2">
|
| 123 |
+
<Button
|
| 124 |
+
variant="ghost"
|
| 125 |
+
size="icon"
|
| 126 |
+
className="h-6 w-6 flex-shrink-0"
|
| 127 |
+
onClick={copyToClipboard}
|
| 128 |
+
>
|
| 129 |
+
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
| 130 |
+
</Button>
|
| 131 |
+
<BatchMenu onDelete={handleDelete} onRemix={handleRemix} />
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
<p className="text-[#141414] dark:text-white text-[10px] mt-0">
|
| 135 |
+
{modelName} | {batch.width}x{batch.height}{formatTimestamp(batch.createdAt) && ` • ${formatTimestamp(batch.createdAt)}`}
|
| 136 |
+
</p>
|
| 137 |
+
</div>
|
| 138 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mt-2">
|
| 139 |
+
{batch.images.map((image, index) => (
|
| 140 |
+
<ImageCard
|
| 141 |
+
key={index}
|
| 142 |
+
image={image}
|
| 143 |
+
batchImages={batch.images}
|
| 144 |
+
batchId={batch.id}
|
| 145 |
+
status={batch.status}
|
| 146 |
+
width={batch.width}
|
| 147 |
+
height={batch.height}
|
| 148 |
+
elapsedTime={elapsedTime}
|
| 149 |
+
/>
|
| 150 |
+
))}
|
| 151 |
+
</div>
|
| 152 |
+
{batch.status === 'error' && (
|
| 153 |
+
<p className="text-red-500 mt-2">Error generating images. Please try again.</p>
|
| 154 |
+
)}
|
| 155 |
+
</div>
|
| 156 |
+
);
|
| 157 |
+
}
|
components/Gallery/ImageCard.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect } from 'react';
|
| 2 |
+
import { Fancybox } from "@fancyapps/ui";
|
| 3 |
+
import "@fancyapps/ui/dist/fancybox/fancybox.css";
|
| 4 |
+
|
| 5 |
+
interface Image {
|
| 6 |
+
url: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface ImageCardProps {
|
| 10 |
+
image: Image;
|
| 11 |
+
batchImages: Image[];
|
| 12 |
+
batchId: number;
|
| 13 |
+
status?: 'pending' | 'error' | 'completed';
|
| 14 |
+
width: number;
|
| 15 |
+
height: number;
|
| 16 |
+
elapsedTime: number;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function ImageCard({ image, batchImages, batchId, status, width, height, elapsedTime }: ImageCardProps) {
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
Fancybox.bind(`[data-fancybox="gallery-${batchId}"]`, {
|
| 22 |
+
contentClick: "iterateZoom",
|
| 23 |
+
Images: {
|
| 24 |
+
Panzoom: {
|
| 25 |
+
maxScale: 8,
|
| 26 |
+
},
|
| 27 |
+
},
|
| 28 |
+
Toolbar: {
|
| 29 |
+
display: {
|
| 30 |
+
left: ["infobar"],
|
| 31 |
+
middle: [],
|
| 32 |
+
right: ["iterateZoom", "fullscreen", "download", "thumbs", "close"],
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
Thumbs: {
|
| 36 |
+
type: "classic",
|
| 37 |
+
},
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
return () => {
|
| 41 |
+
Fancybox.destroy();
|
| 42 |
+
};
|
| 43 |
+
}, [batchId]);
|
| 44 |
+
|
| 45 |
+
const aspectRatio = `${width} / ${height}`;
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<a
|
| 49 |
+
href={image.url}
|
| 50 |
+
data-fancybox={`gallery-${batchId}`}
|
| 51 |
+
data-src={image.url}
|
| 52 |
+
className="block relative"
|
| 53 |
+
style={{ aspectRatio }}
|
| 54 |
+
>
|
| 55 |
+
<div
|
| 56 |
+
className={`w-full h-full bg-center bg-no-repeat bg-cover rounded-xl cursor-pointer overflow-hidden ${
|
| 57 |
+
status === 'pending' ? 'animate-pulse bg-gray-300 dark:bg-gray-700' : ''
|
| 58 |
+
}`}
|
| 59 |
+
style={status !== 'pending' ? {backgroundImage: `url("${image.url}")`} : {}}
|
| 60 |
+
>
|
| 61 |
+
{status === 'pending' && (
|
| 62 |
+
<div className="absolute inset-0 flex items-center justify-center">
|
| 63 |
+
<div className="relative w-16 h-16">
|
| 64 |
+
<svg className="animate-spin h-16 w-16 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 65 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 66 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 67 |
+
</svg>
|
| 68 |
+
<div className="absolute inset-0 flex items-center justify-center">
|
| 69 |
+
<span className="text-primary font-bold">{elapsedTime.toFixed(1)}s</span>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
)}
|
| 74 |
+
{status === 'error' && (
|
| 75 |
+
<div className="flex items-center justify-center h-full bg-red-200 dark:bg-red-900 rounded-xl">
|
| 76 |
+
<span className="text-red-500 dark:text-red-300">Error</span>
|
| 77 |
+
</div>
|
| 78 |
+
)}
|
| 79 |
+
</div>
|
| 80 |
+
</a>
|
| 81 |
+
);
|
| 82 |
+
}
|
components/Gallery/ImageGrid.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ImageCard from './ImageCard';
|
| 2 |
+
|
| 3 |
+
interface Image {
|
| 4 |
+
url: string;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export default function ImageGrid({ title, images, batchId }: { title: string; images: Image[]; batchId: number }) {
|
| 8 |
+
return (
|
| 9 |
+
<div className="mb-8">
|
| 10 |
+
<h2 className="text-[#141414] dark:text-white text-[22px] font-bold leading-tight tracking-[-0.015em] px-4 pb-3 pt-5">{title}</h2>
|
| 11 |
+
<div className="flex overflow-x-auto pb-4">
|
| 12 |
+
<div className="flex gap-3 md:gap-4 px-4">
|
| 13 |
+
{images.map((image, index) => (
|
| 14 |
+
<ImageCard key={index} image={image} batchImages={images} batchId={batchId} />
|
| 15 |
+
))}
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
);
|
| 20 |
+
}
|
components/Header.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Image from 'next/image';
|
| 2 |
+
import Link from 'next/link';
|
| 3 |
+
|
| 4 |
+
export default function Header() {
|
| 5 |
+
return (
|
| 6 |
+
<header className="flex items-center justify-between whitespace-nowrap border-b border-solid border-b-[#ededed] px-10 py-3">
|
| 7 |
+
<div className="flex items-center gap-8">
|
| 8 |
+
<div className="flex items-center gap-4 text-[#141414]">
|
| 9 |
+
<div className="size-4">
|
| 10 |
+
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 11 |
+
<path fillRule="evenodd" clipRule="evenodd" d="M24 18.4228L42 11.475V34.3663C42 34.7796 41.7457 35.1504 41.3601 35.2992L24 42V18.4228Z" fill="currentColor"></path>
|
| 12 |
+
<path fillRule="evenodd" clipRule="evenodd" d="M24 8.18819L33.4123 11.574L24 15.2071L14.5877 11.574L24 8.18819ZM9 15.8487L21 20.4805V37.6263L9 32.9945V15.8487ZM27 37.6263V20.4805L39 15.8487V32.9945L27 37.6263ZM25.354 2.29885C24.4788 1.98402 23.5212 1.98402 22.646 2.29885L4.98454 8.65208C3.7939 9.08038 3 10.2097 3 11.475V34.3663C3 36.0196 4.01719 37.5026 5.55962 38.098L22.9197 44.7987C23.6149 45.0671 24.3851 45.0671 25.0803 44.7987L42.4404 38.098C43.9828 37.5026 45 36.0196 45 34.3663V11.475C45 10.2097 44.2061 9.08038 43.0155 8.65208L25.354 2.29885Z" fill="currentColor"></path>
|
| 13 |
+
</svg>
|
| 14 |
+
</div>
|
| 15 |
+
<h2 className="text-[#141414] text-lg font-bold leading-tight tracking-[-0.015em]">ImageGen</h2>
|
| 16 |
+
</div>
|
| 17 |
+
<div className="flex items-center gap-9">
|
| 18 |
+
<Link href="#" className="text-[#141414] text-sm font-medium leading-normal">Home</Link>
|
| 19 |
+
<Link href="#" className="text-[#141414] text-sm font-medium leading-normal">Explore</Link>
|
| 20 |
+
<Link href="#" className="text-[#141414] text-sm font-medium leading-normal">Create</Link>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div className="flex flex-1 justify-end gap-8">
|
| 24 |
+
<label className="flex flex-col min-w-40 !h-10 max-w-64">
|
| 25 |
+
<div className="flex w-full flex-1 items-stretch rounded-xl h-full">
|
| 26 |
+
<div className="text-neutral-500 flex border-none bg-[#ededed] items-center justify-center pl-4 rounded-l-xl border-r-0">
|
| 27 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" fill="currentColor" viewBox="0 0 256 256">
|
| 28 |
+
<path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"></path>
|
| 29 |
+
</svg>
|
| 30 |
+
</div>
|
| 31 |
+
<input
|
| 32 |
+
placeholder="Search"
|
| 33 |
+
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-xl text-[#141414] focus:outline-0 focus:ring-0 border-none bg-[#ededed] focus:border-none h-full placeholder:text-neutral-500 px-4 rounded-l-none border-l-0 pl-2 text-base font-normal leading-normal"
|
| 34 |
+
/>
|
| 35 |
+
</div>
|
| 36 |
+
</label>
|
| 37 |
+
<button className="flex min-w-[84px] max-w-[480px] cursor-pointer items-center justify-center overflow-hidden rounded-xl h-10 px-4 bg-[#ededed] text-[#141414] text-sm font-bold leading-normal tracking-[0.015em]">
|
| 38 |
+
<span className="truncate">4.6k credits</span>
|
| 39 |
+
</button>
|
| 40 |
+
<div
|
| 41 |
+
className="bg-center bg-no-repeat aspect-square bg-cover rounded-full size-10"
|
| 42 |
+
style={{backgroundImage: 'url("https://cdn.usegalileo.ai/sdxl10/a879f542-e73c-43af-9e70-cc7859996bc5.png")'}}
|
| 43 |
+
></div>
|
| 44 |
+
</div>
|
| 45 |
+
</header>
|
| 46 |
+
);
|
| 47 |
+
}
|
components/ImageGenerator/AspectRatioSelector.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import {
|
| 4 |
+
Select,
|
| 5 |
+
SelectContent,
|
| 6 |
+
SelectItem,
|
| 7 |
+
SelectTrigger,
|
| 8 |
+
SelectValue,
|
| 9 |
+
} from '../ui/select'
|
| 10 |
+
|
| 11 |
+
interface AspectRatioSelectorProps {
|
| 12 |
+
value: string;
|
| 13 |
+
onChange: (value: string) => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function AspectRatioSelector({ value, onChange }: AspectRatioSelectorProps) {
|
| 17 |
+
return (
|
| 18 |
+
<div className="flex max-w-[480px] flex-wrap items-end gap-4 px-4 py-3">
|
| 19 |
+
<label className="flex flex-col min-w-40 flex-1">
|
| 20 |
+
<p className="text-[#141414] dark:text-white text-base font-medium leading-normal pb-2">Aspect Ratio</p>
|
| 21 |
+
<Select value={value} onValueChange={onChange}>
|
| 22 |
+
<SelectTrigger className="w-full bg-[#ededed] dark:bg-gray-900 border-none rounded-2xl h-14 px-4">
|
| 23 |
+
<SelectValue placeholder="Choose aspect ratio" />
|
| 24 |
+
</SelectTrigger>
|
| 25 |
+
<SelectContent className="bg-[#ededed] dark:bg-gray-900 rounded-2xl">
|
| 26 |
+
<SelectItem value="square">Square (1024x1024)</SelectItem>
|
| 27 |
+
<SelectItem value="portrait">Portrait (832x1216)</SelectItem>
|
| 28 |
+
<SelectItem value="landscape">Landscape (1216x832)</SelectItem>
|
| 29 |
+
</SelectContent>
|
| 30 |
+
</Select>
|
| 31 |
+
</label>
|
| 32 |
+
</div>
|
| 33 |
+
);
|
| 34 |
+
}
|
components/ImageGenerator/GeneratorForm.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { Loader2 } from 'lucide-react';
|
| 3 |
+
import ModelSelector from './ModelSelector';
|
| 4 |
+
import PromptInput from './PromptInput';
|
| 5 |
+
import AspectRatioSelector from './AspectRatioSelector';
|
| 6 |
+
import ImageCountSlider from './ImageCountSlider';
|
| 7 |
+
import { Button } from '../ui/button';
|
| 8 |
+
import { v4 as uuidv4 } from 'uuid';
|
| 9 |
+
|
| 10 |
+
interface Image {
|
| 11 |
+
url: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface Batch {
|
| 15 |
+
id: number | string; // Allow string for tempId
|
| 16 |
+
prompt: string;
|
| 17 |
+
width: number;
|
| 18 |
+
height: number;
|
| 19 |
+
model: string;
|
| 20 |
+
images: Image[];
|
| 21 |
+
status?: string;
|
| 22 |
+
tempId?: string;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function GeneratorForm({ onGenerate, remixBatch }: { onGenerate: (batch: Batch, isPlaceholder: boolean) => void, remixBatch: Batch | null }) {
|
| 26 |
+
const [prompt, setPrompt] = useState('');
|
| 27 |
+
const [model, setModel] = useState('runware:100@1'); // FLUX SCHNELL as default
|
| 28 |
+
const [aspectRatio, setAspectRatio] = useState('square');
|
| 29 |
+
const [imageCount, setImageCount] = useState(1);
|
| 30 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 31 |
+
const [error, setError] = useState<string | null>(null);
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
if (remixBatch) {
|
| 35 |
+
setPrompt(remixBatch.prompt);
|
| 36 |
+
setModel(remixBatch.model);
|
| 37 |
+
setAspectRatio(getAspectRatioFromDimensions(remixBatch.width, remixBatch.height));
|
| 38 |
+
setImageCount(remixBatch.images.length);
|
| 39 |
+
}
|
| 40 |
+
}, [remixBatch]);
|
| 41 |
+
|
| 42 |
+
const getAspectRatioFromDimensions = (width: number, height: number) => {
|
| 43 |
+
if (width === height) return 'square';
|
| 44 |
+
if (width === 832 && height === 1216) return 'portrait';
|
| 45 |
+
if (width === 1216 && height === 832) return 'landscape';
|
| 46 |
+
return 'square'; // Default to square if dimensions don't match known ratios
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const aspectRatios: { [key: string]: { width: number; height: number } } = {
|
| 50 |
+
square: { width: 1024, height: 1024 },
|
| 51 |
+
landscape: { width: 1216, height: 832 },
|
| 52 |
+
portrait: { width: 832, height: 1216 }
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const handleGenerate = useCallback(async () => {
|
| 56 |
+
setError(null);
|
| 57 |
+
const placeholderId = uuidv4();
|
| 58 |
+
const placeholderBatch: Batch = {
|
| 59 |
+
id: placeholderId,
|
| 60 |
+
prompt,
|
| 61 |
+
width: aspectRatios[aspectRatio as keyof typeof aspectRatios].width,
|
| 62 |
+
height: aspectRatios[aspectRatio as keyof typeof aspectRatios].height,
|
| 63 |
+
model,
|
| 64 |
+
images: Array(imageCount).fill({ url: '/placeholder-image.png' }),
|
| 65 |
+
status: 'pending',
|
| 66 |
+
tempId: placeholderId
|
| 67 |
+
};
|
| 68 |
+
onGenerate(placeholderBatch, true);
|
| 69 |
+
|
| 70 |
+
try {
|
| 71 |
+
const response = await fetch('/api/generate-image', {
|
| 72 |
+
method: 'POST',
|
| 73 |
+
headers: {
|
| 74 |
+
'Content-Type': 'application/json',
|
| 75 |
+
},
|
| 76 |
+
body: JSON.stringify({
|
| 77 |
+
prompt,
|
| 78 |
+
width: placeholderBatch.width,
|
| 79 |
+
height: placeholderBatch.height,
|
| 80 |
+
model,
|
| 81 |
+
number_results: imageCount,
|
| 82 |
+
placeholderId,
|
| 83 |
+
}),
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
if (!response.ok) {
|
| 87 |
+
throw new Error('Failed to generate image');
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const reader = response.body?.getReader();
|
| 91 |
+
if (!reader) {
|
| 92 |
+
throw new Error('Failed to read response');
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
while (true) {
|
| 96 |
+
const { done, value } = await reader.read();
|
| 97 |
+
if (done) break;
|
| 98 |
+
|
| 99 |
+
const chunk = new TextDecoder().decode(value);
|
| 100 |
+
const data = JSON.parse(chunk);
|
| 101 |
+
|
| 102 |
+
if (data.batch) {
|
| 103 |
+
onGenerate({ ...data.batch, tempId: placeholderId }, false);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
} catch (error) {
|
| 107 |
+
console.error('Error generating image:', error);
|
| 108 |
+
setError(error instanceof Error ? error.message : 'An unknown error occurred');
|
| 109 |
+
onGenerate({ ...placeholderBatch, status: 'error' }, false);
|
| 110 |
+
}
|
| 111 |
+
}, [aspectRatio, prompt, model, imageCount, onGenerate, aspectRatios]);
|
| 112 |
+
|
| 113 |
+
useEffect(() => {
|
| 114 |
+
const handleKeyDown = (event: KeyboardEvent) => {
|
| 115 |
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
| 116 |
+
event.preventDefault();
|
| 117 |
+
handleGenerate();
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
document.addEventListener('keydown', handleKeyDown);
|
| 122 |
+
return () => {
|
| 123 |
+
document.removeEventListener('keydown', handleKeyDown);
|
| 124 |
+
};
|
| 125 |
+
}, [handleGenerate]);
|
| 126 |
+
|
| 127 |
+
return (
|
| 128 |
+
<div className="layout-content-container flex flex-col w-full md:w-80">
|
| 129 |
+
<PromptInput value={prompt} onChange={setPrompt} />
|
| 130 |
+
<ModelSelector value={model} onChange={setModel} />
|
| 131 |
+
<AspectRatioSelector value={aspectRatio} onChange={setAspectRatio} />
|
| 132 |
+
<ImageCountSlider value={imageCount} onChange={setImageCount} />
|
| 133 |
+
{error && (
|
| 134 |
+
<div className="px-4 py-2 mb-3 text-red-500 bg-red-100 dark:bg-red-900 dark:text-red-100 rounded-md">
|
| 135 |
+
{error}
|
| 136 |
+
</div>
|
| 137 |
+
)}
|
| 138 |
+
<div className="flex px-4 py-3">
|
| 139 |
+
<Button
|
| 140 |
+
variant="outline"
|
| 141 |
+
className="w-full justify-center bg-white dark:bg-gray-800 text-[#141414] dark:text-white font-bold"
|
| 142 |
+
onClick={handleGenerate}
|
| 143 |
+
disabled={isLoading}
|
| 144 |
+
>
|
| 145 |
+
{isLoading ? (
|
| 146 |
+
<>
|
| 147 |
+
<Loader2 className="mr-2 size-4 animate-spin" />
|
| 148 |
+
Generating...
|
| 149 |
+
</>
|
| 150 |
+
) : (
|
| 151 |
+
'Generate'
|
| 152 |
+
)}
|
| 153 |
+
</Button>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
);
|
| 157 |
+
}
|
components/ImageGenerator/ImageCountSlider.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React from 'react';
|
| 4 |
+
import { Slider } from '../ui/slider';
|
| 5 |
+
|
| 6 |
+
interface ImageCountSliderProps {
|
| 7 |
+
value: number;
|
| 8 |
+
onChange: (value: number) => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function ImageCountSlider({ value, onChange }: ImageCountSliderProps) {
|
| 12 |
+
return (
|
| 13 |
+
<div className="flex flex-col px-4 py-3">
|
| 14 |
+
<div className="flex justify-between items-center mb-2">
|
| 15 |
+
<p className="text-[#141414] dark:text-white text-base font-medium leading-normal">Image Count</p>
|
| 16 |
+
<p className="text-[#141414] dark:text-white text-sm font-normal leading-normal">{value}</p>
|
| 17 |
+
</div>
|
| 18 |
+
<Slider
|
| 19 |
+
min={1}
|
| 20 |
+
max={4}
|
| 21 |
+
step={1}
|
| 22 |
+
value={[value]}
|
| 23 |
+
onValueChange={(newValue) => onChange(newValue[0])}
|
| 24 |
+
className="w-full"
|
| 25 |
+
formatLabel={(value) => `${value}`}
|
| 26 |
+
/>
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
components/ImageGenerator/ModelSelector.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import {
|
| 4 |
+
Select,
|
| 5 |
+
SelectContent,
|
| 6 |
+
SelectItem,
|
| 7 |
+
SelectTrigger,
|
| 8 |
+
SelectValue,
|
| 9 |
+
} from '../ui/select'
|
| 10 |
+
|
| 11 |
+
interface ModelSelectorProps {
|
| 12 |
+
value: string;
|
| 13 |
+
onChange: (value: string) => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
| 17 |
+
return (
|
| 18 |
+
<div className="flex max-w-[480px] flex-wrap items-end gap-4 px-4 py-3">
|
| 19 |
+
<label className="flex flex-col min-w-40 flex-1">
|
| 20 |
+
<p className="text-[#141414] dark:text-white text-base font-medium leading-normal pb-2">Model</p>
|
| 21 |
+
<Select value={value} onValueChange={onChange}>
|
| 22 |
+
<SelectTrigger className="w-full bg-[#ededed] dark:bg-gray-900 border-none rounded-2xl h-14 px-4">
|
| 23 |
+
<SelectValue placeholder="Choose model" />
|
| 24 |
+
</SelectTrigger>
|
| 25 |
+
<SelectContent className="bg-[#ededed] dark:bg-gray-900 rounded-2xl">
|
| 26 |
+
<SelectItem value="runware:100@1">FLUX SCHNELL</SelectItem>
|
| 27 |
+
<SelectItem value="runware:101@1">FLUX DEV</SelectItem>
|
| 28 |
+
</SelectContent>
|
| 29 |
+
</Select>
|
| 30 |
+
</label>
|
| 31 |
+
</div>
|
| 32 |
+
)
|
| 33 |
+
}
|
components/ImageGenerator/PromptInput.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { Wand2, Undo2 } from 'lucide-react';
|
| 3 |
+
import { Button } from '../ui/button';
|
| 4 |
+
|
| 5 |
+
interface PromptInputProps {
|
| 6 |
+
value: string;
|
| 7 |
+
onChange: (value: string) => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function PromptInput({ value, onChange }: PromptInputProps) {
|
| 11 |
+
const [isEnhancing, setIsEnhancing] = useState(false);
|
| 12 |
+
const [previousPrompt, setPreviousPrompt] = useState('');
|
| 13 |
+
const [isEnhanced, setIsEnhanced] = useState(false);
|
| 14 |
+
|
| 15 |
+
const enhancePrompt = useCallback(async () => {
|
| 16 |
+
if (!value.trim() || isEnhancing) return;
|
| 17 |
+
|
| 18 |
+
setIsEnhancing(true);
|
| 19 |
+
setPreviousPrompt(value);
|
| 20 |
+
try {
|
| 21 |
+
const response = await fetch('/api/enhance-prompt', {
|
| 22 |
+
method: 'POST',
|
| 23 |
+
headers: {
|
| 24 |
+
'Content-Type': 'application/json',
|
| 25 |
+
},
|
| 26 |
+
body: JSON.stringify({ prompt: value }),
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
if (!response.ok) {
|
| 30 |
+
throw new Error('Failed to enhance prompt');
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const data = await response.json();
|
| 34 |
+
onChange(data.enhancedPrompt);
|
| 35 |
+
setIsEnhanced(true);
|
| 36 |
+
} catch (error) {
|
| 37 |
+
console.error('Error enhancing prompt:', error);
|
| 38 |
+
} finally {
|
| 39 |
+
setIsEnhancing(false);
|
| 40 |
+
}
|
| 41 |
+
}, [value, onChange]);
|
| 42 |
+
|
| 43 |
+
const undoEnhance = useCallback(() => {
|
| 44 |
+
onChange(previousPrompt);
|
| 45 |
+
setIsEnhanced(false);
|
| 46 |
+
}, [previousPrompt, onChange]);
|
| 47 |
+
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
const handleKeyDown = (event: KeyboardEvent) => {
|
| 50 |
+
if ((event.ctrlKey || event.metaKey) && event.key === 'i') {
|
| 51 |
+
event.preventDefault();
|
| 52 |
+
enhancePrompt();
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
document.addEventListener('keydown', handleKeyDown);
|
| 57 |
+
return () => {
|
| 58 |
+
document.removeEventListener('keydown', handleKeyDown);
|
| 59 |
+
};
|
| 60 |
+
}, [enhancePrompt]);
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="flex flex-col w-full gap-2 px-4 py-3">
|
| 64 |
+
<div className="flex justify-between items-center">
|
| 65 |
+
<label htmlFor="prompt-input" className="text-[#141414] dark:text-white text-base font-medium leading-normal">
|
| 66 |
+
Prompt
|
| 67 |
+
</label>
|
| 68 |
+
<div className="flex gap-2">
|
| 69 |
+
{isEnhanced && (
|
| 70 |
+
<Button
|
| 71 |
+
variant="ghost"
|
| 72 |
+
size="icon"
|
| 73 |
+
onClick={undoEnhance}
|
| 74 |
+
title="Undo enhance"
|
| 75 |
+
className="h-8 w-8"
|
| 76 |
+
>
|
| 77 |
+
<Undo2 className="h-4 w-4" />
|
| 78 |
+
</Button>
|
| 79 |
+
)}
|
| 80 |
+
<Button
|
| 81 |
+
variant="ghost"
|
| 82 |
+
size="icon"
|
| 83 |
+
onClick={enhancePrompt}
|
| 84 |
+
disabled={isEnhancing || !value.trim()}
|
| 85 |
+
title="Enhance prompt (Ctrl+I / Cmd+I)"
|
| 86 |
+
className="h-8 w-8"
|
| 87 |
+
>
|
| 88 |
+
<Wand2 className={`h-4 w-4 ${isEnhancing ? 'animate-pulse' : ''}`} />
|
| 89 |
+
</Button>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
<textarea
|
| 93 |
+
id="prompt-input"
|
| 94 |
+
value={value}
|
| 95 |
+
onChange={(e) => onChange(e.target.value)}
|
| 96 |
+
placeholder="Type your prompt here"
|
| 97 |
+
className="w-full min-h-[150px] p-4 rounded-xl text-[#141414] dark:text-white focus:outline-none focus:ring-0 border-none bg-[#ededed] dark:bg-gray-900 placeholder:text-neutral-500 dark:placeholder:text-gray-400 text-base font-normal leading-normal resize-none"
|
| 98 |
+
/>
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
components/Layout.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { ThemeProvider } from 'next-themes'
|
| 4 |
+
|
| 5 |
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
| 6 |
+
return (
|
| 7 |
+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
| 8 |
+
<div className="relative flex min-h-screen flex-col bg-neutral-50 dark:bg-amoled-black overflow-x-hidden">
|
| 9 |
+
<main className="flex-grow">
|
| 10 |
+
{children}
|
| 11 |
+
</main>
|
| 12 |
+
</div>
|
| 13 |
+
</ThemeProvider>
|
| 14 |
+
);
|
| 15 |
+
}
|
components/MainContent.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ImageGrid from './Gallery/ImageGrid';
|
| 2 |
+
|
| 3 |
+
export default function MainContent() {
|
| 4 |
+
const currentImages = [
|
| 5 |
+
{ url: "https://cdn.usegalileo.ai/stability/01f4939b-b0b4-4a14-ba61-a2f049aa4cde.png" },
|
| 6 |
+
{ url: "https://cdn.usegalileo.ai/stability/43b6ac61-1427-4ddf-8c99-ab43c5cb8746.png" },
|
| 7 |
+
{ url: "https://cdn.usegalileo.ai/stability/84b80f3d-decd-468c-ae69-6deabde1c7b2.png" }
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
const previousImages = [
|
| 11 |
+
{ url: "https://cdn.usegalileo.ai/stability/318d9396-efa7-485d-8433-2e2de66d869b.png" },
|
| 12 |
+
{ url: "https://cdn.usegalileo.ai/stability/3a91c59c-6f67-4624-b28b-ebba9aea7a60.png" },
|
| 13 |
+
{ url: "https://cdn.usegalileo.ai/stability/aa91a32b-9d69-4382-afdf-f9ca99cef62a.png" }
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div className="layout-content-container flex flex-col max-w-[960px] flex-1">
|
| 18 |
+
<div className="flex flex-wrap justify-between gap-3 p-4">
|
| 19 |
+
<p className="text-[#141414] text-4xl font-black leading-tight tracking-[-0.033em] min-w-72">
|
| 20 |
+
Fantasy castle on a hilltop, surrounded by rolling hills and a beautiful sunset, magical, serene, high details, romantic, stylized
|
| 21 |
+
</p>
|
| 22 |
+
</div>
|
| 23 |
+
<ImageGrid title="Current Generation" images={currentImages} batchId={1} />
|
| 24 |
+
<ImageGrid title="Previously Generated" images={previousImages} batchId={2} />
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
}
|
components/Navbar.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { ThemeToggle } from './ThemeToggle';
|
| 4 |
+
import { Button } from './ui/button';
|
| 5 |
+
import { useRouter, usePathname } from 'next/navigation';
|
| 6 |
+
import { LogOut, RefreshCw, ImagePlus } from 'lucide-react';
|
| 7 |
+
import { useState } from 'react';
|
| 8 |
+
import Link from 'next/link';
|
| 9 |
+
|
| 10 |
+
interface NavbarProps {
|
| 11 |
+
onReload: () => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function Navbar({ onReload }: NavbarProps) {
|
| 15 |
+
const router = useRouter();
|
| 16 |
+
const pathname = usePathname();
|
| 17 |
+
const [isReloading, setIsReloading] = useState(false);
|
| 18 |
+
|
| 19 |
+
const handleLogout = () => {
|
| 20 |
+
// Clear the authentication cookie
|
| 21 |
+
document.cookie = 'isAuthenticated=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
| 22 |
+
// Redirect to the password page
|
| 23 |
+
router.push('/password');
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const handleReload = () => {
|
| 27 |
+
setIsReloading(true);
|
| 28 |
+
onReload();
|
| 29 |
+
setTimeout(() => setIsReloading(false), 500); // Reset after 0.5 seconds
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const handleHomeClick = () => {
|
| 33 |
+
if (pathname === '/') {
|
| 34 |
+
window.location.reload();
|
| 35 |
+
} else {
|
| 36 |
+
router.push('/');
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const isPasswordPage = pathname === '/password';
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<header className="flex items-center justify-between whitespace-nowrap border-b border-solid border-b-[#ededed] dark:border-b-gray-800 px-10 py-0.5 bg-white dark:bg-amoled-black text-[#141414] dark:text-white">
|
| 44 |
+
<div className="flex items-center gap-4 cursor-pointer" onClick={handleHomeClick}>
|
| 45 |
+
<ImagePlus className="h-6 w-6 text-primary" />
|
| 46 |
+
<h2 className="text-lg font-bold leading-tight tracking-[-0.015em]">Imagine</h2>
|
| 47 |
+
</div>
|
| 48 |
+
<div className="flex items-center gap-4">
|
| 49 |
+
{!isPasswordPage && (
|
| 50 |
+
<>
|
| 51 |
+
<Button
|
| 52 |
+
onClick={handleReload}
|
| 53 |
+
variant="ghost"
|
| 54 |
+
size="icon"
|
| 55 |
+
title="Reload batches"
|
| 56 |
+
className={`transition-transform duration-500 ease-in-out ${isReloading ? 'rotate-180' : ''}`}
|
| 57 |
+
disabled={isReloading}
|
| 58 |
+
>
|
| 59 |
+
<RefreshCw className={`h-[1.2rem] w-[1.2rem] ${isReloading ? 'animate-spin' : ''}`} />
|
| 60 |
+
<span className="sr-only">Reload</span>
|
| 61 |
+
</Button>
|
| 62 |
+
<ThemeToggle />
|
| 63 |
+
<Button onClick={handleLogout} variant="ghost" size="icon">
|
| 64 |
+
<LogOut className="h-[1.2rem] w-[1.2rem]" />
|
| 65 |
+
<span className="sr-only">Logout</span>
|
| 66 |
+
</Button>
|
| 67 |
+
</>
|
| 68 |
+
)}
|
| 69 |
+
</div>
|
| 70 |
+
</header>
|
| 71 |
+
);
|
| 72 |
+
}
|
components/SearchInput.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function SearchInput() {
|
| 2 |
+
return (
|
| 3 |
+
<label className="flex flex-col min-w-40 !h-10 max-w-64">
|
| 4 |
+
<div className="flex w-full flex-1 items-stretch rounded-xl h-full">
|
| 5 |
+
<div className="text-neutral-500 dark:text-gray-400 flex border-none bg-[#ededed] dark:bg-gray-900 items-center justify-center pl-4 rounded-l-xl border-r-0">
|
| 6 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" fill="currentColor" viewBox="0 0 256 256">
|
| 7 |
+
<path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"></path>
|
| 8 |
+
</svg>
|
| 9 |
+
</div>
|
| 10 |
+
<input
|
| 11 |
+
placeholder="Search"
|
| 12 |
+
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-xl text-[#141414] dark:text-white focus:outline-0 focus:ring-0 border-none bg-[#ededed] dark:bg-gray-900 focus:border-none h-full placeholder:text-neutral-500 dark:placeholder:text-gray-400 px-4 rounded-l-none border-l-0 pl-2 text-base font-normal leading-normal"
|
| 13 |
+
/>
|
| 14 |
+
</div>
|
| 15 |
+
</label>
|
| 16 |
+
);
|
| 17 |
+
}
|
components/Sidebar.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function Sidebar() {
|
| 2 |
+
return (
|
| 3 |
+
<div className="layout-content-container flex flex-col w-80">
|
| 4 |
+
<h2 className="text-[#141414] text-[22px] font-bold leading-tight tracking-[-0.015em] px-4 pb-3 pt-5">Create</h2>
|
| 5 |
+
<div className="flex max-w-[480px] flex-wrap items-end gap-4 px-4 py-3">
|
| 6 |
+
<label className="flex flex-col min-w-40 flex-1">
|
| 7 |
+
<p className="text-[#141414] text-base font-medium leading-normal pb-2">Model</p>
|
| 8 |
+
<select className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-xl text-[#141414] focus:outline-0 focus:ring-0 border-none bg-[#ededed] focus:border-none h-14 bg-[image:--select-button-svg] placeholder:text-neutral-500 p-4 text-base font-normal leading-normal">
|
| 9 |
+
<option value="one">Choose model</option>
|
| 10 |
+
<option value="two">two</option>
|
| 11 |
+
<option value="three">three</option>
|
| 12 |
+
</select>
|
| 13 |
+
</label>
|
| 14 |
+
</div>
|
| 15 |
+
<div className="flex max-w-[480px] flex-wrap items-end gap-4 px-4 py-3">
|
| 16 |
+
<label className="flex flex-col min-w-40 flex-1">
|
| 17 |
+
<p className="text-[#141414] text-base font-medium leading-normal pb-2">Prompt</p>
|
| 18 |
+
<textarea
|
| 19 |
+
placeholder="Type your prompt here"
|
| 20 |
+
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-xl text-[#141414] focus:outline-0 focus:ring-0 border-none bg-[#ededed] focus:border-none min-h-36 placeholder:text-neutral-500 p-4 text-base font-normal leading-normal"
|
| 21 |
+
></textarea>
|
| 22 |
+
</label>
|
| 23 |
+
</div>
|
| 24 |
+
<div className="flex max-w-[480px] flex-wrap items-end gap-4 px-4 py-3">
|
| 25 |
+
<label className="flex flex-col min-w-40 flex-1">
|
| 26 |
+
<p className="text-[#141414] text-base font-medium leading-normal pb-2">Aspect Ratio</p>
|
| 27 |
+
<select className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-xl text-[#141414] focus:outline-0 focus:ring-0 border-none bg-[#ededed] focus:border-none h-14 bg-[image:--select-button-svg] placeholder:text-neutral-500 p-4 text-base font-normal leading-normal">
|
| 28 |
+
<option value="one">Choose aspect ratio</option>
|
| 29 |
+
<option value="two">two</option>
|
| 30 |
+
<option value="three">three</option>
|
| 31 |
+
</select>
|
| 32 |
+
</label>
|
| 33 |
+
</div>
|
| 34 |
+
<div className="@container">
|
| 35 |
+
<div className="relative flex w-full flex-col items-start justify-between gap-3 p-4 @[480px]:flex-row @[480px]:items-center">
|
| 36 |
+
<div className="flex w-full shrink-[3] items-center justify-between">
|
| 37 |
+
<p className="text-[#141414] text-base font-medium leading-normal">Image Count</p>
|
| 38 |
+
<p className="text-[#141414] text-sm font-normal leading-normal @[480px]:hidden">3</p>
|
| 39 |
+
</div>
|
| 40 |
+
<div className="flex h-4 w-full items-center gap-4">
|
| 41 |
+
<div className="flex h-1 flex-1 rounded-sm bg-[#dbdbdb]">
|
| 42 |
+
<div className="h-full w-[32%] rounded-sm bg-white"></div>
|
| 43 |
+
<div className="relative"><div className="absolute -left-2 -top-1.5 size-4 rounded-full bg-white"></div></div>
|
| 44 |
+
</div>
|
| 45 |
+
<p className="text-[#141414] text-sm font-normal leading-normal hidden @[480px]:block">3</p>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
<div className="flex px-4 py-3">
|
| 50 |
+
<button className="flex min-w-[84px] max-w-[480px] cursor-pointer items-center justify-center overflow-hidden rounded-xl h-10 px-4 flex-1 bg-white text-[#141414] gap-2 pl-4 text-sm font-bold leading-normal tracking-[0.015em]">
|
| 51 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" fill="currentColor" viewBox="0 0 256 256">
|
| 52 |
+
<path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"></path>
|
| 53 |
+
</svg>
|
| 54 |
+
<span className="truncate">Dream (0.91)</span>
|
| 55 |
+
</button>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
}
|
components/ThemeToggle.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
+
import { useTheme } from 'next-themes'
|
| 5 |
+
import { Moon, Sun } from 'lucide-react'
|
| 6 |
+
import { Button } from './ui/button'
|
| 7 |
+
|
| 8 |
+
export function ThemeToggle() {
|
| 9 |
+
const [mounted, setMounted] = useState(false)
|
| 10 |
+
const { theme, setTheme } = useTheme()
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
setMounted(true)
|
| 14 |
+
}, [])
|
| 15 |
+
|
| 16 |
+
if (!mounted) {
|
| 17 |
+
return null
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<Button
|
| 22 |
+
variant="ghost"
|
| 23 |
+
size="icon"
|
| 24 |
+
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
| 25 |
+
>
|
| 26 |
+
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
| 27 |
+
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
| 28 |
+
<span className="sr-only">Toggle theme</span>
|
| 29 |
+
</Button>
|
| 30 |
+
)
|
| 31 |
+
}
|
components/User/CreditDisplay.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
// This component is no longer needed and can be removed.
|
components/ui/button.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from 'react'
|
| 2 |
+
import { Slot } from '@radix-ui/react-slot'
|
| 3 |
+
import { type VariantProps, cva } from 'class-variance-authority'
|
| 4 |
+
|
| 5 |
+
import { ny } from '@/lib/utils'
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
| 13 |
+
destructive:
|
| 14 |
+
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
| 15 |
+
outline:
|
| 16 |
+
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
| 17 |
+
secondary:
|
| 18 |
+
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
| 19 |
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
| 20 |
+
link: 'text-primary underline-offset-4 hover:underline',
|
| 21 |
+
},
|
| 22 |
+
size: {
|
| 23 |
+
default: 'h-10 px-4 py-2',
|
| 24 |
+
sm: 'h-9 rounded-md px-3',
|
| 25 |
+
lg: 'h-11 rounded-md px-8',
|
| 26 |
+
icon: 'h-10 w-10',
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
defaultVariants: {
|
| 30 |
+
variant: 'default',
|
| 31 |
+
size: 'default',
|
| 32 |
+
},
|
| 33 |
+
},
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
export interface ButtonProps
|
| 37 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
| 38 |
+
VariantProps<typeof buttonVariants> {
|
| 39 |
+
asChild?: boolean
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 43 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 44 |
+
const Comp = asChild ? Slot : 'button'
|
| 45 |
+
return (
|
| 46 |
+
<Comp
|
| 47 |
+
className={ny(buttonVariants({ variant, size, className }))}
|
| 48 |
+
ref={ref}
|
| 49 |
+
{...props}
|
| 50 |
+
/>
|
| 51 |
+
)
|
| 52 |
+
},
|
| 53 |
+
)
|
| 54 |
+
Button.displayName = 'Button'
|
| 55 |
+
|
| 56 |
+
export { Button, buttonVariants }
|
components/ui/input.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { ny } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
export interface InputProps
|
| 6 |
+
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
| 7 |
+
|
| 8 |
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
| 9 |
+
({ className, type, ...props }, ref) => {
|
| 10 |
+
return (
|
| 11 |
+
<input
|
| 12 |
+
type={type}
|
| 13 |
+
className={ny(
|
| 14 |
+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
| 15 |
+
className
|
| 16 |
+
)}
|
| 17 |
+
ref={ref}
|
| 18 |
+
{...props}
|
| 19 |
+
/>
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
)
|
| 23 |
+
Input.displayName = "Input"
|
| 24 |
+
|
| 25 |
+
export { Input }
|
components/ui/select.tsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import * as React from 'react'
|
| 4 |
+
import * as SelectPrimitive from '@radix-ui/react-select'
|
| 5 |
+
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
| 6 |
+
|
| 7 |
+
import { ny } from '@/lib/utils'
|
| 8 |
+
|
| 9 |
+
const Select = SelectPrimitive.Root
|
| 10 |
+
|
| 11 |
+
const SelectGroup = SelectPrimitive.Group
|
| 12 |
+
|
| 13 |
+
const SelectValue = SelectPrimitive.Value
|
| 14 |
+
|
| 15 |
+
const SelectTrigger = React.forwardRef<
|
| 16 |
+
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
| 17 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
| 18 |
+
>(({ className, children, ...props }, ref) => (
|
| 19 |
+
<SelectPrimitive.Trigger
|
| 20 |
+
ref={ref}
|
| 21 |
+
className={ny(
|
| 22 |
+
'border-input bg-background ring-offset-background placeholder:data-[placeholder]:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-full border px-3 py-2 text-left text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
| 23 |
+
className,
|
| 24 |
+
)}
|
| 25 |
+
{...props}
|
| 26 |
+
>
|
| 27 |
+
{children}
|
| 28 |
+
<SelectPrimitive.Icon asChild>
|
| 29 |
+
<ChevronDown className="size-4 shrink-0 opacity-50" />
|
| 30 |
+
</SelectPrimitive.Icon>
|
| 31 |
+
</SelectPrimitive.Trigger>
|
| 32 |
+
))
|
| 33 |
+
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
| 34 |
+
|
| 35 |
+
const SelectScrollUpButton = React.forwardRef<
|
| 36 |
+
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
| 37 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
| 38 |
+
>(({ className, ...props }, ref) => (
|
| 39 |
+
<SelectPrimitive.ScrollUpButton
|
| 40 |
+
ref={ref}
|
| 41 |
+
className={ny(
|
| 42 |
+
'flex cursor-default items-center justify-center py-1',
|
| 43 |
+
className,
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
>
|
| 47 |
+
<ChevronUp className="size-4" />
|
| 48 |
+
</SelectPrimitive.ScrollUpButton>
|
| 49 |
+
))
|
| 50 |
+
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
| 51 |
+
|
| 52 |
+
const SelectScrollDownButton = React.forwardRef<
|
| 53 |
+
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
| 54 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
| 55 |
+
>(({ className, ...props }, ref) => (
|
| 56 |
+
<SelectPrimitive.ScrollDownButton
|
| 57 |
+
ref={ref}
|
| 58 |
+
className={ny(
|
| 59 |
+
'flex cursor-default items-center justify-center py-1',
|
| 60 |
+
className,
|
| 61 |
+
)}
|
| 62 |
+
{...props}
|
| 63 |
+
>
|
| 64 |
+
<ChevronDown className="size-4" />
|
| 65 |
+
</SelectPrimitive.ScrollDownButton>
|
| 66 |
+
))
|
| 67 |
+
SelectScrollDownButton.displayName
|
| 68 |
+
= SelectPrimitive.ScrollDownButton.displayName
|
| 69 |
+
|
| 70 |
+
const SelectContent = React.forwardRef<
|
| 71 |
+
React.ElementRef<typeof SelectPrimitive.Content>,
|
| 72 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
| 73 |
+
>(({ className, children, position = 'popper', ...props }, ref) => (
|
| 74 |
+
<SelectPrimitive.Portal>
|
| 75 |
+
<SelectPrimitive.Content
|
| 76 |
+
ref={ref}
|
| 77 |
+
className={ny(
|
| 78 |
+
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border shadow-md',
|
| 79 |
+
position === 'popper'
|
| 80 |
+
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
| 81 |
+
className,
|
| 82 |
+
)}
|
| 83 |
+
position={position}
|
| 84 |
+
{...props}
|
| 85 |
+
>
|
| 86 |
+
<SelectScrollUpButton />
|
| 87 |
+
<SelectPrimitive.Viewport
|
| 88 |
+
className={ny(
|
| 89 |
+
'p-1',
|
| 90 |
+
position === 'popper'
|
| 91 |
+
&& 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
| 92 |
+
)}
|
| 93 |
+
>
|
| 94 |
+
{children}
|
| 95 |
+
</SelectPrimitive.Viewport>
|
| 96 |
+
<SelectScrollDownButton />
|
| 97 |
+
</SelectPrimitive.Content>
|
| 98 |
+
</SelectPrimitive.Portal>
|
| 99 |
+
))
|
| 100 |
+
SelectContent.displayName = SelectPrimitive.Content.displayName
|
| 101 |
+
|
| 102 |
+
const SelectLabel = React.forwardRef<
|
| 103 |
+
React.ElementRef<typeof SelectPrimitive.Label>,
|
| 104 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
| 105 |
+
>(({ className, ...props }, ref) => (
|
| 106 |
+
<SelectPrimitive.Label
|
| 107 |
+
ref={ref}
|
| 108 |
+
className={ny('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
| 109 |
+
{...props}
|
| 110 |
+
/>
|
| 111 |
+
))
|
| 112 |
+
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
| 113 |
+
|
| 114 |
+
const SelectItem = React.forwardRef<
|
| 115 |
+
React.ElementRef<typeof SelectPrimitive.Item>,
|
| 116 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
| 117 |
+
>(({ className, children, ...props }, ref) => (
|
| 118 |
+
<SelectPrimitive.Item
|
| 119 |
+
ref={ref}
|
| 120 |
+
className={ny(
|
| 121 |
+
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
| 122 |
+
className,
|
| 123 |
+
)}
|
| 124 |
+
{...props}
|
| 125 |
+
>
|
| 126 |
+
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
| 127 |
+
<SelectPrimitive.ItemIndicator>
|
| 128 |
+
<Check className="size-4" />
|
| 129 |
+
</SelectPrimitive.ItemIndicator>
|
| 130 |
+
</span>
|
| 131 |
+
|
| 132 |
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
| 133 |
+
</SelectPrimitive.Item>
|
| 134 |
+
))
|
| 135 |
+
SelectItem.displayName = SelectPrimitive.Item.displayName
|
| 136 |
+
|
| 137 |
+
const SelectSeparator = React.forwardRef<
|
| 138 |
+
React.ElementRef<typeof SelectPrimitive.Separator>,
|
| 139 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
| 140 |
+
>(({ className, ...props }, ref) => (
|
| 141 |
+
<SelectPrimitive.Separator
|
| 142 |
+
ref={ref}
|
| 143 |
+
className={ny('bg-muted -mx-1 my-1 h-px', className)}
|
| 144 |
+
{...props}
|
| 145 |
+
/>
|
| 146 |
+
))
|
| 147 |
+
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
| 148 |
+
|
| 149 |
+
export {
|
| 150 |
+
Select,
|
| 151 |
+
SelectGroup,
|
| 152 |
+
SelectValue,
|
| 153 |
+
SelectTrigger,
|
| 154 |
+
SelectContent,
|
| 155 |
+
SelectLabel,
|
| 156 |
+
SelectItem,
|
| 157 |
+
SelectSeparator,
|
| 158 |
+
SelectScrollUpButton,
|
| 159 |
+
SelectScrollDownButton,
|
| 160 |
+
}
|
components/ui/slider.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import * as React from 'react'
|
| 4 |
+
import * as SliderPrimitive from '@radix-ui/react-slider'
|
| 5 |
+
|
| 6 |
+
import { ny } from '@/lib/utils'
|
| 7 |
+
|
| 8 |
+
interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
|
| 9 |
+
showSteps?: 'none' | 'half' | 'full'
|
| 10 |
+
formatLabel?: (value: number) => string
|
| 11 |
+
formatLabelSide?: string
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const Slider = React.forwardRef<
|
| 15 |
+
React.ElementRef<typeof SliderPrimitive.Root>,
|
| 16 |
+
SliderProps
|
| 17 |
+
>(({ className, showSteps = 'none', formatLabel, formatLabelSide = 'top', ...props }, ref) => {
|
| 18 |
+
const { min = 0, max = 100, step = 1, orientation = 'horizontal', value, defaultValue, onValueChange } = props
|
| 19 |
+
const [hoveredThumbIndex, setHoveredThumbIndex] = React.useState<boolean>(false)
|
| 20 |
+
const numberOfSteps = Math.floor((max - min) / step)
|
| 21 |
+
const stepLines = Array.from({ length: numberOfSteps }, (_, index) => index * step + min)
|
| 22 |
+
|
| 23 |
+
const initialValue = Array.isArray(value) ? value : (Array.isArray(defaultValue) ? defaultValue : [min, max])
|
| 24 |
+
const [localValues, setLocalValues] = React.useState<number[]>(initialValue)
|
| 25 |
+
|
| 26 |
+
React.useEffect(() => {
|
| 27 |
+
if (!isEqual(value, localValues))
|
| 28 |
+
setLocalValues(Array.isArray(value) ? value : (Array.isArray(defaultValue) ? defaultValue : [min, max]))
|
| 29 |
+
}, [min, max, value])
|
| 30 |
+
|
| 31 |
+
const handleValueChange = (newValues: number[]) => {
|
| 32 |
+
setLocalValues(newValues)
|
| 33 |
+
if (onValueChange)
|
| 34 |
+
onValueChange(newValues)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function isEqual(array1: number[] | undefined, array2: number[] | undefined) {
|
| 38 |
+
array1 = array1 ?? []
|
| 39 |
+
array2 = array2 ?? []
|
| 40 |
+
|
| 41 |
+
if (array1.length !== array2.length)
|
| 42 |
+
return false
|
| 43 |
+
|
| 44 |
+
for (let i = 0; i < array1.length; i++) {
|
| 45 |
+
if (array1[i] !== array2[i])
|
| 46 |
+
return false
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return true
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<SliderPrimitive.Root
|
| 54 |
+
ref={ref}
|
| 55 |
+
className={ny(
|
| 56 |
+
'relative flex cursor-pointer touch-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
| 57 |
+
orientation === 'horizontal' ? 'w-full items-center' : 'h-full justify-center',
|
| 58 |
+
className,
|
| 59 |
+
)}
|
| 60 |
+
min={min}
|
| 61 |
+
max={max}
|
| 62 |
+
step={step}
|
| 63 |
+
value={localValues}
|
| 64 |
+
onValueChange={value => handleValueChange(value)}
|
| 65 |
+
{...props}
|
| 66 |
+
onFocus={() => setHoveredThumbIndex(true)}
|
| 67 |
+
onBlur={() => setHoveredThumbIndex(false)}
|
| 68 |
+
>
|
| 69 |
+
<SliderPrimitive.Track className={ny(
|
| 70 |
+
'bg-primary/20 relative grow overflow-hidden rounded-full',
|
| 71 |
+
orientation === 'horizontal' ? 'h-1.5 w-full' : 'h-full w-1.5',
|
| 72 |
+
)}
|
| 73 |
+
>
|
| 74 |
+
<SliderPrimitive.Range className={ny(
|
| 75 |
+
'bg-primary absolute',
|
| 76 |
+
orientation === 'horizontal' ? 'h-full' : 'w-full',
|
| 77 |
+
)}
|
| 78 |
+
/>
|
| 79 |
+
{showSteps !== undefined && showSteps !== 'none' && stepLines.map((value, index) => {
|
| 80 |
+
if (value === min || value === max)
|
| 81 |
+
return null
|
| 82 |
+
|
| 83 |
+
const positionPercentage = ((value - min) / (max - min)) * 100
|
| 84 |
+
const adjustedPosition = 50 + (positionPercentage - 50) * 0.96
|
| 85 |
+
return (
|
| 86 |
+
<div
|
| 87 |
+
key={index}
|
| 88 |
+
className={ny(
|
| 89 |
+
{ 'w-0.5 h-2': orientation !== 'vertical', 'w-2 h-0.5': orientation === 'vertical' },
|
| 90 |
+
'bg-muted-foreground absolute',
|
| 91 |
+
{
|
| 92 |
+
'left-1': orientation === 'vertical' && showSteps === 'half',
|
| 93 |
+
'top-1': orientation !== 'vertical' && showSteps === 'half',
|
| 94 |
+
'left-0': orientation === 'vertical' && showSteps === 'full',
|
| 95 |
+
'top-0': orientation !== 'vertical' && showSteps === 'full',
|
| 96 |
+
'-translate-x-1/2': orientation !== 'vertical',
|
| 97 |
+
'-translate-y-1/2': orientation === 'vertical',
|
| 98 |
+
},
|
| 99 |
+
)}
|
| 100 |
+
style={{
|
| 101 |
+
[orientation === 'vertical' ? 'bottom' : 'left']: `${adjustedPosition}%`,
|
| 102 |
+
}}
|
| 103 |
+
/>
|
| 104 |
+
)
|
| 105 |
+
})}
|
| 106 |
+
|
| 107 |
+
</SliderPrimitive.Track>
|
| 108 |
+
{localValues.map((numberStep, index) => (
|
| 109 |
+
<SliderPrimitive.Thumb
|
| 110 |
+
key={index}
|
| 111 |
+
className={ny(
|
| 112 |
+
'border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow transition-colors focus-visible:outline-none focus-visible:ring-1',
|
| 113 |
+
)}
|
| 114 |
+
>
|
| 115 |
+
{hoveredThumbIndex && formatLabel && (
|
| 116 |
+
<div
|
| 117 |
+
className={ny(
|
| 118 |
+
{ 'bottom-8 left-1/2 -translate-x-1/2': formatLabelSide === 'top' },
|
| 119 |
+
{ 'top-8 left-1/2 -translate-x-1/2': formatLabelSide === 'bottom' },
|
| 120 |
+
{ 'right-8 -translate-y-1/4': formatLabelSide === 'left' },
|
| 121 |
+
{ 'left-8 -translate-y-1/4': formatLabelSide === 'right' },
|
| 122 |
+
'bg-popover text-popover-foreground absolute z-30 w-max items-center justify-items-center rounded-md border px-2 py-1 text-center shadow-sm',
|
| 123 |
+
)}
|
| 124 |
+
>
|
| 125 |
+
{formatLabel(numberStep)}
|
| 126 |
+
</div>
|
| 127 |
+
)}
|
| 128 |
+
</SliderPrimitive.Thumb>
|
| 129 |
+
))}
|
| 130 |
+
</SliderPrimitive.Root>
|
| 131 |
+
)
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
Slider.displayName = SliderPrimitive.Root.displayName
|
| 135 |
+
|
| 136 |
+
export { Slider }
|
components/ui/tooltip.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
| 5 |
+
|
| 6 |
+
import { ny } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
const TooltipProvider = TooltipPrimitive.Provider
|
| 9 |
+
|
| 10 |
+
const Tooltip = TooltipPrimitive.Root
|
| 11 |
+
|
| 12 |
+
const TooltipTrigger = TooltipPrimitive.Trigger
|
| 13 |
+
|
| 14 |
+
const TooltipContent = React.forwardRef<
|
| 15 |
+
React.ElementRef<typeof TooltipPrimitive.Content>,
|
| 16 |
+
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
| 17 |
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
| 18 |
+
<TooltipPrimitive.Content
|
| 19 |
+
ref={ref}
|
| 20 |
+
sideOffset={sideOffset}
|
| 21 |
+
className={ny(
|
| 22 |
+
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
| 23 |
+
className
|
| 24 |
+
)}
|
| 25 |
+
{...props}
|
| 26 |
+
/>
|
| 27 |
+
))
|
| 28 |
+
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
| 29 |
+
|
| 30 |
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ClassValue, clsx } from "clsx"
|
| 2 |
+
import { twMerge } from "tailwind-merge"
|
| 3 |
+
|
| 4 |
+
export function ny(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs))
|
| 6 |
+
}
|
middleware.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import type { NextRequest } from 'next/server';
|
| 3 |
+
|
| 4 |
+
export function middleware(request: NextRequest) {
|
| 5 |
+
const isAuthenticated = request.cookies.get('isAuthenticated')?.value === 'true';
|
| 6 |
+
const isPasswordPage = request.nextUrl.pathname === '/password';
|
| 7 |
+
|
| 8 |
+
if (!isAuthenticated && !isPasswordPage) {
|
| 9 |
+
return NextResponse.redirect(new URL('/password', request.url));
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
if (isAuthenticated && isPasswordPage) {
|
| 13 |
+
return NextResponse.redirect(new URL('/', request.url));
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
return NextResponse.next();
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const config = {
|
| 20 |
+
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
| 21 |
+
};
|
next.config.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
typescript: {
|
| 4 |
+
// !! WARN !!
|
| 5 |
+
// Dangerously allow production builds to successfully complete even if
|
| 6 |
+
// your project has type errors.
|
| 7 |
+
// !! WARN !!
|
| 8 |
+
ignoreBuildErrors: true,
|
| 9 |
+
},
|
| 10 |
+
rewrites: async () => {
|
| 11 |
+
return [
|
| 12 |
+
{
|
| 13 |
+
source: "/api/:path*",
|
| 14 |
+
destination:
|
| 15 |
+
process.env.NODE_ENV === "development"
|
| 16 |
+
? "http://127.0.0.1:8000/api/:path*"
|
| 17 |
+
: "/api/",
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
source: "/docs",
|
| 21 |
+
destination:
|
| 22 |
+
process.env.NODE_ENV === "development"
|
| 23 |
+
? "http://127.0.0.1:8000/docs"
|
| 24 |
+
: "/api/docs",
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
source: "/openapi.json",
|
| 28 |
+
destination:
|
| 29 |
+
process.env.NODE_ENV === "development"
|
| 30 |
+
? "http://127.0.0.1:8000/openapi.json"
|
| 31 |
+
: "/api/openapi.json",
|
| 32 |
+
},
|
| 33 |
+
];
|
| 34 |
+
},
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
export default nextConfig;
|
nyxbui.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://nyxbui.design/schema.json",
|
| 3 |
+
"style": "default",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "tailwind.config.ts",
|
| 8 |
+
"css": "app/globals.css",
|
| 9 |
+
"baseColor": "slate",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils"
|
| 16 |
+
}
|
| 17 |
+
}
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "fastapi-corrector",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"fastapi-dev": "pip install -r requirements.txt && uvicorn app.api.main:app --reload",
|
| 7 |
+
"next-dev": "next dev",
|
| 8 |
+
"dev": "next dev",
|
| 9 |
+
"build": "next build",
|
| 10 |
+
"start": "next start",
|
| 11 |
+
"lint": "next lint"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@fancyapps/ui": "^5.0.36",
|
| 15 |
+
"@radix-ui/react-select": "^2.1.1",
|
| 16 |
+
"@radix-ui/react-slider": "^1.2.0",
|
| 17 |
+
"@radix-ui/react-slot": "^1.1.0",
|
| 18 |
+
"@radix-ui/react-tooltip": "^1.1.2",
|
| 19 |
+
"class-variance-authority": "^0.7.0",
|
| 20 |
+
"clsx": "^2.1.1",
|
| 21 |
+
"cobe": "^0.6.3",
|
| 22 |
+
"concurrently": "^9.0.0",
|
| 23 |
+
"framer-motion": "^11.5.4",
|
| 24 |
+
"lucide-react": "^0.439.0",
|
| 25 |
+
"next": "14.2.9",
|
| 26 |
+
"next-themes": "^0.3.0",
|
| 27 |
+
"openai": "^4.5.0",
|
| 28 |
+
"pg": "^8.12.0",
|
| 29 |
+
"react": "^18",
|
| 30 |
+
"react-dom": "^18",
|
| 31 |
+
"react-spring": "^9.7.4",
|
| 32 |
+
"tailwind-merge": "^2.5.2",
|
| 33 |
+
"tailwindcss-animate": "^1.0.7",
|
| 34 |
+
"uuid": "^10.0.0"
|
| 35 |
+
},
|
| 36 |
+
"devDependencies": {
|
| 37 |
+
"@tailwindcss/container-queries": "^0.1.1",
|
| 38 |
+
"@tailwindcss/forms": "^0.5.8",
|
| 39 |
+
"@types/node": "^20",
|
| 40 |
+
"@types/react": "^18",
|
| 41 |
+
"@types/react-dom": "^18",
|
| 42 |
+
"autoprefixer": "^10.4.20",
|
| 43 |
+
"eslint": "^8",
|
| 44 |
+
"eslint-config-next": "14.2.9",
|
| 45 |
+
"postcss": "^8",
|
| 46 |
+
"tailwindcss": "^3.4.10",
|
| 47 |
+
"typescript": "^5"
|
| 48 |
+
},
|
| 49 |
+
"packageManager": "pnpm@9.10.0"
|
| 50 |
+
}
|
pnpm-lock.yaml
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|