Skip to content

Commit bdc0802

Browse files
committed
feat: Poster Loom
1 parent 88c4872 commit bdc0802

File tree

8 files changed

+795
-351
lines changed

8 files changed

+795
-351
lines changed

public/apps/apps.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,15 @@
319319
"description": "Blend background environments to create your ideal focus space.",
320320
"icon": "SpeakerHighIcon",
321321
"created_at": "2025-12-20T14:00:00+03:00"
322+
},
323+
{
324+
"slug": "poster-loom",
325+
"to": "/apps/poster-loom",
326+
"title": "Poster Loom",
327+
"description": "Construct brutalist digital posters with generative grids and technical typography.",
328+
"icon": "GridFourIcon",
329+
"created_at": "2025-12-20T17:00:00+03:00",
330+
"pinned_order": 23
322331
}
323332
]
324333
},

src/components/AnimatedRoutes.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ const AtmosphereMixerPage = lazy(() => import('../pages/apps/AtmosphereMixerPage
143143
const TaskGridPage = lazy(() => import('../pages/apps/TaskGridPage'));
144144
const BlendLabPage = lazy(() => import('../pages/apps/BlendLabPage'));
145145
const AssetStudioPage = lazy(() => import('../pages/apps/AssetStudioPage'));
146+
const PosterLoomPage = lazy(() => import('../pages/apps/PosterLoomPage'));
146147
const RoadmapViewerPage = lazy(() => import('../pages/roadmap/FezzillaPage'));
147148
const RoadmapItemDetailPage = lazy(
148149
() => import('../pages/roadmap/RoadmapItemDetailPage'),
@@ -1777,6 +1778,22 @@ const AnimatedRoutes = ({
17771778
</motion.div>
17781779
}
17791780
/>
1781+
<Route
1782+
path="/apps/poster-loom"
1783+
element={
1784+
<motion.div
1785+
initial="initial"
1786+
animate="in"
1787+
exit="out"
1788+
variants={pageVariants}
1789+
transition={pageTransition}
1790+
>
1791+
<Suspense fallback={<Loading />}>
1792+
<PosterLoomPage />
1793+
</Suspense>
1794+
</motion.div>
1795+
}
1796+
/>
17801797
<Route
17811798
path="/apps/lorem-ipsum-generator"
17821799
element={

src/components/PickerWheel.js

Lines changed: 118 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import React, {
66
useMemo,
77
} from 'react';
88
import '../styles/PickerWheel.css';
9-
import colors from '../config/colors';
10-
import { Trash } from '@phosphor-icons/react';
9+
import { TrashIcon, PlusIcon, ListBulletsIcon, ArrowsClockwiseIcon } from '@phosphor-icons/react';
1110
import ListInputModal from './ListInputModal';
11+
import GenerativeArt from './GenerativeArt';
1212

1313
const PickerWheel = () => {
1414
const [entries, setEntries] = useState([]);
@@ -23,46 +23,16 @@ const PickerWheel = () => {
2323

2424
const colorPalette = useMemo(
2525
() => [
26-
'#FDE2E4',
27-
'#E2ECE9',
28-
'#BEE1E6',
29-
'#F0EFEB',
30-
'#DFE7FD',
31-
'#CDDAFD',
32-
'#EAD5E6',
33-
'#F4C7C3',
34-
'#D6E2E9',
35-
'#B9E2E6',
36-
'#F9D8D6',
37-
'#D4E9E6',
38-
'#A8DADC',
39-
'#E9E4F2',
40-
'#D0D9FB',
41-
'#C0CFFB',
42-
'#E3C8DE',
43-
'#F1BDBD',
44-
'#C9D5DE',
45-
'#A1D5DB',
46-
'#F6C4C1',
47-
'#C1E0DA',
48-
'#92D2D2',
49-
'#E2DDF0',
50-
'#C3CEFA',
51-
'#B3C4FA',
52-
'#DBBBD1',
53-
'#EDB3B0',
54-
'#BCC8D3',
55-
'#8DCED1',
26+
'#FDE2E4', '#E2ECE9', '#BEE1E6', '#F0EFEB', '#DFE7FD',
27+
'#CDDAFD', '#EAD5E6', '#F4C7C3', '#D6E2E9', '#B9E2E6',
28+
'#F9D8D6', '#D4E9E6', '#A8DADC', '#E9E4F2', '#D0D9FB',
29+
'#C0CFFB', '#E3C8DE', '#F1BDBD', '#C9D5DE', '#A1D5DB',
30+
'#F6C4C1', '#C1E0DA', '#92D2D2', '#E2DDF0', '#C3CEFA',
31+
'#B3C4FA', '#DBBBD1', '#EDB3B0', '#BCC8D3', '#8DCED1',
5632
],
5733
[],
5834
);
5935

60-
const cardStyle = {
61-
backgroundColor: colors['app-alpha-10'],
62-
borderColor: colors['app-alpha-50'],
63-
color: colors.app,
64-
};
65-
6636
const drawWheel = useCallback(() => {
6737
const canvas = canvasRef.current;
6838
if (!canvas) return;
@@ -86,13 +56,13 @@ const PickerWheel = () => {
8656

8757
ctx.save();
8858
ctx.fillStyle = '#000';
89-
ctx.font = '30px Arial';
59+
ctx.font = 'bold 24px "Space Mono"';
9060
ctx.translate(
9161
width / 2 + Math.cos(angle + arc / 2) * (width / 2 - 80),
9262
height / 2 + Math.sin(angle + arc / 2) * (height / 2 - 80),
9363
);
9464
ctx.rotate(angle + arc / 2 + Math.PI / 2);
95-
const text = entries[i];
65+
const text = entries[i].toUpperCase();
9666
ctx.fillText(text, -ctx.measureText(text).width / 2, 0);
9767
ctx.restore();
9868
}
@@ -122,9 +92,7 @@ const PickerWheel = () => {
12292
};
12393

12494
const handleKeyDown = (e) => {
125-
if (e.key === 'Enter') {
126-
addEntry();
127-
}
95+
if (e.key === 'Enter') addEntry();
12896
};
12997

13098
const deleteEntry = (index) => {
@@ -160,7 +128,7 @@ const PickerWheel = () => {
160128
const canvas = canvasRef.current;
161129
const ctx = canvas.getContext('2d');
162130
const pinX = canvas.width / 2;
163-
const pinY = 30; // Position of the pin
131+
const pinY = 30;
164132
const pixel = ctx.getImageData(pinX, pinY, 1, 1).data;
165133
const pixelColor = `rgb(${pixel[0]}, ${pixel[1]}, ${pixel[2]})`;
166134

@@ -191,127 +159,121 @@ const PickerWheel = () => {
191159
};
192160

193161
return (
194-
<div
195-
className="group bg-transparent border rounded-lg shadow-2xl p-6 flex flex-col justify-start relative w-full flex-grow"
196-
style={cardStyle}
197-
>
198-
<div
199-
className="absolute top-0 left-0 w-full h-full opacity-10"
200-
style={{
201-
backgroundImage:
202-
'radial-gradient(circle, white 1px, transparent 1px)',
203-
backgroundSize: '10px 10px',
204-
}}
205-
></div>
206-
<div className="relative z-10">
207-
<h1 className="text-3xl font-arvo font-normal mb-4 text-app">
208-
{' '}
209-
Picker Wheel{' '}
210-
</h1>
211-
<hr className="border-gray-700 mb-4" />
212-
<div className="flex gap-8">
213-
<div className="flex flex-col items-center">
214-
<div className="picker-wheel-container mt-8">
215-
<div className="wheel-wrapper">
216-
<div className="pin"></div>
217-
<canvas
218-
ref={canvasRef}
219-
width="600"
220-
height="600"
221-
className={`wheel ${entries.length > 1 && !spinning ? 'slow-spin' : ''}`}
222-
></canvas>
162+
<div className="w-full flex flex-col gap-12">
163+
<div className="relative border border-white/10 bg-white/[0.02] p-8 md:p-12 rounded-sm overflow-hidden group">
164+
<div className="absolute inset-0 opacity-[0.03] pointer-events-none grayscale">
165+
<GenerativeArt seed="picker-wheel" className="w-full h-full" />
166+
</div>
167+
168+
<div className="relative z-10">
169+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16">
170+
<div className="flex flex-col items-center gap-8">
171+
<div className="picker-wheel-container">
172+
<div className="wheel-wrapper">
173+
<div className="pin"></div>
174+
<canvas
175+
ref={canvasRef}
176+
width="600"
177+
height="600"
178+
className={`wheel ${entries.length > 1 && !spinning ? 'slow-spin' : ''}`}
179+
></canvas>
180+
<button
181+
onClick={spin}
182+
className="spin-button font-black uppercase tracking-widest text-xs"
183+
disabled={spinning || entries.length < 2}
184+
>
185+
{spinning ? '...' : winner ? winner.toUpperCase() : 'SPIN'}
186+
</button>
187+
</div>
188+
</div>
189+
190+
<div className="h-12 flex items-center">
191+
{spinning ? (
192+
<div className="flex items-center gap-3 text-emerald-500 font-mono text-xs uppercase tracking-[0.3em]">
193+
<ArrowsClockwiseIcon className="animate-spin" />
194+
<span>Spinning...</span>
195+
</div>
196+
) : winner ? (
197+
<div className="flex flex-col items-center gap-1">
198+
<span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">The winner is</span>
199+
<span className="text-3xl font-black text-white uppercase tracking-tighter italic">{winner}</span>
200+
</div>
201+
) : null}
202+
</div>
203+
</div>
204+
205+
<div className="space-y-12">
206+
<div className="space-y-6">
207+
<h2 className="text-2xl font-black uppercase tracking-tighter">Options</h2>
208+
<div className="flex gap-4">
209+
<input
210+
ref={newEntryInputRef}
211+
type="text"
212+
value={newEntry}
213+
onChange={(e) => setNewEntry(e.target.value)}
214+
onKeyDown={handleKeyDown}
215+
placeholder="Add an option (max 30)"
216+
className="flex-1 bg-black/40 border border-white/10 p-4 font-mono text-sm focus:border-emerald-500 outline-none transition-colors text-white"
217+
disabled={entries.length >= 30}
218+
/>
219+
<button
220+
onClick={addEntry}
221+
disabled={entries.length >= 30}
222+
className="px-8 py-4 bg-white text-black font-black uppercase tracking-widest text-xs hover:bg-emerald-500 transition-all disabled:opacity-20"
223+
>
224+
<PlusIcon weight="bold" size={20} />
225+
</button>
226+
</div>
223227
<button
224-
onClick={spin}
225-
className="spin-button"
226-
disabled={spinning || entries.length < 2}
228+
onClick={() => setIsModalOpen(true)}
229+
className="w-full py-4 border border-white/10 text-gray-500 hover:text-white hover:bg-white/5 transition-all font-mono text-[10px] uppercase tracking-widest flex items-center justify-center gap-2"
227230
>
228-
{spinning ? '...' : winner ? winner : 'Spin'}
231+
<ListBulletsIcon weight="bold" size={16} />
232+
Load from List
229233
</button>
234+
<p className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">
235+
{entries.length} / 30 options added
236+
</p>
237+
</div>
238+
239+
<div className="space-y-4">
240+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[300px] overflow-y-auto custom-scrollbar-terminal pr-4">
241+
{entries.map((entry, index) => (
242+
<div key={index} className="group/item flex items-center justify-between bg-white/5 border border-white/5 p-3 hover:border-white/20 transition-all">
243+
<span className="text-xs font-mono uppercase truncate mr-2 text-white">{entry}</span>
244+
<button
245+
onClick={() => deleteEntry(index)}
246+
className="p-1 text-gray-600 hover:text-red-500 transition-colors opacity-0 group-hover/item:opacity-100"
247+
>
248+
<TrashIcon size={14} weight="bold" />
249+
</button>
250+
</div>
251+
))}
252+
{entries.length === 0 && (
253+
<div className="col-span-full py-12 border border-dashed border-white/10 text-center">
254+
<span className="text-[10px] font-mono text-gray-600 uppercase tracking-widest">No options added</span>
255+
</div>
256+
)}
257+
</div>
230258
</div>
231-
</div>
232-
<div className="winner mt-4">
233-
{spinning
234-
? 'Spinning...'
235-
: winner
236-
? `The winner is: ${winner}`
237-
: ''}
238-
</div>
239-
</div>
240-
<div className="w-full max-w-lg ml-16">
241-
<div className="controls">
242-
<input
243-
ref={newEntryInputRef}
244-
type="text"
245-
value={newEntry}
246-
onChange={(e) => setNewEntry(e.target.value)}
247-
onKeyDown={handleKeyDown}
248-
placeholder="Add an option (max 30)"
249-
className="bg-gray-800 text-white p-2 rounded-lg flex-grow"
250-
disabled={entries.length >= 30}
251-
/>
252-
<button
253-
onClick={addEntry}
254-
className="flex items-center gap-2 text-lg font-arvo font-normal px-4 py-2 rounded-md border transition-colors duration-300 ease-in-out border-app/100 bg-app/50 text-white hover:bg-app/70"
255-
disabled={entries.length >= 30}
256-
>
257-
Add
258-
</button>
259-
</div>
260-
<button
261-
onClick={() => setIsModalOpen(true)}
262-
className="flex items-center justify-center w-full gap-2 text-lg font-arvo font-normal px-4 py-2 mt-4 rounded-md border transition-colors duration-300 ease-in-out border-app/100 bg-app/50 text-white hover:bg-app/70"
263-
>
264-
Load from List
265-
</button>
266-
<div className="w-full mt-4">
267-
<h2 className="text-2xl font-arvo font-normal mb-4">
268-
Entries ({entries.length})
269-
</h2>
270-
<ul className="space-y-2">
271-
{entries.map((entry, index) => (
272-
<li
273-
key={index}
274-
className="flex items-center justify-between bg-gray-800/75 p-2 rounded-lg"
275-
>
276-
<span className="flex-grow text-center">{entry}</span>
277-
<button
278-
onClick={() => deleteEntry(index)}
279-
className="flex items-center gap-2 text-lg font-mono font-normal px-2 py-2 rounded-md border transition-colors duration-300 ease-in-out border-app/100 bg-app/50 text-white hover:bg-app/70"
280-
>
281-
<Trash size={20} />
282-
</button>
283-
</li>
284-
))}
285-
</ul>
286259
</div>
287260
</div>
288261
</div>
289262
</div>
290-
<div
291-
className="group bg-transparent border rounded-lg shadow-2xl p-6 flex flex-col justify-start relative w-full flex-grow mt-8"
292-
style={cardStyle}
293-
>
294-
<div
295-
className="absolute top-0 left-0 w-full h-full opacity-10"
296-
style={{
297-
backgroundImage:
298-
'radial-gradient(circle, white 1px, transparent 1px)',
299-
backgroundSize: '10px 10px',
300-
}}
301-
></div>
302-
<div className="relative z-10">
303-
<h2 className="text-2xl font-arvo font-normal mb-4">How it works</h2>
304-
<p className="text-gray-400">
305-
✦ Add entries one by one using the input field and "Add" button, or
306-
load a list of entries using the "Load from List" button. <br />
307-
✦ The wheel will display the entries as equal divisions. <br />
308-
✦ Click the "Spin" button to spin the wheel. It will spin fast and
309-
then slowly get slower, eventually stopping on a winner. <br />
310-
✦ The winner will be displayed below the wheel and in the center of
311-
the wheel. <br />
312-
</p>
313-
</div>
263+
264+
<div className="p-8 border border-white/10 bg-white/[0.01] rounded-sm">
265+
<h2 className="text-sm font-black uppercase tracking-widest mb-4 flex items-center gap-2">
266+
<ArrowsClockwiseIcon weight="bold" className="text-emerald-500" />
267+
How it works
268+
</h2>
269+
<ul className="space-y-3 text-[10px] font-mono text-gray-500 uppercase tracking-widest leading-relaxed">
270+
<li>• Add entries one by one or load a list.</li>
271+
<li>• The wheel divides space equally among all options.</li>
272+
<li>• Click the center to spin.</li>
273+
<li>• The stop position is determined by physical pixel sampling.</li>
274+
</ul>
314275
</div>
276+
315277
<ListInputModal
316278
isOpen={isModalOpen}
317279
onClose={() => setIsModalOpen(false)}

0 commit comments

Comments
 (0)