generated from nhcarrigan/template
feat(tarot): add interactive card flip reveal animation
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 50s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 50s
Cards now start face-down and flip individually on click, with a synthesised flip sound and staggered text fade-in. The draw button and spread selection buttons are locked until all cards in the current reading have been revealed. An unflipped card shows 'Click card to reveal' as a hint.
This commit is contained in:
+156
-10
@@ -8,6 +8,11 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="Draw tarot cards and receive a reading with traditional card meanings."
|
content="Draw tarot cards and receive a reading with traditional card meanings."
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="image"
|
||||||
|
href="https://cdn.nhcarrigan.com/tarot/back.png"
|
||||||
|
/>
|
||||||
<script
|
<script
|
||||||
src="https://cdn.nhcarrigan.com/headers/index.js"
|
src="https://cdn.nhcarrigan.com/headers/index.js"
|
||||||
async
|
async
|
||||||
@@ -66,6 +71,11 @@
|
|||||||
background: var(--witch-plum);
|
background: var(--witch-plum);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spread-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.spread-description {
|
.spread-description {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--witch-plum);
|
color: var(--witch-plum);
|
||||||
@@ -223,13 +233,10 @@
|
|||||||
width: 150px;
|
width: 150px;
|
||||||
height: 260px;
|
height: 260px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
|
||||||
border: 2px solid var(--witch-plum);
|
border: 2px solid var(--witch-plum);
|
||||||
margin-bottom: 0.75em;
|
margin-bottom: 0.75em;
|
||||||
background: rgba(212, 165, 199, 0.08);
|
perspective: 700px;
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-img-wrap img {
|
.card-img-wrap img {
|
||||||
@@ -243,6 +250,70 @@
|
|||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flip-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-inner.flipped {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-face {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-back img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-inner:not(.flipped) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-front {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-front a {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-details {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-details.revealed {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--witch-plum);
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.card-name {
|
.card-name {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
@@ -975,6 +1046,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playFlipSound() {
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const duration = 0.12;
|
||||||
|
const buf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * duration), ctx.sampleRate);
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / data.length, 4);
|
||||||
|
}
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
filter.type = "highpass";
|
||||||
|
filter.frequency.value = 900;
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
gain.gain.value = 0.3;
|
||||||
|
src.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
src.start();
|
||||||
|
} catch {
|
||||||
|
/* audio unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renderCards(drawn, slots, positionKeys, spreadKey) {
|
async function renderCards(drawn, slots, positionKeys, spreadKey) {
|
||||||
const area = document.getElementById("cards-area");
|
const area = document.getElementById("cards-area");
|
||||||
area.className = "cards-area layout-" + spreadKey;
|
area.className = "cards-area layout-" + spreadKey;
|
||||||
@@ -982,11 +1078,15 @@
|
|||||||
|
|
||||||
const drawBtn = document.getElementById("draw-btn");
|
const drawBtn = document.getElementById("draw-btn");
|
||||||
drawBtn.disabled = true;
|
drawBtn.disabled = true;
|
||||||
|
document.querySelectorAll(".spread-btn").forEach(b => { b.disabled = true; });
|
||||||
|
|
||||||
const meaningData = await Promise.all(
|
const meaningData = await Promise.all(
|
||||||
drawn.map(card => fetchCardMeanings(card.img.replace(".png", "")))
|
drawn.map(card => fetchCardMeanings(card.img.replace(".png", "")))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const flipInners = [];
|
||||||
|
const cardDetails = [];
|
||||||
|
|
||||||
drawn.forEach((card, i) => {
|
drawn.forEach((card, i) => {
|
||||||
const posData = meaningData[i];
|
const posData = meaningData[i];
|
||||||
const posKey = positionKeys[i];
|
const posKey = positionKeys[i];
|
||||||
@@ -1008,6 +1108,19 @@
|
|||||||
const imgWrap = document.createElement("div");
|
const imgWrap = document.createElement("div");
|
||||||
imgWrap.className = "card-img-wrap";
|
imgWrap.className = "card-img-wrap";
|
||||||
|
|
||||||
|
const flipInner = document.createElement("div");
|
||||||
|
flipInner.className = "flip-inner";
|
||||||
|
|
||||||
|
const flipBack = document.createElement("div");
|
||||||
|
flipBack.className = "flip-face flip-back";
|
||||||
|
const backImg = document.createElement("img");
|
||||||
|
backImg.src = "https://cdn.nhcarrigan.com/tarot/back.png";
|
||||||
|
backImg.alt = "Card back";
|
||||||
|
flipBack.appendChild(backImg);
|
||||||
|
|
||||||
|
const flipFront = document.createElement("div");
|
||||||
|
flipFront.className = "flip-face flip-front";
|
||||||
|
|
||||||
const imgLink = document.createElement("a");
|
const imgLink = document.createElement("a");
|
||||||
imgLink.href = `https://cdn.nhcarrigan.com/tarot/${card.img}`;
|
imgLink.href = `https://cdn.nhcarrigan.com/tarot/${card.img}`;
|
||||||
imgLink.target = "_blank";
|
imgLink.target = "_blank";
|
||||||
@@ -1020,7 +1133,14 @@
|
|||||||
if (card.isReversed) img.classList.add("reversed");
|
if (card.isReversed) img.classList.add("reversed");
|
||||||
|
|
||||||
imgLink.appendChild(img);
|
imgLink.appendChild(img);
|
||||||
imgWrap.appendChild(imgLink);
|
flipFront.appendChild(imgLink);
|
||||||
|
flipInner.appendChild(flipBack);
|
||||||
|
flipInner.appendChild(flipFront);
|
||||||
|
imgWrap.appendChild(flipInner);
|
||||||
|
flipInners.push({ inner: flipInner, flipped: false, placeholder: null });
|
||||||
|
|
||||||
|
const details = document.createElement("div");
|
||||||
|
details.className = "card-details";
|
||||||
|
|
||||||
const name = document.createElement("div");
|
const name = document.createElement("div");
|
||||||
name.className = "card-name";
|
name.className = "card-name";
|
||||||
@@ -1034,15 +1154,41 @@
|
|||||||
meaning.className = "card-meaning";
|
meaning.className = "card-meaning";
|
||||||
meaning.textContent = meaningText;
|
meaning.textContent = meaningText;
|
||||||
|
|
||||||
|
details.appendChild(name);
|
||||||
|
details.appendChild(orientation);
|
||||||
|
details.appendChild(meaning);
|
||||||
|
cardDetails.push(details);
|
||||||
|
|
||||||
|
const placeholder = document.createElement("div");
|
||||||
|
placeholder.className = "card-placeholder";
|
||||||
|
placeholder.textContent = "Click card to reveal";
|
||||||
|
flipInners[flipInners.length - 1].placeholder = placeholder;
|
||||||
|
|
||||||
slot.appendChild(label);
|
slot.appendChild(label);
|
||||||
slot.appendChild(imgWrap);
|
slot.appendChild(imgWrap);
|
||||||
slot.appendChild(name);
|
slot.appendChild(placeholder);
|
||||||
slot.appendChild(orientation);
|
slot.appendChild(details);
|
||||||
slot.appendChild(meaning);
|
|
||||||
area.appendChild(slot);
|
area.appendChild(slot);
|
||||||
});
|
});
|
||||||
|
|
||||||
drawBtn.disabled = false;
|
const TEXT_DELAY = 350;
|
||||||
|
|
||||||
|
flipInners.forEach((entry, i) => {
|
||||||
|
entry.inner.addEventListener("click", () => {
|
||||||
|
if (entry.flipped) return;
|
||||||
|
entry.flipped = true;
|
||||||
|
entry.placeholder.classList.add("hidden");
|
||||||
|
playFlipSound();
|
||||||
|
entry.inner.classList.add("flipped");
|
||||||
|
setTimeout(() => {
|
||||||
|
cardDetails[i].classList.add("revealed");
|
||||||
|
}, TEXT_DELAY);
|
||||||
|
if (flipInners.every(e => e.flipped)) {
|
||||||
|
drawBtn.disabled = false;
|
||||||
|
document.querySelectorAll(".spread-btn").forEach(b => { b.disabled = false; });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".spread-btn").forEach(btn => {
|
document.querySelectorAll(".spread-btn").forEach(btn => {
|
||||||
|
|||||||
Reference in New Issue
Block a user