feat: Add HuggingFace Spaces README and fix TypeScript build issues
Browse files- Created comprehensive README.md with YAML frontmatter for HF Spaces
- Fixed TypeScript errors by refactoring setObjects callbacks to direct calls
- Removed unused variables (rect, dropX, dropY)
- Fixed boolean type coercion issues with ?? operator
- Successfully built production bundle (505KB)
- Ready for deployment to HuggingFace Spaces
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <[email protected]>
- README.md +90 -0
- src/App.tsx +16 -26
README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: HF Thumbnail Crafter
|
| 3 |
+
emoji: 🎨
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# HF Thumbnail Crafter
|
| 12 |
+
|
| 13 |
+
A web-based thumbnail creator for HuggingFace users. Create quick, professional thumbnails with pre-made layouts and visual assets (Huggys).
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- **4 Pre-designed Layouts**: Serious Collab, Fun Collab, Sandwich, and Docs layouts
|
| 18 |
+
- **44+ Huggy Mascot Assets**: Loaded directly from HuggingFace dataset
|
| 19 |
+
- **Custom Text Editing**: Support for Inter & IBM Plex Mono fonts with bold/italic styling
|
| 20 |
+
- **Image Upload**: Drag-and-drop support for local images (PNG, JPG, WebP)
|
| 21 |
+
- **Layer Management**: Use `[` and `]` keyboard shortcuts to reorder objects
|
| 22 |
+
- **Smart Canvas Tools**:
|
| 23 |
+
- Multi-select with drag-to-select box or Shift+Click
|
| 24 |
+
- Arrow key movement (1px or 10px with Shift)
|
| 25 |
+
- Shift-constrained drag (horizontal/vertical lock)
|
| 26 |
+
- Smart snapping to canvas center lines
|
| 27 |
+
- **Export**: High-quality PNG export with custom filename
|
| 28 |
+
- **Undo/Redo**: Full history support (Ctrl+Z / Ctrl+Shift+Z)
|
| 29 |
+
|
| 30 |
+
## Usage
|
| 31 |
+
|
| 32 |
+
1. **Select a Layout**: Click the Layout button in the sidebar to choose from 4 pre-designed templates
|
| 33 |
+
2. **Customize**:
|
| 34 |
+
- Add Huggys from the Huggy menu
|
| 35 |
+
- Upload your own images
|
| 36 |
+
- Edit text directly on canvas (double-click)
|
| 37 |
+
- Adjust colors using the background selector
|
| 38 |
+
3. **Arrange Objects**:
|
| 39 |
+
- Drag to reposition
|
| 40 |
+
- Use `[` and `]` keys to change layer order
|
| 41 |
+
- Transform with scaling and rotation
|
| 42 |
+
4. **Export**: Click the export button and download your thumbnail as PNG
|
| 43 |
+
|
| 44 |
+
## Keyboard Shortcuts
|
| 45 |
+
|
| 46 |
+
- **`T`**: Activate text creation mode
|
| 47 |
+
- **`Delete/Backspace`**: Delete selected objects
|
| 48 |
+
- **`Ctrl+Z`**: Undo
|
| 49 |
+
- **`Ctrl+Shift+Z`**: Redo
|
| 50 |
+
- **`Ctrl+D`**: Duplicate selected objects
|
| 51 |
+
- **`Arrow Keys`**: Move selection 1px (hold Shift for 10px)
|
| 52 |
+
- **`[`**: Send backward (decrease layer order)
|
| 53 |
+
- **`]`**: Bring forward (increase layer order)
|
| 54 |
+
- **`Shift + Drag`**: Constrain to horizontal/vertical axis
|
| 55 |
+
|
| 56 |
+
## Canvas Sizes
|
| 57 |
+
|
| 58 |
+
- **1200×675**: Default size (X/Twitter)
|
| 59 |
+
- **1200×627**: LinkedIn size
|
| 60 |
+
- **1160×580**: HuggingFace custom size
|
| 61 |
+
|
| 62 |
+
## Tech Stack
|
| 63 |
+
|
| 64 |
+
- **Frontend**: React 18 + TypeScript
|
| 65 |
+
- **Build Tool**: Vite
|
| 66 |
+
- **Canvas**: react-konva + Konva.js
|
| 67 |
+
- **Styling**: Tailwind CSS
|
| 68 |
+
- **Icons**: Custom Figma-exported SVGs
|
| 69 |
+
- **Fonts**: Inter, IBM Plex Mono, Bison Bold
|
| 70 |
+
|
| 71 |
+
## Development
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
# Install dependencies
|
| 75 |
+
npm install
|
| 76 |
+
|
| 77 |
+
# Run dev server
|
| 78 |
+
npm run dev
|
| 79 |
+
|
| 80 |
+
# Build for production
|
| 81 |
+
npx vite build
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
## License
|
| 85 |
+
|
| 86 |
+
MIT
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
🤖 Built with [Claude Code](https://claude.com/claude-code)
|
src/App.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import LayoutSwitchConfirmation from './components/Layout/LayoutSwitchConfirmati
|
|
| 8 |
import TextToolbar from './components/TextToolbar/TextToolbar';
|
| 9 |
import { useCanvasState } from './hooks/useCanvasState';
|
| 10 |
import { useViewportScale } from './hooks/useViewportScale';
|
| 11 |
-
import { LayoutType, TextObject } from './types/canvas.types';
|
| 12 |
import { getLayoutById } from './data/layouts';
|
| 13 |
import { getCanvasDimensions, generateId, getNextZIndex, sortByZIndex } from './utils/canvas.utils';
|
| 14 |
import { Huggy } from './data/huggys';
|
|
@@ -111,7 +111,7 @@ function App() {
|
|
| 111 |
// Get the maximum zIndex from existing objects
|
| 112 |
const maxZIndex = objects.length > 0 ? Math.max(...objects.map(obj => obj.zIndex)) : 0;
|
| 113 |
|
| 114 |
-
const layoutObjects = layout.objects.map((obj
|
| 115 |
const baseObj = {
|
| 116 |
...obj,
|
| 117 |
id: `${obj.id}-${Date.now()}`,
|
|
@@ -337,12 +337,6 @@ function App() {
|
|
| 337 |
// Get the canvas container element to calculate relative position
|
| 338 |
const canvasContainer = document.querySelector('.canvas-container');
|
| 339 |
if (canvasContainer) {
|
| 340 |
-
const rect = canvasContainer.getBoundingClientRect();
|
| 341 |
-
|
| 342 |
-
// Calculate position relative to canvas
|
| 343 |
-
const dropX = e.clientX - rect.left;
|
| 344 |
-
const dropY = e.clientY - rect.top;
|
| 345 |
-
|
| 346 |
// Find the actual canvas stage element (the white/dark canvas area)
|
| 347 |
const canvasStage = canvasContainer.querySelector('.konvajs-content');
|
| 348 |
if (canvasStage) {
|
|
@@ -775,8 +769,8 @@ function App() {
|
|
| 775 |
const textObj = selectedTextObjects[0];
|
| 776 |
const fontFamily = textObj?.fontFamily || 'Inter';
|
| 777 |
const fill = textObj?.fill || '#000000';
|
| 778 |
-
const bold = textObj?.bold
|
| 779 |
-
const italic = textObj?.italic
|
| 780 |
|
| 781 |
// Get canvas dimensions
|
| 782 |
const dimensions = getCanvasDimensions(canvasSize);
|
|
@@ -793,8 +787,7 @@ function App() {
|
|
| 793 |
scale={scale}
|
| 794 |
stageRef={stageRef}
|
| 795 |
onFontFamilyChange={(font) => {
|
| 796 |
-
|
| 797 |
-
prevObjects.map(o => {
|
| 798 |
if (o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)) {
|
| 799 |
// Recalculate text dimensions with new font
|
| 800 |
try {
|
|
@@ -816,21 +809,19 @@ function App() {
|
|
| 816 |
}
|
| 817 |
}
|
| 818 |
return o;
|
| 819 |
-
})
|
| 820 |
-
);
|
| 821 |
}}
|
| 822 |
onFillChange={(color) => {
|
| 823 |
-
|
| 824 |
-
prevObjects.map(o =>
|
| 825 |
o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)
|
| 826 |
? { ...o, fill: color }
|
| 827 |
: o
|
| 828 |
-
)
|
| 829 |
-
);
|
| 830 |
}}
|
| 831 |
onBoldToggle={() => {
|
| 832 |
-
|
| 833 |
-
prevObjects.map(o => {
|
| 834 |
if (o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)) {
|
| 835 |
const newBold = !o.bold;
|
| 836 |
// Recalculate text dimensions with new bold state
|
|
@@ -853,12 +844,11 @@ function App() {
|
|
| 853 |
}
|
| 854 |
}
|
| 855 |
return o;
|
| 856 |
-
})
|
| 857 |
-
);
|
| 858 |
}}
|
| 859 |
onItalicToggle={() => {
|
| 860 |
-
|
| 861 |
-
prevObjects.map(o => {
|
| 862 |
if (o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)) {
|
| 863 |
const newItalic = !o.italic;
|
| 864 |
// Recalculate text dimensions with new italic state
|
|
@@ -881,8 +871,8 @@ function App() {
|
|
| 881 |
}
|
| 882 |
}
|
| 883 |
return o;
|
| 884 |
-
})
|
| 885 |
-
);
|
| 886 |
}}
|
| 887 |
/>
|
| 888 |
);
|
|
|
|
| 8 |
import TextToolbar from './components/TextToolbar/TextToolbar';
|
| 9 |
import { useCanvasState } from './hooks/useCanvasState';
|
| 10 |
import { useViewportScale } from './hooks/useViewportScale';
|
| 11 |
+
import { LayoutType, TextObject, CanvasObject } from './types/canvas.types';
|
| 12 |
import { getLayoutById } from './data/layouts';
|
| 13 |
import { getCanvasDimensions, generateId, getNextZIndex, sortByZIndex } from './utils/canvas.utils';
|
| 14 |
import { Huggy } from './data/huggys';
|
|
|
|
| 111 |
// Get the maximum zIndex from existing objects
|
| 112 |
const maxZIndex = objects.length > 0 ? Math.max(...objects.map(obj => obj.zIndex)) : 0;
|
| 113 |
|
| 114 |
+
const layoutObjects = layout.objects.map((obj) => {
|
| 115 |
const baseObj = {
|
| 116 |
...obj,
|
| 117 |
id: `${obj.id}-${Date.now()}`,
|
|
|
|
| 337 |
// Get the canvas container element to calculate relative position
|
| 338 |
const canvasContainer = document.querySelector('.canvas-container');
|
| 339 |
if (canvasContainer) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
// Find the actual canvas stage element (the white/dark canvas area)
|
| 341 |
const canvasStage = canvasContainer.querySelector('.konvajs-content');
|
| 342 |
if (canvasStage) {
|
|
|
|
| 769 |
const textObj = selectedTextObjects[0];
|
| 770 |
const fontFamily = textObj?.fontFamily || 'Inter';
|
| 771 |
const fill = textObj?.fill || '#000000';
|
| 772 |
+
const bold = textObj?.bold ?? false;
|
| 773 |
+
const italic = textObj?.italic ?? false;
|
| 774 |
|
| 775 |
// Get canvas dimensions
|
| 776 |
const dimensions = getCanvasDimensions(canvasSize);
|
|
|
|
| 787 |
scale={scale}
|
| 788 |
stageRef={stageRef}
|
| 789 |
onFontFamilyChange={(font) => {
|
| 790 |
+
const updatedObjects = objects.map((o: CanvasObject) => {
|
|
|
|
| 791 |
if (o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)) {
|
| 792 |
// Recalculate text dimensions with new font
|
| 793 |
try {
|
|
|
|
| 809 |
}
|
| 810 |
}
|
| 811 |
return o;
|
| 812 |
+
});
|
| 813 |
+
setObjects(updatedObjects);
|
| 814 |
}}
|
| 815 |
onFillChange={(color) => {
|
| 816 |
+
const updatedObjects = objects.map((o: CanvasObject) =>
|
|
|
|
| 817 |
o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)
|
| 818 |
? { ...o, fill: color }
|
| 819 |
: o
|
| 820 |
+
);
|
| 821 |
+
setObjects(updatedObjects);
|
| 822 |
}}
|
| 823 |
onBoldToggle={() => {
|
| 824 |
+
const updatedObjects = objects.map((o: CanvasObject) => {
|
|
|
|
| 825 |
if (o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)) {
|
| 826 |
const newBold = !o.bold;
|
| 827 |
// Recalculate text dimensions with new bold state
|
|
|
|
| 844 |
}
|
| 845 |
}
|
| 846 |
return o;
|
| 847 |
+
});
|
| 848 |
+
setObjects(updatedObjects);
|
| 849 |
}}
|
| 850 |
onItalicToggle={() => {
|
| 851 |
+
const updatedObjects = objects.map((o: CanvasObject) => {
|
|
|
|
| 852 |
if (o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)) {
|
| 853 |
const newItalic = !o.italic;
|
| 854 |
// Recalculate text dimensions with new italic state
|
|
|
|
| 871 |
}
|
| 872 |
}
|
| 873 |
return o;
|
| 874 |
+
});
|
| 875 |
+
setObjects(updatedObjects);
|
| 876 |
}}
|
| 877 |
/>
|
| 878 |
);
|