feat: implement customizable Bitcoin logo with 3D rotation and interactive controls
All checks were successful
Deploy to Production / deploy (push) Successful in 4s

This commit is contained in:
2026-03-03 09:12:50 +00:00
commit 2b6e9077c7
17 changed files with 2121 additions and 0 deletions

317
src/App.vue Normal file
View File

@@ -0,0 +1,317 @@
<script setup>
import { ref } from 'vue';
import BitcoinLogo from './components/BitcoinLogo.vue'
// Modes: 'auto', 'interactive', 'controlled'
const activeMode = ref('interactive');
const activeEasing = ref('linear');
const activeDuration = ref(4);
const activeFriction = ref(0.98);
const activeSymbolColor = ref('#ffffff');
const activeSize = ref(250);
const activeStrokeWidth = ref(5);
const externalRotation = ref(0);
const easingOptions = [
'linear',
'ease',
'ease-in',
'ease-out',
'ease-in-out',
'cubic-bezier(0.68, -0.55, 0.265, 1.55)' // bouncy
];
const handleRotationUpdate = (val) => {
if (activeMode.value !== 'controlled') {
externalRotation.value = val;
}
};
</script>
<template>
<div class="app-container">
<div class="glass-card">
<header>
<h1>Bitcoin 3D</h1>
<p>Premium 3D Bitcoin logo with multiple rotation modes.</p>
</header>
<main>
<BitcoinLogo
:mode="activeMode"
:easing="activeEasing"
:duration="activeDuration"
:friction="activeFriction"
:symbolColor="activeSymbolColor"
:size="activeSize"
:strokeWidth="activeStrokeWidth"
v-model:rotation="externalRotation"
@update:rotation="handleRotationUpdate"
/>
</main>
<footer>
<div class="global-controls">
<div class="color-control">
<span class="label">SYMBOL COLOR</span>
<input type="color" v-model="activeSymbolColor">
</div>
<div class="size-control">
<span class="label">SIZE</span>
<input type="range" v-model.number="activeSize" min="50" max="400" step="1">
<span class="value">{{ activeSize }}px</span>
</div>
<div class="size-control">
<span class="label">STROKE</span>
<input type="range" v-model.number="activeStrokeWidth" min="0" max="20" step="0.5">
<span class="value">{{ activeStrokeWidth }}px</span>
</div>
</div>
<div class="mode-selector">
<label
v-for="mode in ['auto', 'interactive', 'controlled']"
:key="mode"
:class="{ active: activeMode === mode }"
>
<input type="radio" :value="mode" v-model="activeMode">
{{ mode.toUpperCase() }}
</label>
</div>
<div v-if="activeMode === 'auto'" class="control-panel">
<div class="duration-control">
<span class="label">SPEED</span>
<input type="range" v-model.number="activeDuration" min="0.5" max="10" step="0.1">
<span class="value">{{ activeDuration }}s</span>
</div>
<select v-model="activeEasing" class="easing-select">
<option v-for="option in easingOptions" :key="option" :value="option">
{{ option === 'cubic-bezier(0.68, -0.55, 0.265, 1.55)' ? 'BOUNCY' : option.toUpperCase() }}
</option>
</select>
</div>
<div v-if="activeMode === 'interactive'" class="control-panel">
<div class="duration-control">
<span class="label">INERTIA</span>
<input type="range" v-model.number="activeFriction" min="0.8" max="0.999" step="0.001">
<span class="value">{{ (activeFriction * 100).toFixed(1) }}%</span>
</div>
</div>
<div v-if="activeMode === 'controlled'" class="control-panel">
<input type="range" v-model.number="externalRotation" min="0" max="360" step="1">
<span class="value">{{ externalRotation }}°</span>
</div>
<div class="badges">
<div class="badge">Vue 3</div>
<div class="badge">SVG 3D</div>
</div>
</footer>
</div>
</div>
</template>
<style scoped>
.app-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
main {
margin: 2rem 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
position: relative;
}
header h1 {
margin-top: 0;
}
footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
margin-top: 2rem;
}
.global-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
background: rgba(255, 255, 255, 0.03);
padding: 1rem 2rem;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
margin-bottom: 0.5rem;
}
.color-control {
display: flex;
align-items: center;
gap: 12px;
}
.color-control .label {
font-size: 0.7rem;
font-weight: 800;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.05em;
}
.color-control input[type="color"] {
appearance: none;
-webkit-appearance: none;
border: none;
width: 32px;
height: 32px;
cursor: pointer;
background: none;
padding: 0;
}
.color-control input[type="color"]::-webkit-color-swatch {
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
}
.size-control {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 200px;
}
.size-control .label {
font-size: 0.7rem;
font-weight: 800;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.05em;
}
.size-control input[type="range"] {
flex: 1;
accent-color: #f7931a;
height: 4px;
}
.size-control .value {
font-family: monospace;
min-width: 45px;
font-size: 0.85rem;
color: #f7931a;
}
.mode-selector {
display: flex;
background: rgba(255, 255, 255, 0.05);
padding: 4px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.mode-selector label {
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
transition: all 0.3s ease;
color: rgba(255, 255, 255, 0.6);
}
.mode-selector label.active {
background: #f7931a;
color: white;
box-shadow: 0 4px 12px rgba(247, 147, 26, 0.3);
}
.mode-selector input {
display: none;
}
.control-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
max-width: 300px;
}
.duration-control {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.duration-control .label {
font-size: 0.7rem;
font-weight: 800;
color: rgba(255, 255, 255, 0.4);
min-width: 50px;
}
.control-panel input[type="range"] {
flex: 1;
accent-color: #f7931a;
height: 4px;
border-radius: 2px;
}
.control-panel .value {
font-family: monospace;
min-width: 45px;
font-size: 0.85rem;
color: #f7931a;
}
.easing-select {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: white;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
font-weight: 600;
appearance: none;
text-align: center;
}
.easing-select:focus {
outline: none;
border-color: #f7931a;
}
.badges {
display: flex;
gap: 1rem;
}
.badge {
background: rgba(255, 255, 255, 0.1);
padding: 0.5rem 1rem;
border-radius: 99px;
font-size: 0.875rem;
font-weight: 600;
color: #fbbf24;
border: 1px solid rgba(251, 191, 36, 0.2);
}
@media (max-width: 640px) {
h1 { font-size: 2.5rem; }
.glass-card { padding: 2rem; margin: 1rem; }
}
</style>

View File

@@ -0,0 +1,221 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
const FIX_SHOWING_THROUGH = '0.5px';
const props = defineProps({
// Modes: 'auto', 'interactive', 'controlled'
mode: {
type: String,
default: 'interactive'
},
easing: {
type: String,
default: 'linear'
},
duration: {
type: Number,
default: 4
},
friction: {
type: Number,
default: 0.98
},
symbolColor: {
type: String,
default: '#fff'
},
size: {
type: Number,
default: 200
},
strokeWidth: {
type: Number,
default: 5
},
// Used only in 'controlled' mode
rotation: {
type: Number,
default: 0
}
});
const emit = defineEmits(['update:rotation']);
// Internal state for 'interactive' mode
const internalRotation = ref(0);
const velocity = ref(2);
const isDragging = ref(false);
const lastMouseX = ref(0);
// Final rotation value used in CSS
const displayRotation = computed(() => {
if (props.mode === 'controlled') return props.rotation;
return internalRotation.value;
});
const rotationStyle = computed(() => `${displayRotation.value}deg`);
// Drag handlers
const handleStart = (e) => {
if (props.mode !== 'interactive') return;
isDragging.value = true;
lastMouseX.value = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
};
const handleMove = (e) => {
if (!isDragging.value || props.mode !== 'interactive') return;
const currentX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
const deltaX = currentX - lastMouseX.value;
internalRotation.value += deltaX * 0.5;
velocity.value = deltaX * 0.5;
lastMouseX.value = currentX;
emit('update:rotation', internalRotation.value);
};
const handleEnd = () => {
isDragging.value = false;
};
// Animation loop
let rafId = null;
const animate = () => {
if (props.mode === 'interactive' && !isDragging.value) {
internalRotation.value += velocity.value;
velocity.value *= props.friction;
emit('update:rotation', internalRotation.value);
}
rafId = requestAnimationFrame(animate);
};
onMounted(() => {
rafId = requestAnimationFrame(animate);
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleEnd);
window.addEventListener('touchmove', handleMove, { passive: false });
window.addEventListener('touchend', handleEnd);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleEnd);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('touchend', handleEnd);
});
// Sync internal rotation if external control changes
watch(() => props.rotation, (newVal) => {
if (props.mode === 'controlled') {
internalRotation.value = newVal;
}
});
const coinShape = 'M98.4946664 62.0964188c-6.6779856,26.7864844 -33.808340799999996,43.0882088 -60.5980024,36.4085124 -26.778419200000002,-6.6779856 -43.0801436,-33.8100516 -36.3992252,-60.594336399999996 6.6750528000000005,-26.789417200000003 33.805408,-43.0923636 60.58676,-36.414378 26.787706399999998,6.6779856 43.0884532,33.8129844 36.4097344,60.6006908l0.0004888 -0.0004888z';
const symbolShape = 'M72.0434988 42.8770472c0.9951968,-6.6540344 -4.0707264,-10.2308284 -10.998,-12.6169056l2.247258 -9.013472 -5.4867799999999995 -1.3671735999999999 -2.1876244000000002 8.7761596c-1.4424488,-0.3597568 -2.9237572,-0.6987396 -4.3960228,-1.0347896l2.2035104 -8.8340824 -5.4833584 -1.3671735999999999 -2.24848 9.0105392c-1.1936496,-0.2717728 -2.3660364,-0.5403684 -3.5034739999999998,-0.8233836l0.0063544000000000005 -0.028350399999999998 -7.566379599999999 -1.8894564 -1.4595567999999999 5.8602232c0,0 4.0707264,0.9331192 3.984942,0.9905532 2.2218404,0.5545436 2.623634,2.0253428 2.5569128,3.1911308l-2.5598456 10.268466c0.1529944,0.0388596 0.3514472,0.0950716 0.5704296,0.1830556 -0.1830556,-0.0454584 -0.3778424,-0.0950716 -0.5799612,-0.1434628l-3.5880364 14.384650800000001c-0.2715284,0.6750328 -0.9607364,1.6880707999999998 -2.5141428,1.3033852 0.05499,0.07967439999999999 -3.9878747999999997,-0.9951968 -3.9878747999999997,-0.9951968l-2.7240824 6.280591200000001 7.140146 1.7799652c1.328314,0.3331172 2.6299884,0.6816316 3.9118664,1.009372l-2.270476 9.1168532 5.4804256 1.3671735999999999 2.24848 -9.0200708c1.4971944,0.4064372 2.9501524,0.7813468 4.3725604,1.1347492l-2.2409035999999998 8.9775452 5.4870244 1.3671735999999999 2.2702316 -9.0997452c9.3561208,1.770678 16.391174799999998,1.0567856 19.3523252,-7.4058088 2.3860772,-6.8133832 -0.1187784,-10.743335199999999 -5.0409944,-13.306113600000002 3.5851036,-0.8268051999999999 6.2854792,-3.1847764 7.0054815999999995,-8.0556684l-0.0017108000000000002 -0.001222zm-12.536009199999999 17.5787144c-1.6956471999999998,6.8133832 -13.1672944,3.1302752000000003 -16.886573600000002,2.2066876l3.0129632 -12.078248c3.7190347999999998,0.9284756000000001 15.645754799999999,2.7658748 13.873854799999998,9.8715604zm1.6968692 -17.677452c-1.5468076,6.1974952000000005 -11.0947824,3.04889 -14.192063600000001,2.2768303999999997l2.7316588 -10.9542524c3.0972812000000003,0.7720596 13.071734000000001,2.2130419999999997 11.4608936,8.677422l-0.0004888 0z';
</script>
<template>
<div
class="logo-container"
@mousedown="handleStart"
@touchstart="handleStart"
>
<div
class="coin"
:class="{
'auto-rotate': props.mode === 'auto',
'dragging': isDragging
}"
>
<div class="side heads">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100 100">
<path fill="#F7931A" fill-rule="nonzero" :stroke="symbolColor" :stroke-width="strokeWidth" :d="coinShape"></path>
<path :fill="symbolColor" fill-rule="nonzero" :d="symbolShape"></path>
</svg>
</div>
<div class="side tails">
<svg xmlns="http://www.w3.org/2000/svg" class="svg_back" width="100%" height="100%" viewBox="0 0 100 100">
<path fill="#F7931A" fill-rule="nonzero" :stroke="symbolColor" :stroke-width="strokeWidth" :d="coinShape"></path>
<path :fill="symbolColor" fill-rule="nonzero" :d="symbolShape"></path>
</svg>
</div>
</div>
</div>
</template>
<style scoped>
.logo-container {
font-size: v-bind("props.size + 'px'");
width: 1em;
height: 1em;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
/* background: rgba(255, 0, 0, 0.2); Bounding rect debug */
display: flex;
justify-content: center;
align-items: center;
cursor: v-bind("props.mode === 'interactive' ? (isDragging ? 'grabbing' : 'grab') : 'default'");
user-select: none;
touch-action: v-bind("props.mode === 'interactive' ? 'none' : 'auto'");
}
.coin {
width: 0.1em;
height: 1em;
background: linear-gradient(#faa504, #141001);
transform: rotateY(v-bind(rotationStyle));
transform-style: preserve-3d;
position: relative;
}
.coin.auto-rotate {
animation: rotate v-bind("props.duration + 's'") infinite v-bind("props.easing");
}
.coin .side, .coin:before, .coin:after {
content: "";
position: absolute;
width: 1em;
height: 1em;
overflow: hidden;
border-radius: 50%;
right: calc(-0.4em + v-bind(FIX_SHOWING_THROUGH));
text-align: center;
line-height: 1;
transform: rotateY(-90deg);
-moz-backface-visibility: hidden;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.coin .tails, .coin:after {
left: calc(-0.4em + v-bind(FIX_SHOWING_THROUGH));
transform: rotateY(90deg);
}
.coin:before, .coin:after {
background: linear-gradient(#faa504, #141001);
backface-visibility: hidden;
transform: rotateY(90deg);
}
.coin:after {
transform: rotateY(-90deg);
}
.svg_back {
transform: scaleX(-1);
}
@keyframes rotate {
0% { transform: rotateY(90deg); }
100% { transform: rotateY(450deg); }
}
</style>

5
src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

65
src/style.css Normal file
View File

@@ -0,0 +1,65 @@
:root {
font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.95);
background-color: #0f172a;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
/* Center vertically if content is small */
min-width: 320px;
min-height: 100vh;
background: radial-gradient(circle at center, #1e293b 0%, #0f172a 100%);
overflow-y: auto;
/* Ensure vertical scroll is allowed */
}
#app {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
h1 {
font-size: 4rem;
line-height: 1;
font-weight: 800;
margin-bottom: 0.5rem;
background: linear-gradient(to right, #f59e0b, #fbbf24);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 10px rgba(245, 158, 11, 0.3));
}
p {
font-size: 1.25rem;
color: #94a3b8;
max-width: 600px;
margin: 0 auto 3rem;
}
.glass-card {
background: rgba(30, 41, 59, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
padding: 3rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
display: inline-block;
}