From fa28148d410ee2486eb582d9fa3dd1d35fe9f968 Mon Sep 17 00:00:00 2001 From: Andy Aylward Date: Sat, 27 Sep 2025 22:23:18 -0400 Subject: [PATCH] [posterize] add posterize ui --- src/App.tsx | 2 + .../components/ImageUploader.module.css | 150 ++++++++++ .../posterize/components/ImageUploader.tsx | 134 +++++++++ .../components/PosterizeNavigation.module.css | 57 ++++ .../components/PosterizeNavigation.tsx | 23 ++ .../posterize/pages/PosterizePage.module.css | 261 ++++++++++++++++++ src/apps/posterize/pages/PosterizePage.tsx | 211 ++++++++++++++ src/apps/tracy/pages/TracyPage.module.css | 67 +++++ src/apps/tracy/pages/TracyPage.tsx | 157 +++++++++-- src/shared/components/Navigation.tsx | 1 + 10 files changed, 1042 insertions(+), 21 deletions(-) create mode 100644 src/apps/posterize/components/ImageUploader.module.css create mode 100644 src/apps/posterize/components/ImageUploader.tsx create mode 100644 src/apps/posterize/components/PosterizeNavigation.module.css create mode 100644 src/apps/posterize/components/PosterizeNavigation.tsx create mode 100644 src/apps/posterize/pages/PosterizePage.module.css create mode 100644 src/apps/posterize/pages/PosterizePage.tsx diff --git a/src/App.tsx b/src/App.tsx index ea558f1..84a6e7c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import PartyPage from './apps/party/pages/PartyPage' import QuestPage from './apps/quest/pages/QuestPage' import LearningPage from './apps/math-learning/pages/LearningPage' import TracyPage from './apps/tracy/pages/TracyPage' +import PosterizePage from './apps/posterize/pages/PosterizePage' import SystemsPage from './apps/metrics-systems/pages/SystemsPage' import ResilienceGamePage from './apps/metrics-systems/pages/ResilienceGamePage' import MetricsPage from './apps/metrics-systems/pages/MetricsPage' @@ -25,6 +26,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/posterize/components/ImageUploader.module.css b/src/apps/posterize/components/ImageUploader.module.css new file mode 100644 index 0000000..9fedac3 --- /dev/null +++ b/src/apps/posterize/components/ImageUploader.module.css @@ -0,0 +1,150 @@ +.container { + width: 100%; + max-width: 500px; + margin: 0 auto; +} + +.dropZone { + border: 2px dashed #ccc; + border-radius: 8px; + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: #fafafa; + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.dropZone:hover { + border-color: #007bff; + background: #f0f8ff; +} + +.dropZone.active { + border-color: #007bff; + background: #e6f3ff; + transform: scale(1.02); +} + +.dropZone.loading { + cursor: not-allowed; + opacity: 0.6; +} + +.hiddenInput { + display: none; +} + +.uploadPrompt { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.uploadIcon { + font-size: 3rem; + margin-bottom: 0.5rem; +} + +.uploadPrompt h3 { + margin: 0; + color: #333; + font-weight: 500; +} + +.uploadPrompt p { + margin: 0; + color: #666; + font-size: 0.9rem; +} + +.previewContainer { + position: relative; + width: 100%; + height: 100%; + min-height: 200px; +} + +.preview { + max-width: 100%; + max-height: 300px; + object-fit: contain; + border-radius: 4px; +} + +.previewOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + color: white; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + border-radius: 4px; +} + +.previewContainer:hover .previewOverlay { + opacity: 1; +} + +.actions { + margin-top: 1rem; + display: flex; + justify-content: center; + gap: 1rem; +} + +.pasteButton { + padding: 0.75rem 1.5rem; + background: #28a745; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; +} + +.pasteButton:hover:not(:disabled) { + background: #218838; +} + +.pasteButton:disabled { + background: #6c757d; + cursor: not-allowed; +} + +/* Mobile Styles */ +@media (max-width: 768px) { + .dropZone { + padding: 1.5rem; + min-height: 180px; + } + + .uploadIcon { + font-size: 2.5rem; + } + + .uploadPrompt h3 { + font-size: 1.1rem; + } + + .uploadPrompt p { + font-size: 0.85rem; + } + + .pasteButton { + padding: 0.6rem 1.2rem; + font-size: 0.85rem; + } +} \ No newline at end of file diff --git a/src/apps/posterize/components/ImageUploader.tsx b/src/apps/posterize/components/ImageUploader.tsx new file mode 100644 index 0000000..50de19d --- /dev/null +++ b/src/apps/posterize/components/ImageUploader.tsx @@ -0,0 +1,134 @@ +import { useState, useRef, useCallback } from 'react' +import styles from './ImageUploader.module.css' + +interface ImageUploaderProps { + onImageSelect: (base64: string) => void + isLoading: boolean +} + +const ImageUploader = ({ onImageSelect, isLoading }: ImageUploaderProps) => { + const [dragActive, setDragActive] = useState(false) + const [previewUrl, setPreviewUrl] = useState(null) + const fileInputRef = useRef(null) + + const processFile = useCallback((file: File) => { + if (!file.type.startsWith('image/png')) { + alert('Please select a PNG image file') + return + } + + const reader = new FileReader() + reader.onload = (e) => { + const result = e.target?.result as string + if (result) { + // Extract base64 data (remove data:image/png;base64, prefix) + const base64Data = result.split(',')[1] + setPreviewUrl(result) + onImageSelect(base64Data) + } + } + reader.readAsDataURL(file) + }, [onImageSelect]) + + const handleDrag = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.type === 'dragenter' || e.type === 'dragover') { + setDragActive(true) + } else if (e.type === 'dragleave') { + setDragActive(false) + } + }, []) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragActive(false) + + const files = e.dataTransfer.files + if (files && files.length > 0) { + processFile(files[0]) + } + }, [processFile]) + + const handleFileInput = useCallback((e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + processFile(files[0]) + } + }, [processFile]) + + const handlePaste = useCallback(async () => { + try { + const clipboardItems = await navigator.clipboard.read() + for (const clipboardItem of clipboardItems) { + for (const type of clipboardItem.types) { + if (type.startsWith('image/')) { + const blob = await clipboardItem.getType(type) + const file = new File([blob], 'pasted-image.png', { type: 'image/png' }) + processFile(file) + return + } + } + } + } catch (err) { + console.error('Failed to read clipboard contents: ', err) + alert('Failed to paste image. Please try uploading a file instead.') + } + }, [processFile]) + + const handleClick = () => { + if (!isLoading) { + fileInputRef.current?.click() + } + } + + return ( +
+
+ + + {previewUrl ? ( +
+ Preview +
+

Click to change image

+
+
+ ) : ( +
+
📁
+

Drop your PNG here

+

or click to browse files

+
+ )} +
+ +
+ +
+
+ ) +} + +export default ImageUploader \ No newline at end of file diff --git a/src/apps/posterize/components/PosterizeNavigation.module.css b/src/apps/posterize/components/PosterizeNavigation.module.css new file mode 100644 index 0000000..ccfbe75 --- /dev/null +++ b/src/apps/posterize/components/PosterizeNavigation.module.css @@ -0,0 +1,57 @@ +/* Posterize Page Navigation Styles */ +.nav { + position: fixed; + top: 0; + left: 0; + width: 100%; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(68, 68, 68, 0.1); + z-index: 1000; + transition: all 0.3s ease; +} + +.navContainer { + max-width: 650px; + margin: 0 auto; + padding: 0 20px; + display: flex; + justify-content: space-between; + align-items: center; + height: 60px; +} + +.centerContent { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.navLogo { + font-size: 20px; + font-weight: 300; + color: #444; + text-decoration: none; +} + +.tagline { + font-size: 14px; + color: #666; + font-style: italic; +} + +/* Mobile Styles */ +@media (max-width: 768px) { + .tagline { + font-size: 12px; + } + + .navLogo { + font-size: 18px; + } + + .navContainer { + padding: 0 15px; + } +} \ No newline at end of file diff --git a/src/apps/posterize/components/PosterizeNavigation.tsx b/src/apps/posterize/components/PosterizeNavigation.tsx new file mode 100644 index 0000000..6cf28b6 --- /dev/null +++ b/src/apps/posterize/components/PosterizeNavigation.tsx @@ -0,0 +1,23 @@ +import { Link } from 'react-router-dom' +import styles from './PosterizeNavigation.module.css' + +interface PosterizeNavigationProps { + className?: string +} + +const PosterizeNavigation = ({ className }: PosterizeNavigationProps) => { + return ( + + ) +} + +export default PosterizeNavigation \ No newline at end of file diff --git a/src/apps/posterize/pages/PosterizePage.module.css b/src/apps/posterize/pages/PosterizePage.module.css new file mode 100644 index 0000000..5f0fa5b --- /dev/null +++ b/src/apps/posterize/pages/PosterizePage.module.css @@ -0,0 +1,261 @@ +.container { + min-height: 100vh; + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + padding-top: 60px; /* Account for fixed navigation */ +} + +.header { + text-align: center; + padding: 2rem 1rem; + max-width: 800px; + margin: 0 auto; +} + +.header h1 { + font-size: 2.5rem; + font-weight: 300; + color: #333; + margin: 0 0 0.5rem 0; +} + +.header p { + font-size: 1.1rem; + color: #666; + margin: 0; +} + +.content { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem 3rem 1rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; + align-items: start; +} + +.uploadSection, .resultSection { + background: white; + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.uploadSection h2, .resultSection h2 { + margin: 0 0 1.5rem 0; + font-size: 1.5rem; + font-weight: 500; + color: #333; + text-align: center; +} + +.actions { + margin-top: 1.5rem; + text-align: center; +} + +.blurButton { + padding: 0.75rem 2rem; + background: #007bff; + color: white; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + min-width: 150px; +} + +.blurButton:hover:not(:disabled) { + background: #0056b3; +} + +.blurButton:disabled { + background: #6c757d; + cursor: not-allowed; +} + +.resultContainer { + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + border: 1px solid #e9ecef; + border-radius: 8px; + background: #f8f9fa; +} + +.loadingOverlay { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + color: #666; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.errorOverlay { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + text-align: center; + color: #dc3545; +} + +.errorContent p { + margin: 0; + font-weight: 500; +} + +.retryButton { + padding: 0.5rem 1rem; + background: #dc3545; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; +} + +.retryButton:hover { + background: #c82333; +} + +.result { + width: 100%; + text-align: center; +} + +.resultImage { + max-width: 100%; + max-height: 400px; + object-fit: contain; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-bottom: 1rem; +} + +.resultActions { + display: flex; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1rem; +} + +.downloadButton, .copyButton { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; +} + +.downloadButton { + background: #28a745; + color: white; +} + +.downloadButton:hover { + background: #218838; +} + +.copyButton { + background: #17a2b8; + color: white; +} + +.copyButton:hover { + background: #138496; +} + +.resultInfo { + font-size: 0.85rem; + color: #666; + line-height: 1.4; +} + +.resultInfo p { + margin: 0.2rem 0; +} + +.placeholder { + text-align: center; + color: #888; + padding: 2rem; +} + +.placeholder p { + margin: 0.5rem 0; +} + +.hint { + font-size: 0.9rem; + font-style: italic; +} + +/* Mobile Styles */ +@media (max-width: 768px) { + .header h1 { + font-size: 2rem; + } + + .header p { + font-size: 1rem; + } + + .content { + grid-template-columns: 1fr; + gap: 2rem; + padding: 0 1rem 2rem 1rem; + } + + .uploadSection, .resultSection { + padding: 1.5rem; + } + + .resultContainer { + min-height: 250px; + } + + .resultImage { + max-height: 300px; + } + + .resultActions { + flex-direction: column; + gap: 0.75rem; + } + + .downloadButton, .copyButton { + width: 100%; + padding: 0.75rem; + } +} + +@media (max-width: 480px) { + .uploadSection, .resultSection { + padding: 1rem; + } + + .blurButton { + width: 100%; + padding: 0.75rem; + } +} \ No newline at end of file diff --git a/src/apps/posterize/pages/PosterizePage.tsx b/src/apps/posterize/pages/PosterizePage.tsx new file mode 100644 index 0000000..852d407 --- /dev/null +++ b/src/apps/posterize/pages/PosterizePage.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react' +import styles from './PosterizePage.module.css' +import PosterizeNavigation from '../components/PosterizeNavigation' +import ImageUploader from '../components/ImageUploader' + +interface BlurResponse { + width: number + height: number + format: string + image_data: string + size_bytes: number +} + +const PosterizePage = () => { + const [selectedImage, setSelectedImage] = useState(null) + const [blurredImage, setBlurredImage] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const handleImageSelect = (base64: string) => { + setSelectedImage(base64) + setBlurredImage(null) + setError(null) + } + + const handleBlur = async () => { + if (!selectedImage) return + + setIsLoading(true) + setError(null) + + // Scroll to result section on mobile + if (window.innerWidth <= 768) { + const resultSection = document.querySelector('.resultSection') + if (resultSection) { + resultSection.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + + // Use environment variable for API URL, defaulting to production URL + const apiUrl = import.meta.env.VITE_POSTERIZE_API_URL || 'https://api.muchq.com/v1/imagine/blur' + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + b64_png: selectedImage + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || `HTTP error! status: ${response.status}`) + } + + const data: BlurResponse = await response.json() + setBlurredImage(data) + + // Auto-scroll to result after successful blur on mobile + setTimeout(() => { + if (window.innerWidth <= 768) { + const resultImage = document.getElementById('result-image') + if (resultImage) { + resultImage.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + }, 100) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to blur image') + console.error('Blur error:', err) + } finally { + setIsLoading(false) + } + } + + const downloadImage = () => { + if (!blurredImage) return + + const link = document.createElement('a') + link.href = `data:image/png;base64,${blurredImage.image_data}` + link.download = 'blurred-image.png' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + const copyToClipboard = async () => { + if (!blurredImage) return + + try { + // Convert base64 to blob + const response = await fetch(`data:image/png;base64,${blurredImage.image_data}`) + const blob = await response.blob() + + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': blob + }) + ]) + + // Show success feedback + const button = document.getElementById('copy-button') + if (button) { + const originalText = button.textContent + button.textContent = '✅ Copied!' + setTimeout(() => { + button.textContent = originalText + }, 2000) + } + } catch (err) { + console.error('Failed to copy image: ', err) + alert('Failed to copy image to clipboard. You can download it instead.') + } + } + + return ( +
+ + +
+

Posterize

+

Upload a PNG image and get a beautiful blurred version

+
+ +
+
+

Upload Image

+ + + {selectedImage && ( +
+ +
+ )} +
+ +
+

Result

+ +
+ {isLoading && ( +
+
+

Blurring your image...

+
+ )} + + {error && ( +
+
+

❌ Error: {error}

+ +
+
+ )} + + {blurredImage && !isLoading && ( +
+ Blurred result + +
+ + +
+ +
+

Size: {blurredImage.width} × {blurredImage.height}

+

Format: {blurredImage.format}

+

File size: {(blurredImage.size_bytes / 1024).toFixed(1)} KB

+
+
+ )} + + {!blurredImage && !isLoading && !error && ( +
+

Your blurred image will appear here

+

Upload an image and click "Blur Image" to get started

+
+ )} +
+
+
+
+ ) +} + +export default PosterizePage \ No newline at end of file diff --git a/src/apps/tracy/pages/TracyPage.module.css b/src/apps/tracy/pages/TracyPage.module.css index 1bd5c88..e6bee59 100644 --- a/src/apps/tracy/pages/TracyPage.module.css +++ b/src/apps/tracy/pages/TracyPage.module.css @@ -137,6 +137,63 @@ opacity: 0.7; } +.result { + width: 100%; + text-align: center; +} + +.resultActions { + display: flex; + gap: 0.5rem; + justify-content: center; + margin-top: 1rem; + flex-wrap: wrap; +} + +.downloadButton, .copyButton, .blurButton { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; + white-space: nowrap; +} + +.downloadButton { + background: #28a745; + color: white; +} + +.downloadButton:hover { + background: #218838; +} + +.copyButton { + background: #17a2b8; + color: white; +} + +.copyButton:hover { + background: #138496; +} + +.blurButton { + background: #6f42c1; + color: white; +} + +.blurButton:hover:not(:disabled) { + background: #5a32a3; +} + +.blurButton:disabled { + background: #6c757d; + cursor: not-allowed; + opacity: 0.6; +} + + /* Mobile responsiveness */ @media (max-width: 1024px) { .content { @@ -179,4 +236,14 @@ aspect-ratio: 4/3; min-height: 250px; } + + .resultActions { + flex-direction: column; + gap: 0.75rem; + } + + .downloadButton, .copyButton, .blurButton { + width: 100%; + padding: 0.75rem; + } } \ No newline at end of file diff --git a/src/apps/tracy/pages/TracyPage.tsx b/src/apps/tracy/pages/TracyPage.tsx index 9c55285..b3827bd 100644 --- a/src/apps/tracy/pages/TracyPage.tsx +++ b/src/apps/tracy/pages/TracyPage.tsx @@ -38,7 +38,7 @@ const TracyPage = () => { const handleRender = async (sceneData: SceneData) => { setIsLoading(true) setError(null) - + // Scroll to canvas on mobile if (window.innerWidth <= 768) { const canvasSection = document.querySelector('.canvasSection') @@ -46,10 +46,10 @@ const TracyPage = () => { canvasSection.scrollIntoView({ behavior: 'smooth', block: 'start' }) } } - + // Use environment variable for API URL, defaulting to production URL const apiUrl = import.meta.env.VITE_TRACY_API_URL || 'https://api.muchq.com/v1/trace' - + try { const response = await fetch(apiUrl, { method: 'POST', @@ -65,7 +65,7 @@ const TracyPage = () => { const data = await response.json() setImageData(data) - + // Auto-scroll to result after successful render on mobile setTimeout(() => { if (window.innerWidth <= 768) { @@ -83,6 +83,98 @@ const TracyPage = () => { } } + const downloadImage = () => { + if (!imageData) return + + const link = document.createElement('a') + link.href = `data:image/png;base64,${imageData.base64_png}` + link.download = 'traced-image.png' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + const copyToClipboard = async () => { + if (!imageData) return + + try { + // Convert base64 to blob + const response = await fetch(`data:image/png;base64,${imageData.base64_png}`) + const blob = await response.blob() + + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': blob + }) + ]) + + // Show success feedback + const button = document.getElementById('copy-button') + if (button) { + const originalText = button.textContent + button.textContent = '✅ Copied!' + setTimeout(() => { + button.textContent = originalText + }, 2000) + } + } catch (err) { + console.error('Failed to copy image: ', err) + alert('Failed to copy image to clipboard. You can download it instead.') + } + } + + const blurImage = async () => { + if (!imageData) return + + setIsLoading(true) + setError(null) + + // Use environment variable for Posterize API URL, defaulting to production URL + const posterizeApiUrl = import.meta.env.VITE_POSTERIZE_API_URL || 'https://api.muchq.com/v1/imagine/blur' + + try { + const response = await fetch(posterizeApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + b64_png: imageData.base64_png + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || `HTTP error! status: ${response.status}`) + } + + const blurredData = await response.json() + + // Replace the current image with the blurred version + setImageData({ + base64_png: blurredData.image_data, + width: blurredData.width, + height: blurredData.height + }) + + // Show success feedback + const button = document.getElementById('blur-button') + if (button) { + const originalText = button.textContent + button.textContent = '✅ Blurred!' + setTimeout(() => { + button.textContent = originalText + }, 2000) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to blur image') + console.error('Blur error:', err) + } finally { + setIsLoading(false) + } + } + + return (
@@ -114,25 +206,48 @@ const TracyPage = () => { )} {imageData && !isLoading && ( - { - if (canvas && imageData) { - const ctx = canvas.getContext('2d') - if (ctx) { - const img = new Image() - img.onload = () => { - ctx.clearRect(0, 0, canvas.width, canvas.height) - ctx.drawImage(img, 0, 0) +
+ { + if (canvas && imageData) { + const ctx = canvas.getContext('2d') + if (ctx) { + const img = new Image() + img.onload = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(img, 0, 0) + } + img.src = `data:image/png;base64,${imageData.base64_png}` } - img.src = `data:image/png;base64,${imageData.base64_png}` } - } - }} - /> + }} + /> + +
+ + + +
+
)} {!imageData && !isLoading && !error && ( diff --git a/src/shared/components/Navigation.tsx b/src/shared/components/Navigation.tsx index 2468d14..2d9dd1c 100644 --- a/src/shared/components/Navigation.tsx +++ b/src/shared/components/Navigation.tsx @@ -47,6 +47,7 @@ const Navigation = ({ className }: NavigationProps) => {
Tracy + Posterize Metrics Wordchains