mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 02:53:51 +00:00
For now sunset resources work
This commit is contained in:
@@ -1,877 +0,0 @@
|
|||||||
---
|
|
||||||
import Background from "@/components/background";
|
|
||||||
---
|
|
||||||
|
|
||||||
<button id="presentation-button" class="presentation-hidden" type="button"
|
|
||||||
>Start Presentation</button
|
|
||||||
>
|
|
||||||
|
|
||||||
<div class="presentation-progress"></div>
|
|
||||||
|
|
||||||
<!-- Background components for presentation mode -->
|
|
||||||
<div class="presentation-backgrounds">
|
|
||||||
<Background layout="content" position="right" client:only="react" transition:persist />
|
|
||||||
<Background layout="content" position="left" client:only="react" transition:persist />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fullscreen preview background -->
|
|
||||||
<div id="fullscreen-preview" class="fullscreen-preview-hidden">
|
|
||||||
<Background layout="index" client:only="react" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const button = document.getElementById(
|
|
||||||
"presentation-button"
|
|
||||||
) as HTMLButtonElement;
|
|
||||||
|
|
||||||
let slides = Array.from(document.querySelectorAll(".presentation-slide"));
|
|
||||||
|
|
||||||
let slide = 0;
|
|
||||||
let step = 0; // Current step within the slide
|
|
||||||
let presenter = false;
|
|
||||||
let fullscreenPreview = false;
|
|
||||||
|
|
||||||
const presentationId = window.location.href;
|
|
||||||
|
|
||||||
const nextSlide = () => {
|
|
||||||
if (slide === slides.length - 1) {
|
|
||||||
return slide;
|
|
||||||
}
|
|
||||||
return slide + 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevSlide = () => {
|
|
||||||
if (slide === 0) {
|
|
||||||
return slide;
|
|
||||||
}
|
|
||||||
return slide - 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextClass = "presentation-next";
|
|
||||||
const currClass = "presentation-current";
|
|
||||||
const prevClass = "presentation-prev";
|
|
||||||
|
|
||||||
const transitionClasses = [nextClass, currClass, prevClass];
|
|
||||||
|
|
||||||
// Animation classes for step-through content
|
|
||||||
const animationClasses = {
|
|
||||||
'fade-in': 'animate-fade-in-step',
|
|
||||||
'slide-in-left': 'animate-slide-in-left-step',
|
|
||||||
'slide-in-right': 'animate-slide-in-right-step',
|
|
||||||
'slide-in-up': 'animate-slide-in-up-step',
|
|
||||||
'slide-in-down': 'animate-slide-in-down-step',
|
|
||||||
'type-in': 'animate-type-in-step',
|
|
||||||
'scale-in': 'animate-scale-in-step',
|
|
||||||
'bounce-in': 'animate-bounce-in-step'
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCurrentSlideSteps = () => {
|
|
||||||
const currentSlide = slides[slide];
|
|
||||||
return Array.from(currentSlide.querySelectorAll('[step]'))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const stepA = parseInt((a as HTMLElement).getAttribute('step') || '0');
|
|
||||||
const stepB = parseInt((b as HTMLElement).getAttribute('step') || '0');
|
|
||||||
return stepA - stepB;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMaxSteps = () => {
|
|
||||||
const steps = getCurrentSlideSteps();
|
|
||||||
if (steps.length === 0) return 0;
|
|
||||||
const lastStep = steps[steps.length - 1] as HTMLElement;
|
|
||||||
return parseInt(lastStep.getAttribute('step') || '0');
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepsUpTo = (targetStep: number, isReverse: boolean = false) => {
|
|
||||||
const steps = getCurrentSlideSteps();
|
|
||||||
|
|
||||||
steps.forEach((stepElement) => {
|
|
||||||
const element = stepElement as HTMLElement;
|
|
||||||
const elementStep = parseInt(element.getAttribute('step') || '0');
|
|
||||||
const animationType = element.getAttribute('animation') || 'fade-in';
|
|
||||||
|
|
||||||
// Remove all animation classes first
|
|
||||||
Object.values(animationClasses).forEach(cls => {
|
|
||||||
element.classList.remove(cls);
|
|
||||||
element.classList.remove(cls.replace('-step', '-reverse-step'));
|
|
||||||
});
|
|
||||||
element.classList.remove('step-hidden', 'step-visible');
|
|
||||||
|
|
||||||
if (elementStep <= targetStep) {
|
|
||||||
// Show this step with animation
|
|
||||||
element.classList.add('step-visible');
|
|
||||||
if (elementStep === targetStep) {
|
|
||||||
// Apply animation only to the current step
|
|
||||||
const baseAnimationClass = animationClasses[animationType as keyof typeof animationClasses] || animationClasses['fade-in'];
|
|
||||||
const animationClass = isReverse ? baseAnimationClass.replace('-step', '-reverse-step') : baseAnimationClass;
|
|
||||||
|
|
||||||
// Special handling for type-in animation
|
|
||||||
if (animationType === 'type-in') {
|
|
||||||
if (isReverse) {
|
|
||||||
startTypeAnimation(element, false);
|
|
||||||
} else {
|
|
||||||
startTypeAnimation(element, true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
element.classList.add(animationClass);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Hide this step
|
|
||||||
element.classList.add('step-hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const startTypeAnimation = (element: HTMLElement, isForward: boolean) => {
|
|
||||||
const text = element.textContent || '';
|
|
||||||
const duration = isForward ? 1500 : 1000; // ms
|
|
||||||
const steps = Math.max(text.length, 1);
|
|
||||||
const stepDuration = duration / steps;
|
|
||||||
|
|
||||||
if (isForward) {
|
|
||||||
// Type in: reveal characters one by one
|
|
||||||
element.textContent = '';
|
|
||||||
element.style.opacity = '1';
|
|
||||||
|
|
||||||
let currentIndex = 0;
|
|
||||||
const typeInterval = setInterval(() => {
|
|
||||||
if (currentIndex < text.length) {
|
|
||||||
element.textContent = text.substring(0, currentIndex + 1);
|
|
||||||
currentIndex++;
|
|
||||||
} else {
|
|
||||||
clearInterval(typeInterval);
|
|
||||||
}
|
|
||||||
}, stepDuration);
|
|
||||||
|
|
||||||
// Store interval reference for cleanup
|
|
||||||
(element as any)._typeInterval = typeInterval;
|
|
||||||
} else {
|
|
||||||
// Type out: hide characters one by one from the end
|
|
||||||
let currentLength = text.length;
|
|
||||||
const typeInterval = setInterval(() => {
|
|
||||||
if (currentLength > 0) {
|
|
||||||
element.textContent = text.substring(0, currentLength - 1);
|
|
||||||
currentLength--;
|
|
||||||
} else {
|
|
||||||
clearInterval(typeInterval);
|
|
||||||
element.style.opacity = '0';
|
|
||||||
}
|
|
||||||
}, stepDuration);
|
|
||||||
|
|
||||||
// Store interval reference for cleanup
|
|
||||||
(element as any)._typeInterval = typeInterval;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetSlideSteps = () => {
|
|
||||||
const steps = getCurrentSlideSteps();
|
|
||||||
steps.forEach((stepElement) => {
|
|
||||||
const element = stepElement as HTMLElement;
|
|
||||||
|
|
||||||
// Clear any running type animations
|
|
||||||
if ((element as any)._typeInterval) {
|
|
||||||
clearInterval((element as any)._typeInterval);
|
|
||||||
(element as any)._typeInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.values(animationClasses).forEach(cls => {
|
|
||||||
element.classList.remove(cls);
|
|
||||||
element.classList.remove(cls.replace('-step', '-reverse-step'));
|
|
||||||
});
|
|
||||||
element.classList.remove('step-hidden', 'step-visible');
|
|
||||||
element.classList.add('step-hidden');
|
|
||||||
|
|
||||||
// Reset text content and styles for type-in elements
|
|
||||||
if (element.getAttribute('animation') === 'type-in') {
|
|
||||||
element.style.opacity = '0';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextStep = () => {
|
|
||||||
const maxSteps = getMaxSteps();
|
|
||||||
|
|
||||||
if (step < maxSteps) {
|
|
||||||
step++;
|
|
||||||
showStepsUpTo(step, false);
|
|
||||||
return { slide, step };
|
|
||||||
} else {
|
|
||||||
// Move to next slide
|
|
||||||
if (slide < slides.length - 1) {
|
|
||||||
const nextSlideIndex = nextSlide();
|
|
||||||
return { slide: nextSlideIndex, step: 0 };
|
|
||||||
}
|
|
||||||
return { slide, step };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevStep = () => {
|
|
||||||
if (step > 0) {
|
|
||||||
step--;
|
|
||||||
showStepsUpTo(step, true);
|
|
||||||
return { slide, step };
|
|
||||||
} else {
|
|
||||||
// Move to previous slide
|
|
||||||
if (slide > 0) {
|
|
||||||
const prevSlideIndex = prevSlide();
|
|
||||||
// Set to max steps of previous slide
|
|
||||||
const tempSlide = slide;
|
|
||||||
slide = prevSlideIndex;
|
|
||||||
const maxSteps = getMaxSteps();
|
|
||||||
slide = tempSlide;
|
|
||||||
return { slide: prevSlideIndex, step: maxSteps };
|
|
||||||
}
|
|
||||||
return { slide, step };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleFullscreenPreview = () => {
|
|
||||||
const previewElement = document.getElementById('fullscreen-preview');
|
|
||||||
if (!previewElement) return;
|
|
||||||
|
|
||||||
fullscreenPreview = !fullscreenPreview;
|
|
||||||
|
|
||||||
if (fullscreenPreview) {
|
|
||||||
previewElement.classList.remove('fullscreen-preview-hidden');
|
|
||||||
previewElement.classList.add('fullscreen-preview-visible');
|
|
||||||
} else {
|
|
||||||
previewElement.classList.remove('fullscreen-preview-visible');
|
|
||||||
previewElement.classList.add('fullscreen-preview-hidden');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const keyHandlers: Record<string, () => { slide: number, step: number } | void> = {
|
|
||||||
ArrowRight: nextStep,
|
|
||||||
ArrowLeft: prevStep,
|
|
||||||
p: toggleFullscreenPreview,
|
|
||||||
P: toggleFullscreenPreview,
|
|
||||||
};
|
|
||||||
|
|
||||||
const displaySlides = () => {
|
|
||||||
for (let i = 0; i < slides.length; i++) {
|
|
||||||
slides[i].classList.remove("active", "inactive", ...transitionClasses);
|
|
||||||
|
|
||||||
if (i === slide) {
|
|
||||||
slides[i].classList.add("active", currClass);
|
|
||||||
// Show steps for current slide
|
|
||||||
showStepsUpTo(step, false);
|
|
||||||
} else {
|
|
||||||
slides[i].classList.add("inactive");
|
|
||||||
// Reset steps for non-current slides
|
|
||||||
const tempSlide = slide;
|
|
||||||
slide = i;
|
|
||||||
resetSlideSteps();
|
|
||||||
slide = tempSlide;
|
|
||||||
|
|
||||||
if (i > slide) {
|
|
||||||
slides[i].classList.add(nextClass);
|
|
||||||
} else {
|
|
||||||
slides[i].classList.add(prevClass);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let presenting = false;
|
|
||||||
const startPresentation = () => {
|
|
||||||
button.innerHTML = "Resume presentation";
|
|
||||||
document.body.classList.add("presentation-overflow-hidden");
|
|
||||||
|
|
||||||
presenting = true;
|
|
||||||
step = 0; // Reset step
|
|
||||||
displaySlides();
|
|
||||||
setProgress();
|
|
||||||
initListeners();
|
|
||||||
};
|
|
||||||
|
|
||||||
const endPresentation = () => {
|
|
||||||
document.body.classList.remove("presentation-overflow-hidden");
|
|
||||||
|
|
||||||
presenting = false;
|
|
||||||
step = 0;
|
|
||||||
fullscreenPreview = false;
|
|
||||||
|
|
||||||
// Hide fullscreen preview if active
|
|
||||||
const previewElement = document.getElementById('fullscreen-preview');
|
|
||||||
if (previewElement) {
|
|
||||||
previewElement.classList.remove('fullscreen-preview-visible');
|
|
||||||
previewElement.classList.add('fullscreen-preview-hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
slides.map((s) => {
|
|
||||||
s.classList.remove("active", "inactive", ...transitionClasses);
|
|
||||||
// Reset all steps
|
|
||||||
const tempSlide = slide;
|
|
||||||
slides.forEach((_, i) => {
|
|
||||||
slide = i;
|
|
||||||
resetSlideSteps();
|
|
||||||
});
|
|
||||||
slide = tempSlide;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const setPresenter = () => {
|
|
||||||
presenter = true;
|
|
||||||
document.body.classList.add("presentation-presenter");
|
|
||||||
};
|
|
||||||
|
|
||||||
const setProgress = () => {
|
|
||||||
const maxSteps = getMaxSteps();
|
|
||||||
const totalSteps = slides.reduce((acc, _, i) => {
|
|
||||||
const tempSlide = slide;
|
|
||||||
slide = i;
|
|
||||||
const steps = getMaxSteps();
|
|
||||||
slide = tempSlide;
|
|
||||||
return acc + Math.max(1, steps);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
let currentProgress = 0;
|
|
||||||
for (let i = 0; i < slide; i++) {
|
|
||||||
const tempSlide = slide;
|
|
||||||
slide = i;
|
|
||||||
const steps = getMaxSteps();
|
|
||||||
slide = tempSlide;
|
|
||||||
currentProgress += Math.max(1, steps);
|
|
||||||
}
|
|
||||||
currentProgress += step;
|
|
||||||
|
|
||||||
const progress = (currentProgress / totalSteps) * 100;
|
|
||||||
document.body.style.setProperty('--presentation-progress', `${progress}%`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const transition = (nextSlide: number, nextStep: number = 0) => {
|
|
||||||
if (!presenting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slide === nextSlide && step === nextStep) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
slides.forEach((s) => s.classList.remove(...transitionClasses));
|
|
||||||
|
|
||||||
slide = nextSlide;
|
|
||||||
step = nextStep;
|
|
||||||
|
|
||||||
displaySlides();
|
|
||||||
setProgress();
|
|
||||||
};
|
|
||||||
|
|
||||||
let listenersInitialized = false;
|
|
||||||
const initListeners = () => {
|
|
||||||
if (listenersInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listenersInitialized = true;
|
|
||||||
window.addEventListener("keyup", (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const isEscape = ev.key === "Escape";
|
|
||||||
if (isEscape) {
|
|
||||||
endPresentation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSpace = ev.key === " ";
|
|
||||||
if (isSpace) {
|
|
||||||
setPresenter();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getNextPosition = keyHandlers[ev.key];
|
|
||||||
|
|
||||||
if (!getNextPosition) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = getNextPosition();
|
|
||||||
if (result && 'slide' in result) {
|
|
||||||
const { slide: nextSlideIndex, step: nextStepIndex } = result;
|
|
||||||
transition(nextSlideIndex, nextStepIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let touchstartX = 0;
|
|
||||||
let touchendX = 0;
|
|
||||||
const handleGesture = () => {
|
|
||||||
const magnitude = Math.abs(touchstartX - touchendX);
|
|
||||||
|
|
||||||
if (magnitude < 40) {
|
|
||||||
// Ignore since this could be a scroll up/down
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (touchendX < touchstartX) {
|
|
||||||
const { slide: nextSlideIndex, step: nextStepIndex } = nextStep();
|
|
||||||
transition(nextSlideIndex, nextStepIndex);
|
|
||||||
}
|
|
||||||
if (touchendX > touchstartX) {
|
|
||||||
const { slide: prevSlideIndex, step: prevStepIndex } = prevStep();
|
|
||||||
transition(prevSlideIndex, prevStepIndex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"touchstart",
|
|
||||||
(ev) => {
|
|
||||||
touchstartX = ev.changedTouches[0].screenX;
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"touchend",
|
|
||||||
(event) => {
|
|
||||||
touchendX = event.changedTouches[0].screenX;
|
|
||||||
handleGesture();
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// If there is no presentation on the page then we don't initialize
|
|
||||||
if (slides.length) {
|
|
||||||
button.classList.remove("presentation-hidden");
|
|
||||||
button.addEventListener("click", startPresentation);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style is:global>
|
|
||||||
.presentation-progress {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Presentation background positioning */
|
|
||||||
.presentation-backgrounds {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 5; /* Below slides but above normal content */
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-overflow-hidden .presentation-backgrounds {
|
|
||||||
display: block;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-overflow-hidden:not(.presentation-presenter) .presentation-backgrounds {
|
|
||||||
z-index: 5; /* Ensure it's visible during presentation */
|
|
||||||
}
|
|
||||||
.fullscreen-preview-hidden {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 9999;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullscreen-preview-visible {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 9999;
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Step animation styles */
|
|
||||||
.step-hidden {
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-visible {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation keyframes */
|
|
||||||
@keyframes fadeInStep {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeOutStep {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInLeftStep {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-30px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideOutLeftStep {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-30px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInRightStep {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(30px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideOutRightStep {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(30px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInUpStep {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideOutUpStep {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInDownStep {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-30px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideOutDownStep {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-30px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scaleInStep {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scaleOutStep {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounceInStep {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.3);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounceOutStep {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes typeInStep {
|
|
||||||
/* This is now unused - keeping for backward compatibility */
|
|
||||||
from { opacity: 1; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes typeOutStep {
|
|
||||||
/* This is now unused - keeping for backward compatibility */
|
|
||||||
from { opacity: 1; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation classes */
|
|
||||||
.animate-fade-in-step {
|
|
||||||
animation: fadeInStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-reverse-step {
|
|
||||||
animation: fadeOutStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in-left-step {
|
|
||||||
animation: slideInLeftStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in-left-reverse-step {
|
|
||||||
animation: slideOutLeftStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in-right-step {
|
|
||||||
animation: slideInRightStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in-right-reverse-step {
|
|
||||||
animation: slideOutRightStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in-up-step {
|
|
||||||
animation: slideInUpStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in-up-reverse-step {
|
|
||||||
animation: slideOutUpStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in-down-step {
|
|
||||||
animation: slideInDownStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in-down-reverse-step {
|
|
||||||
animation: slideOutDownStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-scale-in-step {
|
|
||||||
animation: scaleInStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-scale-in-reverse-step {
|
|
||||||
animation: scaleOutStep 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-bounce-in-step {
|
|
||||||
animation: bounceInStep 0.8s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-bounce-in-reverse-step {
|
|
||||||
animation: bounceOutStep 0.8s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-type-in-step {
|
|
||||||
/* JavaScript-controlled animation - no CSS animation needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-type-in-reverse-step {
|
|
||||||
/* JavaScript-controlled animation - no CSS animation needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-overflow-hidden {
|
|
||||||
overflow: hidden;
|
|
||||||
visibility: hidden;
|
|
||||||
|
|
||||||
.presentation-hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4 {
|
|
||||||
font-size: xx-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-slide.large {
|
|
||||||
font-size: x-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-progress {
|
|
||||||
transition: width 1000ms;
|
|
||||||
display: block;
|
|
||||||
visibility: visible;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 20;
|
|
||||||
top:0px;
|
|
||||||
left: 0px;
|
|
||||||
width: var(--presentation-progress);
|
|
||||||
height: .25rem;
|
|
||||||
background: var(--color-brand-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-slide {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
|
|
||||||
visibility: visible;
|
|
||||||
|
|
||||||
transition: transform 300ms ease-in-out;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
background-color: var(--color-base);
|
|
||||||
color: var(--color-on-base);
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
padding: 2rem 4rem;
|
|
||||||
|
|
||||||
z-index: 10;
|
|
||||||
overflow: auto;
|
|
||||||
|
|
||||||
&.centered {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.highlight{
|
|
||||||
background-color: var(--color-brand);
|
|
||||||
color: var(--color-on-brand)
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-slide-only {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.astro-code {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-height: 80vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
&.presentation-presenter {
|
|
||||||
.presentation-slide {
|
|
||||||
border: none;
|
|
||||||
border-bottom: solid 8px var(--color-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-note {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 24px;
|
|
||||||
opacity: .8;
|
|
||||||
right: 24px;
|
|
||||||
left: 25%;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-slide-only {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-next {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-current {
|
|
||||||
transform: translateX(0%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-prev {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-note {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presentation-presenter {
|
|
||||||
.presentation-slide {
|
|
||||||
border: dotted 8px var(--color-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ensure that notes are visible if presentation mode is active, even if
|
|
||||||
not presenting */
|
|
||||||
.presentation-note {
|
|
||||||
display: block;
|
|
||||||
/* intentionally obnoxios color to draw attention */
|
|
||||||
background-color: crimson;
|
|
||||||
padding: 24px;
|
|
||||||
color: white;
|
|
||||||
font-size: xx-large;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
export interface Props {
|
|
||||||
centered?: boolean
|
|
||||||
highlight?: boolean
|
|
||||||
large?: boolean
|
|
||||||
}
|
|
||||||
const { centered = true, highlight, large } = Astro.props
|
|
||||||
---
|
|
||||||
|
|
||||||
<section class="presentation-slide" class:list={{ centered, highlight, large }}>
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
8
src/src/content/blog/breaking-the-chromebook-cage.mdx
Normal file
8
src/src/content/blog/breaking-the-chromebook-cage.mdx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Breaking the Chromebook Cage
|
||||||
|
description: From breaking Chromebooks as a student to breaking Chromebooks to stop students from breaking Chromebooks.
|
||||||
|
author: Timothy Pidashev
|
||||||
|
tags: ["uefi", "coreboot", "firmware", "chromebooks"]
|
||||||
|
date: 2025-09-15
|
||||||
|
image: "/blog/breaking-the-chromebook-cage/thumbnail.png"
|
||||||
|
---
|
||||||
@@ -24,17 +24,5 @@ export const collections = {
|
|||||||
date: z.string(),
|
date: z.string(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
})
|
||||||
resources: defineCollection({
|
|
||||||
schema: z.object({
|
|
||||||
title: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
date: z.coerce.date().transform((date) => {
|
|
||||||
return new Date(date.setUTCHours(12, 0, 0, 0));
|
|
||||||
}),
|
|
||||||
duration: z.string(),
|
|
||||||
image: z.string().optional(),
|
|
||||||
tags: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
---
|
|
||||||
title: "0101 - Welcome to the Terminal"
|
|
||||||
description: "Week 1 | Lesson 1"
|
|
||||||
date: 2025-09-27
|
|
||||||
duration: "45 minutes"
|
|
||||||
tags: ["terminal"]
|
|
||||||
---
|
|
||||||
|
|
||||||
import Slide from "@/components/resources/slide.astro";
|
|
||||||
import Presentation from "@/components/resources/presentation.astro";
|
|
||||||
|
|
||||||
import { Video } from "@/components/mdx/video";
|
|
||||||
|
|
||||||
<Presentation />
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
* The terminal is a way to **talk to your computer** using words instead of clicking.
|
|
||||||
* It’s like giving your computer instructions directly.
|
|
||||||
* Powerful, fast, and used by programmers every day!
|
|
||||||
* It's sometimes called a **command-line** because it accepts commands as instructions
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
## Video Introduction
|
|
||||||
<Video client:load
|
|
||||||
title="What is the terminal and why should I use it? // Developer Fundamentals"
|
|
||||||
url="https://timmypidashev.us-sea-1.linodeobjects.com/curriculum%2Fterminal%2F01.mp4"
|
|
||||||
attribution="https://www.youtube.com/watch?v=lZ7Kix9bjPI"
|
|
||||||
/>
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
## Opening the Terminal
|
|
||||||
|
|
||||||
1. Open your laptops and power them on
|
|
||||||
2. When prompted to login, enter your credentials:
|
|
||||||
**Username:** your first and last name (all lowercase, no spaces) `timothypidashev`
|
|
||||||
**Password:** your birthday in MMDDYYYY format (without slashes) `08052004`
|
|
||||||
> Once logged in, help the person to your right and left,
|
|
||||||
> we will continue when everybody is ready!
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
## It's Lonely in Here...
|
|
||||||
|
|
||||||
Now you should see a **prompt** that looks something like this:
|
|
||||||
```fish
|
|
||||||
timothypidashev@laptop1 ~>
|
|
||||||
```
|
|
||||||
This might look intimidating, but it's actually telling you useful information!
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
## Understanding the Prompt
|
|
||||||
|
|
||||||
```fish
|
|
||||||
timothypidashev@laptop1 ~>
|
|
||||||
```
|
|
||||||
|
|
||||||
Breaking it down:
|
|
||||||
- **`timothypidashev`** - This is **your username**. It tells you who is currently using the computer
|
|
||||||
- **`@laptop1`** - This is the **computer's name** (hostname). Useful when working with multiple computers!
|
|
||||||
- **`~`** - This is your **current location** in the computer. The `~` symbol means you're in your "home" folder
|
|
||||||
- **`>`** - This is the **prompt symbol**. It's waiting for you to type a command!
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
## Your First Command - `whoami`
|
|
||||||
|
|
||||||
Let's try our first command! Type this and press **Enter**:
|
|
||||||
|
|
||||||
```fish
|
|
||||||
whoami
|
|
||||||
```
|
|
||||||
|
|
||||||
- This command asks the computer: "Who am I?"
|
|
||||||
- The computer should respond with your username!
|
|
||||||
|
|
||||||
> **Try it!** Type `whoami` and press Enter
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide>
|
|
||||||
## Where Am I? - The `pwd` Command
|
|
||||||
|
|
||||||
Now let's find out exactly where we are in the computer. Type:
|
|
||||||
|
|
||||||
```fish
|
|
||||||
pwd
|
|
||||||
```
|
|
||||||
|
|
||||||
- `pwd` stands for **"Print Working Directory"**
|
|
||||||
- It tells you the **full path** to where you currently are
|
|
||||||
- You should see something like: `/home/timothypidashev`
|
|
||||||
|
|
||||||
> **Try it!** Type `pwd` and press Enter
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide>
|
|
||||||
## Looking Around - The `ls` Command
|
|
||||||
|
|
||||||
Let's see what's in our current location. Type:
|
|
||||||
|
|
||||||
```fish
|
|
||||||
ls
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ls` stands for **"list"** - it shows you all the files and folders in your current location
|
|
||||||
- You might see folders like `Desktop`, `Documents`, `Downloads`, etc.
|
|
||||||
|
|
||||||
> **Try it!** Type `ls` and press Enter. What do you see?
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide>
|
|
||||||
## Making Your Mark - The `mkdir` Command
|
|
||||||
|
|
||||||
Let's create your first folder using the terminal! Type:
|
|
||||||
|
|
||||||
```fish
|
|
||||||
mkdir my-first-folder
|
|
||||||
```
|
|
||||||
|
|
||||||
- `mkdir` stands for **"make directory"** (folder and directory mean the same thing)
|
|
||||||
- Now type `ls` again to see your new folder appear!
|
|
||||||
|
|
||||||
> **Try it!** Create a folder, then list the contents to see it
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
## Moving Around - The `cd` Command
|
|
||||||
|
|
||||||
Let's go inside the folder we just created. Type:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd my-first-folder
|
|
||||||
```
|
|
||||||
|
|
||||||
- `cd` stands for **"change directory"** - it moves you to a different location
|
|
||||||
- Notice how your prompt changes! The `~` might change to show your new location
|
|
||||||
- Type `pwd` to confirm where you are now
|
|
||||||
|
|
||||||
> **Try it!** Move into your folder and check your location
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
## Going Back - `cd ..`
|
|
||||||
|
|
||||||
To go back to the previous folder (your home), type:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
- The `..` means **"parent directory"** - the folder that contains your current folder
|
|
||||||
- You can also type `cd ~` or just `cd` to go back to your home folder anytime
|
|
||||||
|
|
||||||
> **Try it!** Go back to your home folder
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
## Creating Files - The `touch` Command
|
|
||||||
|
|
||||||
Let's create your first file using the terminal. Type:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
touch hello.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
- `touch` creates a new, empty file
|
|
||||||
- Type `ls` to see your new file appear alongside your folder
|
|
||||||
|
|
||||||
> **Try it!** Create a file and list the contents to see it
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
## Cleaning Up - The `rm` Command
|
|
||||||
|
|
||||||
Let's clean up by removing the file we just created:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm hello.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
- `rm` stands for **"remove"** - it deletes files
|
|
||||||
- ⚠️ **Warning**: `rm` permanently deletes files - there's no trash can in the terminal!
|
|
||||||
- Type `ls` to confirm the file is gone
|
|
||||||
|
|
||||||
> **Try it!** Remove your file and verify it's deleted
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
|
|
||||||
<Slide large>
|
|
||||||
|
|
||||||
## Command Summary
|
|
||||||
|
|
||||||
Here are the commands we learned today:
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `whoami` | Shows your username |
|
|
||||||
| `pwd` | Shows your current location |
|
|
||||||
| `ls` | Lists files and folders |
|
|
||||||
| `mkdir` | Creates a new folder |
|
|
||||||
| `cd` | Changes to a different folder |
|
|
||||||
| `cd ..` | Goes to the parent folder |
|
|
||||||
| `touch` | Creates a new file |
|
|
||||||
| `rm` | Removes/deletes a file |
|
|
||||||
|
|
||||||
> BYTE! | Create a file named `.hidden` and see if you can find it with `ls -a`!
|
|
||||||
|
|
||||||
---
|
|
||||||
</Slide>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
export const prerender = true;
|
|
||||||
|
|
||||||
import { getCollection } from "astro:content";
|
|
||||||
|
|
||||||
import ResourceLayout from "@/layouts/resource.astro";
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const resources = await getCollection("resources");
|
|
||||||
return resources.map(resource => ({
|
|
||||||
params: { slug: resource.slug },
|
|
||||||
props: { resource },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { resource } = Astro.props;
|
|
||||||
const { Content } = await resource.render();
|
|
||||||
---
|
|
||||||
|
|
||||||
<ResourceLayout title={`${resource.data.title} | Timothy Pidashev`}>
|
|
||||||
<article class="w-full mx-auto px-4 pt-6 sm:pt-12">
|
|
||||||
<header class="mb-8">
|
|
||||||
<h1 class="text-3xl sm:text-4xl font-bold text-yellow-bright mb-4">
|
|
||||||
{resource.data.title}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<div class="prose prose-invert prose-lg max-w-none">
|
|
||||||
<Content />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</ResourceLayout>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
// src/pages/resources/index.astro - SIMPLE LISTING
|
|
||||||
import { getCollection } from "astro:content";
|
|
||||||
import ContentLayout from "@/layouts/content.astro";
|
|
||||||
|
|
||||||
const allResources = (await getCollection("resources")).sort(
|
|
||||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
||||||
);
|
|
||||||
---
|
|
||||||
|
|
||||||
<ContentLayout
|
|
||||||
title="Resources | Timothy Pidashev"
|
|
||||||
description="Educational resources, curriculum materials, and presentation slides."
|
|
||||||
>
|
|
||||||
<div class="max-w-6xl mx-auto pt-24 sm:pt-32 px-4">
|
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-purple mb-12 text-center leading-relaxed">
|
|
||||||
Educational Resources <br className="sm:hidden" />
|
|
||||||
& Materials
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{allResources.map((resource) => (
|
|
||||||
<a
|
|
||||||
href={`/resources/${resource.slug}`}
|
|
||||||
class="group block p-6 bg-background border border-foreground/20 rounded-lg hover:border-purple-bright/50 transition-all duration-300"
|
|
||||||
>
|
|
||||||
{resource.data.image && (
|
|
||||||
<div class="aspect-video w-full mb-4 rounded-md overflow-hidden bg-foreground/5">
|
|
||||||
<img
|
|
||||||
src={resource.data.image}
|
|
||||||
alt={resource.data.title}
|
|
||||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h3 class="text-xl font-bold text-foreground/90 group-hover:text-purple-bright transition-colors mb-2">
|
|
||||||
{resource.data.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p class="text-foreground/70 mb-4 line-clamp-3">
|
|
||||||
{resource.data.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4 text-sm text-foreground/60 mb-4">
|
|
||||||
<span>⏱️ {resource.data.duration}</span>
|
|
||||||
<span>📅 {resource.data.date.toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{resource.data.tags.map((tag) => (
|
|
||||||
<span class="text-xs px-2 py-1 bg-foreground/10 text-foreground/70 rounded">
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ContentLayout>
|
|
||||||
Reference in New Issue
Block a user