rspace-online/lib/folk-image-gen.ts

417 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 320px;
min-height: 400px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #ec4899, #8b5cf6);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
.prompt-area {
padding: 12px;
border-bottom: 1px solid #e2e8f0;
}
.prompt-input {
width: 100%;
padding: 10px 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
}
.prompt-input:focus {
border-color: #8b5cf6;
}
.controls {
display: flex;
gap: 8px;
margin-top: 8px;
}
.generate-btn {
flex: 1;
padding: 8px 16px;
background: linear-gradient(135deg, #ec4899, #8b5cf6);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.generate-btn:hover {
opacity: 0.9;
}
.generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.image-area {
flex: 1;
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
text-align: center;
gap: 8px;
}
.placeholder-icon {
font-size: 48px;
opacity: 0.5;
}
.generated-image {
width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.image-item {
position: relative;
}
.image-prompt {
font-size: 11px;
color: #64748b;
margin-top: 4px;
padding: 4px 8px;
background: #f1f5f9;
border-radius: 4px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #8b5cf6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
}
.style-select {
padding: 6px 10px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
background: white;
cursor: pointer;
}
`;
export interface GeneratedImage {
id: string;
prompt: string;
url: string;
timestamp: Date;
}
declare global {
interface HTMLElementTagNameMap {
"folk-image-gen": FolkImageGen;
}
}
export class FolkImageGen extends FolkShape {
static override tagName = "folk-image-gen";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#images: GeneratedImage[] = [];
#isLoading = false;
#error: string | null = null;
#promptInput: HTMLTextAreaElement | null = null;
#styleSelect: HTMLSelectElement | null = null;
#imageArea: HTMLElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
get images() {
return this.#images;
}
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>🎨</span>
<span>Image Gen</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="prompt-area">
<textarea class="prompt-input" placeholder="Describe the image you want to generate..." rows="3"></textarea>
<div class="controls">
<select class="style-select">
<option value="illustration">Illustration</option>
<option value="photorealistic">Photorealistic</option>
<option value="painting">Painting</option>
<option value="sketch">Sketch</option>
<option value="punk-zine">Punk Zine</option>
</select>
<button class="generate-btn">Generate</button>
</div>
</div>
<div class="image-area">
<div class="placeholder">
<span class="placeholder-icon">🖼️</span>
<span>Enter a prompt and click Generate</span>
</div>
</div>
</div>
`;
// Replace the container div (slot's parent) with our wrapper
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#styleSelect = wrapper.querySelector(".style-select");
this.#imageArea = wrapper.querySelector(".image-area");
this.#generateBtn = wrapper.querySelector(".generate-btn");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Generate button handler
this.#generateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#generate();
});
// Enter key in prompt
this.#promptInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.#generate();
}
});
// Prevent drag on input
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
async #generate() {
const prompt = this.#promptInput?.value.trim();
if (!prompt || this.#isLoading) return;
const style = this.#styleSelect?.value || "illustration";
this.#isLoading = true;
this.#error = null;
if (this.#generateBtn) this.#generateBtn.disabled = true;
this.#renderLoading();
try {
// Call backend API (to be implemented)
const response = await fetch("/api/image-gen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, style }),
});
if (!response.ok) {
throw new Error(`Generation failed: ${response.statusText}`);
}
const result = await response.json();
const image: GeneratedImage = {
id: crypto.randomUUID(),
prompt,
url: result.url || result.image_url,
timestamp: new Date(),
};
this.#images.unshift(image);
this.#renderImages();
this.dispatchEvent(new CustomEvent("image-generated", { detail: { image } }));
// Clear input
if (this.#promptInput) this.#promptInput.value = "";
} catch (error) {
this.#error = error instanceof Error ? error.message : "Generation failed";
this.#renderError();
} finally {
this.#isLoading = false;
if (this.#generateBtn) this.#generateBtn.disabled = false;
}
}
#renderLoading() {
if (!this.#imageArea) return;
this.#imageArea.innerHTML = `
<div class="loading">
<div class="spinner"></div>
<span>Generating image...</span>
</div>
`;
}
#renderError() {
if (!this.#imageArea) return;
this.#imageArea.innerHTML = `
<div class="error">${this.#escapeHtml(this.#error || "Unknown error")}</div>
${this.#images.length > 0 ? this.#renderImageList() : '<div class="placeholder"><span class="placeholder-icon">🖼</span><span>Try again with a different prompt</span></div>'}
`;
}
#renderImages() {
if (!this.#imageArea) return;
if (this.#images.length === 0) {
this.#imageArea.innerHTML = `
<div class="placeholder">
<span class="placeholder-icon">🖼</span>
<span>Enter a prompt and click Generate</span>
</div>
`;
return;
}
this.#imageArea.innerHTML = this.#renderImageList();
}
#renderImageList(): string {
return this.#images
.map(
(img) => `
<div class="image-item">
<img class="generated-image" src="${this.#escapeHtml(img.url)}" alt="${this.#escapeHtml(img.prompt)}" loading="lazy" />
<div class="image-prompt">${this.#escapeHtml(img.prompt)}</div>
</div>
`
)
.join("");
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-image-gen",
images: this.images.map((img) => ({
...img,
timestamp: img.timestamp.toISOString(),
})),
};
}
}