Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +26,7 @@ function App() {
<Route path="/party" element={<PartyPage />} />
<Route path="/quest" element={<QuestPage />} />
<Route path="/tracy" element={<TracyPage />} />
<Route path="/posterize" element={<PosterizePage />} />
<Route path="/wordchains" element={<WordchainsPage />} />
<Route path="/metrics" element={<Navigate to="/metrics/system" replace />} />
<Route path="/metrics/:tab" element={<MetricsPage />} />
Expand Down
150 changes: 150 additions & 0 deletions src/apps/posterize/components/ImageUploader.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
134 changes: 134 additions & 0 deletions src/apps/posterize/components/ImageUploader.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className={styles.container}>
<div
className={`${styles.dropZone} ${dragActive ? styles.active : ''} ${isLoading ? styles.loading : ''}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={fileInputRef}
type="file"
accept="image/png"
onChange={handleFileInput}
className={styles.hiddenInput}
/>

{previewUrl ? (
<div className={styles.previewContainer}>
<img src={previewUrl} alt="Preview" className={styles.preview} />
<div className={styles.previewOverlay}>
<p>Click to change image</p>
</div>
</div>
) : (
<div className={styles.uploadPrompt}>
<div className={styles.uploadIcon}>📁</div>
<h3>Drop your PNG here</h3>
<p>or click to browse files</p>
</div>
)}
</div>

<div className={styles.actions}>
<button
onClick={handlePaste}
disabled={isLoading}
className={styles.pasteButton}
title="Paste image from clipboard"
>
📋 Paste Image
</button>
</div>
</div>
)
}

export default ImageUploader
57 changes: 57 additions & 0 deletions src/apps/posterize/components/PosterizeNavigation.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 23 additions & 0 deletions src/apps/posterize/components/PosterizeNavigation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav className={`${styles.nav} ${className || ''}`}>
<div className={styles.navContainer}>
<Link to="/" className={styles.navLogo}>MuchQ : Posterize</Link>
<div className={styles.centerContent}>
<div className={styles.tagline}>
Blur your images with ease
</div>
</div>
</div>
</nav>
)
}

export default PosterizeNavigation
Loading