Skip to content

Commit 7900d22

Browse files
committed
feat: rotary phone
1 parent 8ceea35 commit 7900d22

File tree

5 files changed

+337
-11
lines changed

5 files changed

+337
-11
lines changed

public/apps/apps.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@
147147
"icon": "SparkleIcon",
148148
"order": 2,
149149
"apps": [
150+
{
151+
"slug": "rotary-phone",
152+
"to": "/apps/rotary-phone",
153+
"title": "Rotary Phone",
154+
"description": "A digital rotary phone. Dial numbers with your mouse or finger.",
155+
"icon": "PhoneIcon",
156+
"created_at": "2025-12-02T00:00:00+03:00"
157+
},
150158
{
151159
"slug": "cozy-corner",
152160
"to": "/apps/cozy-corner",

src/components/AnimatedRoutes.js

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ const SpirographPage = lazy(() => import('../pages/apps/SpirographPage'));
128128
const FractalFloraPage = lazy(() => import('../pages/apps/FractalFloraPage'));
129129
const AbstractWavesPage = lazy(() => import('../pages/apps/AbstractWavesPage'));
130130
const TopographicMapPage = lazy(() => import('../pages/apps/TopographicMapPage'));
131+
const RotaryPhonePage = lazy(() => import('../pages/apps/RotaryPhonePage'));
131132
const FezynthPage = lazy(() => import('../pages/apps/FezynthPage'));
132133
const CodeSeancePage = lazy(() => import('../pages/apps/CodeSeancePage'));
133134
const RoadmapViewerPage = lazy(() => import('../pages/roadmap/FezzillaPage'));
@@ -1783,17 +1784,17 @@ function AnimatedRoutes() {
17831784
<Route
17841785
path="/apps/topographic-maps"
17851786
element={
1786-
<motion.div
1787-
initial="initial"
1788-
animate="in"
1789-
exit="out"
1790-
variants={pageVariants}
1791-
transition={pageTransition}
1792-
>
1793-
<Suspense fallback={<Loading />}>
1794-
<TopographicMapPage />
1795-
</Suspense>
1796-
</motion.div>
1787+
<Suspense fallback={<Loading />}>
1788+
<TopographicMapPage />
1789+
</Suspense>
1790+
}
1791+
/>
1792+
<Route
1793+
path="/apps/rotary-phone"
1794+
element={
1795+
<Suspense fallback={<Loading />}>
1796+
<RotaryPhonePage />
1797+
</Suspense>
17971798
}
17981799
/>
17991800
<Route

src/components/RotaryDial.js

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import React, {useState, useRef, useEffect} from 'react';
2+
import {motion, useMotionValue, animate} from 'framer-motion';
3+
4+
const RotaryDial = ({onDial}) => {
5+
const [isDragging, setIsDragging] = useState(false);
6+
const [activeDigit, setActiveDigit] = useState(null);
7+
const rotation = useMotionValue(0);
8+
const containerRef = useRef(null); // Ref for the static container
9+
const animationControls = useRef(null);
10+
11+
// Configuration
12+
const STOP_ANGLE = 60; // Angle of the finger stop (in degrees, 0 is 3 o'clock)
13+
const GAP = 30; // Degrees between numbers
14+
// Numbers 1-9, 0. '1' is closest to stop.
15+
const DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
16+
17+
const getDigitAngle = (digit) => {
18+
const index = DIGITS.indexOf(digit);
19+
return STOP_ANGLE - (index + 1) * GAP;
20+
};
21+
22+
const getAngle = (event, center) => {
23+
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
24+
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
25+
26+
const dx = clientX - center.x;
27+
const dy = clientY - center.y;
28+
let theta = Math.atan2(dy, dx) * (180 / Math.PI);
29+
return theta;
30+
};
31+
32+
const handleStart = (event, digit) => {
33+
// Only prevent default on touch to stop scrolling
34+
if (event.touches && event.cancelable) event.preventDefault();
35+
36+
// Stop any ongoing spring-back animation
37+
if (animationControls.current) {
38+
animationControls.current.stop();
39+
}
40+
41+
setIsDragging(true);
42+
setActiveDigit(digit);
43+
};
44+
45+
const handleMove = (event) => {
46+
if (!isDragging || activeDigit === null || !containerRef.current) return;
47+
const rect = containerRef.current.getBoundingClientRect();
48+
const center = {
49+
x: rect.left + rect.width / 2,
50+
y: rect.top + rect.height / 2
51+
};
52+
53+
const currentMouseAngle = getAngle(event, center);
54+
const startAngle = getDigitAngle(activeDigit);
55+
let newRotation = currentMouseAngle - startAngle;
56+
57+
const normalizeDiff = (diff) => {
58+
while (diff <= -180) diff += 360;
59+
while (diff > 180) diff -= 360;
60+
return diff;
61+
};
62+
63+
newRotation = normalizeDiff(newRotation);
64+
65+
const maxRot = STOP_ANGLE - startAngle;
66+
67+
// If rotation is negative, it might be because we've crossed the 180 boundary
68+
// for a high number digit (like 9 or 0).
69+
// Check if adding 360 brings us into a valid positive range close to maxRot.
70+
// We allow a small buffer over maxRot for elasticity.
71+
if (newRotation < 0 && (newRotation + 360) <= maxRot + 30) {
72+
newRotation += 360;
73+
}
74+
75+
// Clamp
76+
if (newRotation < -10) newRotation = -10;
77+
if (newRotation > maxRot) newRotation = maxRot;
78+
79+
rotation.set(newRotation);
80+
};
81+
const handleEnd = () => {
82+
if (!isDragging) return;
83+
setIsDragging(false);
84+
85+
const maxRot = STOP_ANGLE - getDigitAngle(activeDigit);
86+
const threshold = 15;
87+
88+
const currentRot = rotation.get();
89+
90+
if (Math.abs(currentRot - maxRot) < threshold) {
91+
if (onDial) onDial(activeDigit);
92+
}
93+
94+
setActiveDigit(null);
95+
96+
// Animate back to 0
97+
animationControls.current = animate(rotation, 0, {
98+
type: "spring",
99+
stiffness: 200,
100+
damping: 20,
101+
mass: 1
102+
});
103+
};
104+
105+
useEffect(() => {
106+
const onMove = (e) => handleMove(e);
107+
const onUp = () => handleEnd();
108+
109+
if (isDragging) {
110+
window.addEventListener('mousemove', onMove);
111+
window.addEventListener('mouseup', onUp);
112+
window.addEventListener('touchmove', onMove, {passive: false});
113+
window.addEventListener('touchend', onUp);
114+
}
115+
116+
return () => {
117+
window.removeEventListener('mousemove', onMove);
118+
window.removeEventListener('mouseup', onUp);
119+
window.removeEventListener('touchmove', onMove);
120+
window.removeEventListener('touchend', onUp);
121+
};
122+
}, [isDragging, activeDigit]);
123+
124+
return (
125+
<div
126+
ref={containerRef}
127+
className="relative w-80 h-80 sm:w-96 sm:h-96 select-none"
128+
>
129+
<div className="absolute inset-0 rounded-full bg-gray-900 border-4 border-gray-700 shadow-2xl">
130+
{DIGITS.map((digit) => {
131+
const angle = getDigitAngle(digit);
132+
return (
133+
<div
134+
key={`num-${digit}`}
135+
className="absolute inset-0 flex items-center justify-center pointer-events-none"
136+
style={{transform: `rotate(${angle}deg)`}}
137+
>
138+
<div
139+
className="text-3xl font-bold text-white font-mono"
140+
style={{transform: `translate(${140}%) rotate(${-angle}deg)`}}
141+
>
142+
{digit}
143+
</div>
144+
</div>
145+
);
146+
})}
147+
</div>
148+
149+
<motion.div
150+
className="absolute inset-0 rounded-full border-4 border-transparent"
151+
style={{rotate: rotation}}
152+
>
153+
<div
154+
className="absolute inset-2 rounded-full bg-gradient-to-br from-gray-800/90 to-gray-900/90 shadow-inner backdrop-blur-sm border border-gray-600">
155+
<div
156+
className="absolute inset-0 m-auto w-1/3 h-1/3 rounded-full bg-gray-800 border-2 border-gray-600 flex items-center justify-center shadow-lg">
157+
<div className="text-gray-500 text-xs font-mono text-center opacity-50">
158+
FEZ<br/>CODE
159+
</div>
160+
</div>
161+
162+
{DIGITS.map((digit) => {
163+
const angle = getDigitAngle(digit);
164+
return (
165+
<div
166+
key={`hole-${digit}`}
167+
className="absolute inset-0 flex items-center justify-center pointer-events-none"
168+
style={{transform: `rotate(${angle}deg)`}}
169+
>
170+
<div
171+
className={`w-14 h-14 sm:w-16 sm:h-16 rounded-full border-2 transition-colors duration-200 cursor-pointer pointer-events-auto
172+
${activeDigit === digit ? 'bg-primary-500/20 border-primary-400 shadow-[0_0_15px_rgba(59,130,246,0.5)]' : 'bg-gray-950/50 border-gray-600 hover:border-gray-400'}
173+
flex items-center justify-center shadow-inner`}
174+
style={{transform: `translate(${140}%)`}}
175+
onMouseDown={(e) => handleStart(e, digit)}
176+
onTouchStart={(e) => handleStart(e, digit)}
177+
>
178+
</div>
179+
</div>
180+
);
181+
})}
182+
</div>
183+
</motion.div>
184+
185+
<div
186+
className="absolute inset-0 pointer-events-none z-10"
187+
style={{transform: `rotate(${STOP_ANGLE}deg)`}}
188+
>
189+
<div
190+
className="absolute top-1/2 right-0 w-16 h-4 bg-gradient-to-r from-gray-400 to-gray-200 rounded-l-lg shadow-lg origin-right translate-x-2 -translate-y-1/2"/>
191+
</div>
192+
</div>
193+
);
194+
};
195+
196+
export default RotaryDial;

src/pages/apps/RotaryPhonePage.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import React, {useState} from 'react';
2+
import RotaryDial from '../../components/RotaryDial';
3+
import BreadcrumbTitle from '../../components/BreadcrumbTitle';
4+
import Seo from '../../components/Seo';
5+
import {BackspaceIcon, TrashIcon} from '@phosphor-icons/react';
6+
import {motion, AnimatePresence} from 'framer-motion';
7+
8+
const RotaryPhonePage = () => {
9+
const [phoneNumber, setPhoneNumber] = useState('');
10+
const [lastDialed, setLastDialed] = useState(null);
11+
12+
const handleDial = (digit) => {
13+
setPhoneNumber((prev) => prev + digit);
14+
setLastDialed(digit);
15+
16+
// Clear "last dialed" highlight after a moment
17+
setTimeout(() => setLastDialed(null), 500);
18+
};
19+
20+
const handleBackspace = () => {
21+
setPhoneNumber((prev) => prev.slice(0, -1));
22+
};
23+
24+
const handleClear = () => {
25+
setPhoneNumber('');
26+
};
27+
28+
// Format phone number for display (US format style roughly)
29+
const formatPhoneNumber = (value) => {
30+
// Just simple grouping for readability if it gets long
31+
// 123-456-7890
32+
if (!value) return '';
33+
return value.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3');
34+
};
35+
36+
return (
37+
<div
38+
className="min-h-screen bg-gray-950 py-12 px-4 sm:px-6 lg:px-8 flex flex-col items-center relative overflow-hidden">
39+
<Seo
40+
title="Rotary Phone | Fezcodex"
41+
description="A digital rotary phone. Dial numbers with your mouse or finger."
42+
keywords="rotary phone, dialer, retro, interactive, web app"
43+
/>
44+
45+
<div className="w-full max-w-7xl mx-auto relative z-10">
46+
<BreadcrumbTitle title="Rotary Phone" slug="rotary-phone"/>
47+
</div>
48+
49+
<div className="z-10 w-full max-w-2xl flex flex-col items-center gap-12 mt-8">
50+
51+
{/* Display Area */}
52+
<div className="w-full bg-gray-900 p-6 rounded-3xl shadow-2xl border border-gray-800 relative">
53+
<div
54+
className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-950 px-4 py-1 rounded-full border border-gray-800 text-gray-500 text-xs uppercase tracking-widest font-mono">
55+
Display
56+
</div>
57+
58+
<div
59+
className="bg-green-900/20 border border-green-800/50 rounded-xl p-4 h-24 flex items-center justify-end overflow-hidden relative shadow-[inset_0_0_20px_rgba(0,0,0,0.5)]">
60+
<AnimatePresence mode="popLayout">
61+
<motion.span
62+
key={phoneNumber} // Re-render on change for effect
63+
initial={{opacity: 0.5, y: 5}}
64+
animate={{opacity: 1, y: 0}}
65+
className="text-4xl sm:text-5xl font-mono text-green-400 tracking-widest font-bold drop-shadow-[0_0_8px_rgba(74,222,128,0.6)]"
66+
>
67+
{phoneNumber || <span className="opacity-20">...</span>}
68+
</motion.span>
69+
</AnimatePresence>
70+
71+
{/* Cursor/Caret */}
72+
<motion.div
73+
animate={{opacity: [0, 1, 0]}}
74+
transition={{repeat: Infinity, duration: 1}}
75+
className="w-3 h-10 bg-green-400/50 ml-1"
76+
/>
77+
</div>
78+
79+
<div className="flex justify-end gap-3 mt-4">
80+
<button
81+
onClick={handleBackspace}
82+
disabled={!phoneNumber}
83+
className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:text-white hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
84+
title="Backspace"
85+
>
86+
<BackspaceIcon size={24}/>
87+
</button>
88+
<button
89+
onClick={handleClear}
90+
disabled={!phoneNumber}
91+
className="p-2 rounded-lg bg-red-900/30 text-red-400 hover:text-red-300 hover:bg-red-900/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
92+
title="Clear All"
93+
>
94+
<TrashIcon size={24}/>
95+
</button>
96+
</div>
97+
</div>
98+
99+
{/* The Dial */}
100+
<div className="relative py-8">
101+
<div className="absolute inset-0 bg-primary-500/5 blur-[100px] rounded-full pointer-events-none"/>
102+
<RotaryDial onDial={handleDial}/>
103+
</div>
104+
105+
{/* Instructions */}
106+
<div className="text-gray-500 text-center max-w-md space-y-2">
107+
<p className="text-lg font-medium text-gray-400">How to use:</p>
108+
<p className="text-sm">
109+
Click and hold a number hole, drag it clockwise until it hits the metal stop at the bottom right, then
110+
release.
111+
</p>
112+
</div>
113+
114+
</div>
115+
</div>
116+
);
117+
};
118+
119+
export default RotaryPhonePage;

src/utils/appIcons.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
KanbanIcon,
6868
WaveSine,
6969
MapTrifold,
70+
PhoneIcon,
7071
} from '@phosphor-icons/react';
7172

7273
export const appIcons = {
@@ -138,4 +139,5 @@ export const appIcons = {
138139
KanbanIcon,
139140
WaveSine,
140141
MapTrifold,
142+
PhoneIcon,
141143
};

0 commit comments

Comments
 (0)