Compare commits
No commits in common. "6497f5dfb837739c7ab53713c029a7575250618e" and "6e392cb8bf1081d5c28bca42ea3a5d4d320e6f84" have entirely different histories.
6497f5dfb8
...
6e392cb8bf
@ -27,9 +27,9 @@ const { title, description, url, duration, timestamp, tags } = Astro.props;
|
|||||||
<p class="">
|
<p class="">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap gap-2 w-full">
|
<div class="flex flex-wrap gap-2">
|
||||||
{tags?.map((tag: string) => (
|
{tags?.map((tag: string) => (
|
||||||
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title={tag}>
|
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
import SkeletonLoader from "./common/SkeletonLoader.astro";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { className = "" } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class={`article-snippet-skeleton ${className} mb-8`}>
|
|
||||||
<!-- Title skeleton -->
|
|
||||||
<div class="mb-2">
|
|
||||||
<SkeletonLoader type="title" height="1.75rem" width="85%" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date skeleton -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<SkeletonLoader type="text" height="1rem" width="30%" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description skeleton -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<SkeletonLoader type="paragraph" height="3rem" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tags skeleton -->
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<SkeletonLoader type="text" height="1.5rem" width="60px" rounded={true} />
|
|
||||||
<SkeletonLoader type="text" height="1.5rem" width="80px" rounded={true} />
|
|
||||||
<SkeletonLoader type="text" height="1.5rem" width="70px" rounded={true} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -38,10 +38,10 @@ const { title, description, url, githubUrl, liveUrl, tags } = Astro.props;
|
|||||||
<p class="zag-text zag-transition">
|
<p class="zag-text zag-transition">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap gap-2 w-full">
|
<div class="flex flex-row wrap gap-2">
|
||||||
{
|
{
|
||||||
tags.map((tag) => (
|
tags.map((tag) => (
|
||||||
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title={tag}>
|
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
import SkeletonLoader from "./common/SkeletonLoader.astro";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { className = "" } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class={`project-snippet-skeleton ${className} mb-8`}>
|
|
||||||
<div class="flex flex-col md:flex-row gap-6">
|
|
||||||
<!-- Image skeleton -->
|
|
||||||
<div class="md:w-1/3">
|
|
||||||
<SkeletonLoader type="image" height="200px" rounded={true} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:w-2/3">
|
|
||||||
<!-- Title skeleton -->
|
|
||||||
<div class="mb-2">
|
|
||||||
<SkeletonLoader type="title" height="1.75rem" width="85%" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description skeleton -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<SkeletonLoader type="paragraph" height="4rem" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Links skeleton -->
|
|
||||||
<div class="flex gap-4 mb-3">
|
|
||||||
<SkeletonLoader type="text" height="1.25rem" width="100px" />
|
|
||||||
<SkeletonLoader type="text" height="1.25rem" width="100px" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tags skeleton -->
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<SkeletonLoader type="text" height="1.5rem" width="60px" rounded={true} />
|
|
||||||
<SkeletonLoader type="text" height="1.5rem" width="80px" rounded={true} />
|
|
||||||
<SkeletonLoader type="text" height="1.5rem" width="70px" rounded={true} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -24,23 +24,16 @@ function initializeSearch(contentSelector = '.searchable-item', options = {}) {
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
hasResults: true,
|
hasResults: true,
|
||||||
visibleCount: 0,
|
visibleCount: 0,
|
||||||
loading: false, // Start with loading state false - the LoadingManager will control this
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize the visible count
|
// Initialize the visible count
|
||||||
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
||||||
this.setupWatchers();
|
this.setupWatchers();
|
||||||
this.setupKeyboardShortcuts();
|
this.setupKeyboardShortcuts();
|
||||||
|
|
||||||
// Handle theme changes
|
|
||||||
window.addEventListener('theme-changed', () => {
|
|
||||||
this.filterContent(this.searchQuery);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setupWatchers() {
|
setupWatchers() {
|
||||||
this.$watch('searchQuery', (query) => {
|
this.$watch('searchQuery', (query) => {
|
||||||
// Filter content immediately - no artificial delay
|
|
||||||
this.filterContent(query);
|
this.filterContent(query);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -1,224 +0,0 @@
|
|||||||
---
|
|
||||||
interface Props {
|
|
||||||
message?: string;
|
|
||||||
subMessage?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
message = "This is taking longer than expected...",
|
|
||||||
subMessage = "Still working on loading your content",
|
|
||||||
className = ""
|
|
||||||
} = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div
|
|
||||||
class={`loading-overlay fixed inset-0 flex flex-col items-center justify-center z-50 bg-opacity-90 zag-bg ${className}`}
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<div class="loading-content text-center p-8 rounded-lg">
|
|
||||||
<!-- Spinner -->
|
|
||||||
<div class="spinner-container mb-6 flex justify-center">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Messages -->
|
|
||||||
<p class="text-xl font-semibold mb-2 zag-text">{message}</p>
|
|
||||||
<p class="text-base zag-text-muted">{subMessage}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.loading-overlay {
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-overlay.visible {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 4px solid var(--color-zag-dark-muted);
|
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: var(--color-zag-accent-dark);
|
|
||||||
animation: spin 1.5s cubic-bezier(0.68, -0.55, 0.27, 1.55) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .spinner {
|
|
||||||
border-color: var(--color-zag-light-muted);
|
|
||||||
border-top-color: var(--color-zag-accent-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.spinner {
|
|
||||||
animation-duration: 3s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// This script will be executed client-side
|
|
||||||
class LoadingManager {
|
|
||||||
overlay: HTMLElement | null = null;
|
|
||||||
timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
longLoadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.overlay = document.querySelector('.loading-overlay');
|
|
||||||
this.setupNavigationListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupNavigationListeners() {
|
|
||||||
// Listen for navigation events if the Navigation API is supported
|
|
||||||
if ('navigation' in window) {
|
|
||||||
// @ts-ignore - Navigation API might not be recognized by TypeScript
|
|
||||||
window.navigation.addEventListener('navigate', (event) => {
|
|
||||||
// Only handle same-origin navigations
|
|
||||||
if (new URL(event.destination.url).origin === window.location.origin) {
|
|
||||||
this.showLoading();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
window.navigation.addEventListener('navigatesuccess', () => {
|
|
||||||
this.hideLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
window.navigation.addEventListener('navigateerror', () => {
|
|
||||||
this.hideLoading();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback for browsers without Navigation API
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
this.showLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
// For SPA navigation (if using client-side routing)
|
|
||||||
document.addEventListener('astro:page-load', () => {
|
|
||||||
this.hideLoading();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Astro's view transitions
|
|
||||||
document.addEventListener('astro:before-swap', () => {
|
|
||||||
this.showLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('astro:after-swap', () => {
|
|
||||||
this.hideLoading();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showLoading() {
|
|
||||||
// Clear any existing timeouts
|
|
||||||
if (this.timeoutId) {
|
|
||||||
clearTimeout(this.timeoutId);
|
|
||||||
this.timeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.longLoadingTimeoutId) {
|
|
||||||
clearTimeout(this.longLoadingTimeoutId);
|
|
||||||
this.longLoadingTimeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check connection speed if available
|
|
||||||
const showDelay = this.getConnectionBasedDelay();
|
|
||||||
|
|
||||||
// Only show loading UI after a short delay to avoid flashing on fast loads
|
|
||||||
this.timeoutId = setTimeout(() => {
|
|
||||||
// Set loading state in Alpine.js components if they exist
|
|
||||||
document.querySelectorAll('[x-data]').forEach(el => {
|
|
||||||
// @ts-ignore
|
|
||||||
if (el.__x && typeof el.__x.$data.loading !== 'undefined') {
|
|
||||||
// @ts-ignore
|
|
||||||
el.__x.$data.loading = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, showDelay);
|
|
||||||
|
|
||||||
// Show the overlay for long loading times (5+ seconds)
|
|
||||||
this.longLoadingTimeoutId = setTimeout(() => {
|
|
||||||
if (this.overlay) {
|
|
||||||
this.overlay.classList.add('visible');
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
hideLoading() {
|
|
||||||
// Clear timeouts
|
|
||||||
if (this.timeoutId) {
|
|
||||||
clearTimeout(this.timeoutId);
|
|
||||||
this.timeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.longLoadingTimeoutId) {
|
|
||||||
clearTimeout(this.longLoadingTimeoutId);
|
|
||||||
this.longLoadingTimeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the overlay
|
|
||||||
if (this.overlay) {
|
|
||||||
this.overlay.classList.remove('visible');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset loading state in Alpine.js components after a short delay
|
|
||||||
// This ensures transitions look smooth
|
|
||||||
setTimeout(() => {
|
|
||||||
document.querySelectorAll('[x-data]').forEach(el => {
|
|
||||||
// @ts-ignore
|
|
||||||
if (el.__x && typeof el.__x.$data.loading !== 'undefined') {
|
|
||||||
// @ts-ignore
|
|
||||||
el.__x.$data.loading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
getConnectionBasedDelay() {
|
|
||||||
// Use Network Information API if available
|
|
||||||
// @ts-ignore - Navigator connection API might not be recognized
|
|
||||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
|
||||||
|
|
||||||
if (connection) {
|
|
||||||
const type = connection.effectiveType;
|
|
||||||
|
|
||||||
// Adjust delay based on connection type
|
|
||||||
switch (type) {
|
|
||||||
case '4g':
|
|
||||||
return 100; // Fast connection - short delay
|
|
||||||
case '3g':
|
|
||||||
return 200; // Medium connection
|
|
||||||
case '2g':
|
|
||||||
case 'slow-2g':
|
|
||||||
return 0; // Slow connection - show immediately
|
|
||||||
default:
|
|
||||||
return 100; // Default delay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default delay if Network Information API is not available
|
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the loading manager when the DOM is ready
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
new LoadingManager();
|
|
||||||
});
|
|
||||||
</script>
|
|
@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
import SkeletonLoader from "./SkeletonLoader.astro";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { className = "" } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class={`service-card-skeleton ${className}`}>
|
|
||||||
<div class="flex flex-col items-center p-4 border border-current rounded-lg zag-transition">
|
|
||||||
<!-- Icon skeleton -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<SkeletonLoader type="image" width="48px" height="48px" rounded={true} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Title skeleton -->
|
|
||||||
<div class="w-full text-center mb-2">
|
|
||||||
<SkeletonLoader type="title" width="80%" height="1.25rem" className="mx-auto" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.service-card-skeleton {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,134 +0,0 @@
|
|||||||
---
|
|
||||||
interface Props {
|
|
||||||
type?: 'card' | 'text' | 'image' | 'title' | 'paragraph' | 'custom';
|
|
||||||
width?: string;
|
|
||||||
height?: string;
|
|
||||||
className?: string;
|
|
||||||
rounded?: boolean;
|
|
||||||
count?: number;
|
|
||||||
animate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
type = 'card',
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
className = '',
|
|
||||||
rounded = false,
|
|
||||||
count = 1,
|
|
||||||
animate = true,
|
|
||||||
} = Astro.props;
|
|
||||||
|
|
||||||
// Determine dimensions based on type
|
|
||||||
let defaultWidth = '100%';
|
|
||||||
let defaultHeight = '100%';
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'card':
|
|
||||||
defaultWidth = '100%';
|
|
||||||
defaultHeight = '200px';
|
|
||||||
break;
|
|
||||||
case 'text':
|
|
||||||
defaultWidth = '100%';
|
|
||||||
defaultHeight = '1rem';
|
|
||||||
break;
|
|
||||||
case 'image':
|
|
||||||
defaultWidth = '100%';
|
|
||||||
defaultHeight = '150px';
|
|
||||||
break;
|
|
||||||
case 'title':
|
|
||||||
defaultWidth = '70%';
|
|
||||||
defaultHeight = '1.5rem';
|
|
||||||
break;
|
|
||||||
case 'paragraph':
|
|
||||||
defaultWidth = '100%';
|
|
||||||
defaultHeight = '4rem';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalWidth = width || defaultWidth;
|
|
||||||
const finalHeight = height || defaultHeight;
|
|
||||||
const roundedClass = rounded ? 'rounded-lg' : '';
|
|
||||||
const animateClass = animate ? 'animate-pulse' : '';
|
|
||||||
|
|
||||||
// Generate multiple skeleton items if count > 1
|
|
||||||
const items = Array.from({ length: count }, (_, i) => i);
|
|
||||||
---
|
|
||||||
|
|
||||||
{
|
|
||||||
items.map((_, index) => (
|
|
||||||
<div
|
|
||||||
class:list={[
|
|
||||||
'skeleton-loader',
|
|
||||||
'zag-bg-skeleton',
|
|
||||||
animateClass,
|
|
||||||
roundedClass,
|
|
||||||
className
|
|
||||||
]}
|
|
||||||
style={`width: ${finalWidth}; height: ${finalHeight};`}
|
|
||||||
aria-hidden="true"
|
|
||||||
data-testid="skeleton-loader"
|
|
||||||
data-index={index}
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.skeleton-loader {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--color-zag-light-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .skeleton-loader {
|
|
||||||
background-color: var(--color-zag-dark-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-loader::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
background-image: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(255, 255, 255, 0) 0,
|
|
||||||
rgba(255, 255, 255, 0.1) 20%,
|
|
||||||
rgba(255, 255, 255, 0.2) 60%,
|
|
||||||
rgba(255, 255, 255, 0)
|
|
||||||
);
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .skeleton-loader::after {
|
|
||||||
background-image: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(0, 0, 0, 0) 0,
|
|
||||||
rgba(0, 0, 0, 0.1) 20%,
|
|
||||||
rgba(0, 0, 0, 0.2) 60%,
|
|
||||||
rgba(0, 0, 0, 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
100% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-pulse {
|
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -50,13 +50,13 @@ const sourceUrl = generateSourceUrl(frontmatter.filename, "blog");
|
|||||||
<Prose>
|
<Prose>
|
||||||
<slot />
|
<slot />
|
||||||
</Prose>
|
</Prose>
|
||||||
<div class="flex flex-wrap gap-2 w-full mt-8 mb-4">
|
<div class="flex flex-wrap gap-2 mt-4">
|
||||||
{frontmatter.tags?.map((tag) => (
|
{frontmatter.tags?.map((tag) => (
|
||||||
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title={tag}>
|
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p class="pt-4">~{GLOBAL.username}</p>
|
<p class="pt-8">~{GLOBAL.username}</p>
|
||||||
</Section>
|
</Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import Footer from "../components/Footer.astro";
|
import Footer from "../components/Footer.astro";
|
||||||
import Header from "../components/Header.astro";
|
import Header from "../components/Header.astro";
|
||||||
import SearchScript from "../components/SearchScript.astro";
|
import SearchScript from "../components/SearchScript.astro";
|
||||||
import LoadingOverlay from "../components/common/LoadingOverlay.astro";
|
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -52,9 +51,6 @@ import "../styles/global.css";
|
|||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
<body class="zag-bg zag-text zag-transition font-mono">
|
<body class="zag-bg zag-text zag-transition font-mono">
|
||||||
<!-- Loading overlay for long loading times -->
|
|
||||||
<LoadingOverlay />
|
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -37,11 +37,11 @@ const sourceUrl = generateSourceUrl(frontmatter.filename, "projects");
|
|||||||
<h1 class="text-3xl sm:text-4xl leading-tight font-display">
|
<h1 class="text-3xl sm:text-4xl leading-tight font-display">
|
||||||
{frontmatter.title}
|
{frontmatter.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex flex-wrap gap-2 w-full">
|
<div class="flex text-sm gap-2">
|
||||||
{
|
{
|
||||||
frontmatter.tags
|
frontmatter.tags
|
||||||
? frontmatter.tags.map((tag) => (
|
? frontmatter.tags.map((tag) => (
|
||||||
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title={tag}>
|
<span class="-zag-text -zag-bg zag-transition font-semibold py-1 px-2">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
|
@ -28,7 +28,6 @@ export const processContentInDir = async <T extends object, K>(
|
|||||||
.filter((file: string) => file.endsWith(".md"))
|
.filter((file: string) => file.endsWith(".md"))
|
||||||
.map((file) => file.split(".")[0]);
|
.map((file) => file.split(".")[0]);
|
||||||
const readMdFileContent = async (file: string) => {
|
const readMdFileContent = async (file: string) => {
|
||||||
try {
|
|
||||||
if (contentType === "projects") {
|
if (contentType === "projects") {
|
||||||
const content = import.meta
|
const content = import.meta
|
||||||
.glob(`/src/pages/projects/*.md`)
|
.glob(`/src/pages/projects/*.md`)
|
||||||
@ -38,13 +37,6 @@ export const processContentInDir = async <T extends object, K>(
|
|||||||
file: string;
|
file: string;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate frontmatter before processing
|
|
||||||
if (!data || !data.frontmatter) {
|
|
||||||
console.warn(`Warning: Missing or invalid frontmatter in ${file}.md`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return processFn(data);
|
return processFn(data);
|
||||||
} else {
|
} else {
|
||||||
const content = import.meta
|
const content = import.meta
|
||||||
@ -55,23 +47,10 @@ export const processContentInDir = async <T extends object, K>(
|
|||||||
file: string;
|
file: string;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate frontmatter before processing
|
|
||||||
if (!data || !data.frontmatter) {
|
|
||||||
console.warn(`Warning: Missing or invalid frontmatter in ${file}.md`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return processFn(data);
|
return processFn(data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error processing ${file}.md:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const results = await Promise.all(markdownFiles.map(readMdFileContent));
|
return await Promise.all(markdownFiles.map(readMdFileContent));
|
||||||
// Filter out null results from files with errors
|
|
||||||
return results.filter(result => result !== null) as K[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -2,58 +2,9 @@
|
|||||||
layout: ../../layouts/BlogLayout.astro
|
layout: ../../layouts/BlogLayout.astro
|
||||||
title: No, We Have Netflix at Home
|
title: No, We Have Netflix at Home
|
||||||
description: How my exasperation at paying for an ever growing number of streaming services led to a deep obsession
|
description: How my exasperation at paying for an ever growing number of streaming services led to a deep obsession
|
||||||
tags: ["code", "html", "homelab"]
|
tags: ["code", "htmlf"]
|
||||||
time: 4
|
time: 4
|
||||||
featured: true
|
featured: true
|
||||||
timestamp: 2024-12-18T02:39:03+00:00
|
timestamp: 2024-12-18T02:39:03+00:00
|
||||||
filename: html-intro
|
filename: html-intro
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Beginning of an Obsession
|
|
||||||
|
|
||||||
It all started with a simple thought: "Why am I paying for so many streaming services?" Netflix, Hulu, Disney+, HBO Max, Apple TV+, and the list goes on. Each one offering just enough exclusive content to justify its monthly fee, but collectively draining my wallet.
|
|
||||||
|
|
||||||
That's when I decided to take matters into my own hands and build my own media server. Little did I know this would be the gateway to a much deeper homelab obsession.
|
|
||||||
|
|
||||||
## The First Steps
|
|
||||||
|
|
||||||
I started with a simple Plex server running on an old laptop. It wasn't much, but it was mine. I could store my legally obtained media and stream it to any device in my home. The convenience was immediately apparent, and the satisfaction of building something myself was addictive.
|
|
||||||
|
|
||||||
But as with any tech hobby, it didn't stop there. Soon I was researching NAS solutions, RAID configurations, and the best hard drives for 24/7 operation. My simple media server was evolving into something much more complex.
|
|
||||||
|
|
||||||
## Expanding Horizons
|
|
||||||
|
|
||||||
As my collection grew, so did my ambitions. I found myself exploring other self-hosted applications:
|
|
||||||
|
|
||||||
- **Sonarr and Radarr** for managing TV shows and movies
|
|
||||||
- **Jackett** for indexing
|
|
||||||
- **Ombi** for allowing family members to request content
|
|
||||||
- **Tautulli** for monitoring Plex usage
|
|
||||||
|
|
||||||
Each new addition made my system more powerful and more tailored to my specific needs. I was no longer just replicating Netflix; I was building something better.
|
|
||||||
|
|
||||||
## The Current Setup
|
|
||||||
|
|
||||||
Today, my homelab has expanded well beyond just media. It now includes:
|
|
||||||
|
|
||||||
- A proper NAS with redundant storage
|
|
||||||
- Docker containers for various services
|
|
||||||
- Home automation integration
|
|
||||||
- VPN for remote access
|
|
||||||
- Automated backups
|
|
||||||
|
|
||||||
The journey from "I don't want to pay for Netflix" to "I need more hard drives for my server rack" happened almost without me noticing. But I wouldn't have it any other way.
|
|
||||||
|
|
||||||
## Lessons Learned
|
|
||||||
|
|
||||||
If you're considering starting your own homelab journey, here's what I've learned:
|
|
||||||
|
|
||||||
1. **Start small** - You don't need enterprise hardware to begin
|
|
||||||
2. **Document everything** - You'll thank yourself later
|
|
||||||
3. **Backup, backup, backup** - Data loss is painful
|
|
||||||
4. **Join the community** - r/homelab and other forums are invaluable resources
|
|
||||||
5. **Enjoy the process** - The learning is half the fun
|
|
||||||
|
|
||||||
So while my family jokes about having "Netflix at home," I smile knowing that what we have is so much more than just another streaming service. It's a hobby, a learning experience, and a constantly evolving project that brings me joy.
|
|
||||||
|
|
||||||
And yes, it's probably cost me more than just paying for those streaming services would have. But where's the fun in that?
|
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import { GLOBAL } from "../../lib/variables";
|
import { GLOBAL } from "../../lib/variables";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import ArticleSnippet from "../../components/ArticleSnippet.astro";
|
import ArticleSnippet from "../../components/ArticleSnippet.astro";
|
||||||
import ArticleSnippetSkeleton from "../../components/ArticleSnippetSkeleton.astro";
|
|
||||||
import Section from "../../components/common/Section.astro";
|
import Section from "../../components/common/Section.astro";
|
||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import { articles } from "../../lib/list";
|
import { articles } from "../../lib/list";
|
||||||
@ -42,7 +41,7 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte
|
|||||||
<!-- Search functionality is provided by search-client.js -->
|
<!-- Search functionality is provided by search-client.js -->
|
||||||
|
|
||||||
<Section class="my-8">
|
<Section class="my-8">
|
||||||
<div x-data="searchArticles" x-init="init()" x-cloak>
|
<div x-data="searchArticles" x-init="init()">
|
||||||
<!-- Search container - positioned at the top -->
|
<!-- Search container - positioned at the top -->
|
||||||
<div class="mb-4 pt-0">
|
<div class="mb-4 pt-0">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@ -65,29 +64,7 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte
|
|||||||
>
|
>
|
||||||
<p class="text-xl font-semibold zag-text">No Articles Found</p>
|
<p class="text-xl font-semibold zag-text">No Articles Found</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Loading skeleton -->
|
<ul id="article-list">
|
||||||
<div
|
|
||||||
x-show="loading"
|
|
||||||
x-transition:enter="transition ease-out duration-300"
|
|
||||||
x-transition:enter-start="opacity-0"
|
|
||||||
x-transition:enter-end="opacity-100"
|
|
||||||
x-transition:leave="transition ease-in duration-200"
|
|
||||||
x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<ArticleSnippetSkeleton />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actual content -->
|
|
||||||
<ul
|
|
||||||
id="article-list"
|
|
||||||
x-show="!loading"
|
|
||||||
x-transition:enter="transition ease-out duration-300"
|
|
||||||
x-transition:enter-start="opacity-0"
|
|
||||||
x-transition:enter-end="opacity-100"
|
|
||||||
>
|
|
||||||
{
|
{
|
||||||
articles.map((article) => {
|
articles.map((article) => {
|
||||||
const articleTags = article.tags ? article.tags.join(' ').toLowerCase() : '';
|
const articleTags = article.tags ? article.tags.join(' ').toLowerCase() : '';
|
||||||
|
@ -4,8 +4,6 @@ import Layout from "../../layouts/Layout.astro";
|
|||||||
import Section from "../../components/common/Section.astro";
|
import Section from "../../components/common/Section.astro";
|
||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import CategorySection from "../../components/homelab/CategorySection.astro";
|
import CategorySection from "../../components/homelab/CategorySection.astro";
|
||||||
import ServiceCardSkeleton from "../../components/common/ServiceCardSkeleton.astro";
|
|
||||||
import SkeletonLoader from "../../components/common/SkeletonLoader.astro";
|
|
||||||
import { services } from "./services.ts";
|
import { services } from "./services.ts";
|
||||||
import { initializeSearch } from "../../components/common/searchUtils.js";
|
import { initializeSearch } from "../../components/common/searchUtils.js";
|
||||||
---
|
---
|
||||||
@ -30,7 +28,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
|
|||||||
<!-- Search functionality is provided by search-client.js -->
|
<!-- Search functionality is provided by search-client.js -->
|
||||||
|
|
||||||
<Section class="my-8">
|
<Section class="my-8">
|
||||||
<div x-data="searchServices" x-init="init()" x-cloak>
|
<div x-data="searchServices" x-init="init()">
|
||||||
<!-- Search container - positioned at the top -->
|
<!-- Search container - positioned at the top -->
|
||||||
<div class="mb-4 pt-0">
|
<div class="mb-4 pt-0">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@ -55,38 +53,8 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
|
|||||||
<p class="text-xl font-semibold zag-text">No Results</p>
|
<p class="text-xl font-semibold zag-text">No Results</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
<!-- Service categories -->
|
||||||
<div
|
<div id="app-list">
|
||||||
x-show="loading"
|
|
||||||
x-transition:enter="transition ease-out duration-300"
|
|
||||||
x-transition:enter-start="opacity-0"
|
|
||||||
x-transition:enter-end="opacity-100"
|
|
||||||
x-transition:leave="transition ease-in duration-200"
|
|
||||||
x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0"
|
|
||||||
>
|
|
||||||
{Object.entries(services).map(([category, apps]) => (
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-xl font-semibold mb-4 w-full text-left flex items-center justify-between">
|
|
||||||
<SkeletonLoader type="title" width="40%" height="1.5rem" />
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
||||||
{Array.from({ length: apps.length || 4 }).map((_, i) => (
|
|
||||||
<ServiceCardSkeleton />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Service categories (actual content) -->
|
|
||||||
<div
|
|
||||||
id="app-list"
|
|
||||||
x-show="!loading"
|
|
||||||
x-transition:enter="transition ease-out duration-300"
|
|
||||||
x-transition:enter-start="opacity-0"
|
|
||||||
x-transition:enter-end="opacity-100"
|
|
||||||
>
|
|
||||||
{Object.entries(services).map(([category, apps]) => (
|
{Object.entries(services).map(([category, apps]) => (
|
||||||
<CategorySection category={category} apps={apps} />
|
<CategorySection category={category} apps={apps} />
|
||||||
))}
|
))}
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import { projects } from "../../lib/list";
|
import { projects } from "../../lib/list";
|
||||||
import Section from "../../components/common/Section.astro";
|
import Section from "../../components/common/Section.astro";
|
||||||
import ProjectSnippet from "../../components/ProjectSnippet.astro";
|
import ProjectSnippet from "../../components/ProjectSnippet.astro";
|
||||||
import ProjectSnippetSkeleton from "../../components/ProjectSnippetSkeleton.astro";
|
|
||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import { GLOBAL } from "../../lib/variables";
|
import { GLOBAL } from "../../lib/variables";
|
||||||
@ -38,7 +37,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
|
|||||||
<!-- Search functionality is provided by search-client.js -->
|
<!-- Search functionality is provided by search-client.js -->
|
||||||
|
|
||||||
<Section class="py-4 my-8">
|
<Section class="py-4 my-8">
|
||||||
<div x-data="searchProjects" x-init="init()" x-cloak>
|
<div x-data="searchProjects" x-init="init()">
|
||||||
<!-- Search container - positioned at the top -->
|
<!-- Search container - positioned at the top -->
|
||||||
<div class="mb-4 pt-0">
|
<div class="mb-4 pt-0">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@ -62,29 +61,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
|
|||||||
<p class="text-xl font-semibold zag-text">No Projects Found</p>
|
<p class="text-xl font-semibold zag-text">No Projects Found</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
<ul id="project-list">
|
||||||
<div
|
|
||||||
x-show="loading"
|
|
||||||
x-transition:enter="transition ease-out duration-300"
|
|
||||||
x-transition:enter-start="opacity-0"
|
|
||||||
x-transition:enter-end="opacity-100"
|
|
||||||
x-transition:leave="transition ease-in duration-200"
|
|
||||||
x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<ProjectSnippetSkeleton />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actual content -->
|
|
||||||
<ul
|
|
||||||
id="project-list"
|
|
||||||
x-show="!loading"
|
|
||||||
x-transition:enter="transition ease-out duration-300"
|
|
||||||
x-transition:enter-start="opacity-0"
|
|
||||||
x-transition:enter-end="opacity-100"
|
|
||||||
>
|
|
||||||
{
|
{
|
||||||
projects.map((project) => {
|
projects.map((project) => {
|
||||||
const projectTags = project.tags ? project.tags.join(' ').toLowerCase() : '';
|
const projectTags = project.tags ? project.tags.join(' ').toLowerCase() : '';
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
layout: ../../layouts/ProjectLayout.astro
|
layout: ../../layouts/ProjectLayout.astro
|
||||||
title: This Website
|
title: This Website
|
||||||
description: My personal blog, portfolio, and homelab dashboard built with Astro, TypeScript, TailwindCSS, and Alpine.js featuring smart loading, advanced search, and accessibility features.
|
description: My personal blog and portfolio, built using TypeScript, TailwindCSS, and Astro.
|
||||||
tags: ["astro", "typescript", "tailwindcss", "alpinejs", "responsive-design", "accessibility", "dark-mode", "gruvbox-theme"]
|
tags: ["astro", "typescript", "web-development"]
|
||||||
githubUrl: https://code.justin.deal
|
githubUrl: https://code.justin.deal
|
||||||
timestamp: 2025-02-24T02:39:03+00:00
|
timestamp: 2025-02-24T02:39:03+00:00
|
||||||
featured: true
|
featured: true
|
||||||
@ -11,59 +11,17 @@ filename: zaggonaut
|
|||||||
|
|
||||||
## The Details
|
## The Details
|
||||||
|
|
||||||
This website serves as my personal digital space, combining a blog, project portfolio, and homelab dashboard in one cohesive experience. Built on Astro for performance and flexibility, it leverages TypeScript for type safety, TailwindCSS for styling, and Alpine.js for interactive components.
|
Zaggonaut is a retro-inspired theme for Astro, built using TypeScript, TailwindCSS, and Astro. Use this theme to power your own personal website, blog, or portfolio with flexibility and customization.
|
||||||
|
|
||||||
The architecture follows a component-based approach with a focus on reusability and maintainability. The site features a custom Gruvbox-inspired theme system that supports both dark and light modes while maintaining accessibility standards. The responsive design ensures a seamless experience across all device sizes.
|
|
||||||
|
|
||||||
## The Features
|
## The Features
|
||||||
|
|
||||||
### Content & Navigation
|
- Dark & light mode
|
||||||
- **Multi-purpose platform**: Blog, portfolio, and homelab dashboard integration
|
- Customizable colors
|
||||||
- **Dark & light mode** with smooth transitions and flash prevention
|
- 100 / 100 Lighthouse score
|
||||||
- **Gruvbox-inspired color scheme** with customizable theme variables
|
- Fully accessible
|
||||||
- **Fully responsive design** optimized for all screen sizes
|
- Fully responsive
|
||||||
|
- Type-safe
|
||||||
|
|
||||||
### Performance & User Experience
|
## The Future
|
||||||
- **Smart loading system**:
|
|
||||||
- Connection-aware loading states that adapt to network speed
|
|
||||||
- Skeleton loaders that match content layout during loading
|
|
||||||
- Extended loading fallback UI for slow connections
|
|
||||||
- Navigation API integration for accurate loading states
|
|
||||||
|
|
||||||
- **Advanced search functionality**:
|
Check out [the theme website](https://zaggonaut.dev) to see it in action!
|
||||||
- Real-time content filtering across all sections
|
|
||||||
- Keyboard navigation with arrow keys and shortcuts
|
|
||||||
- Category-aware filtering for structured content
|
|
||||||
- Accessible search results with screen reader support
|
|
||||||
|
|
||||||
### Technical Implementation
|
|
||||||
- **Component architecture**:
|
|
||||||
- Reusable UI components with TypeScript interfaces
|
|
||||||
- Consistent styling patterns using CSS custom properties
|
|
||||||
- Modular structure for easy maintenance and extension
|
|
||||||
|
|
||||||
- **Accessibility features**:
|
|
||||||
- ARIA attributes for screen reader compatibility
|
|
||||||
- Keyboard navigation throughout the site
|
|
||||||
- Reduced motion support for animations
|
|
||||||
- High contrast theme options
|
|
||||||
|
|
||||||
- **Performance optimizations**:
|
|
||||||
- Optimized page transitions with minimal flicker
|
|
||||||
- Network-aware resource loading
|
|
||||||
- Efficient DOM updates using Alpine.js
|
|
||||||
- Astro's static site generation for fast initial loads
|
|
||||||
|
|
||||||
### Homelab Integration
|
|
||||||
- **Self-hosted services dashboard** with categorized listings
|
|
||||||
- **Service search and filtering** by name, category, and tags
|
|
||||||
- **Visual indicators** for service status and information
|
|
||||||
|
|
||||||
## The Technology Stack
|
|
||||||
|
|
||||||
- **Framework**: Astro 5.6
|
|
||||||
- **Languages**: TypeScript, JavaScript
|
|
||||||
- **Styling**: TailwindCSS 4.1, Custom CSS Variables
|
|
||||||
- **Interactivity**: Alpine.js
|
|
||||||
- **Build Tools**: Vite, npm/pnpm
|
|
||||||
- **Deployment**: Self-hosted
|
|
@ -13,17 +13,6 @@ html.theme-loaded body {
|
|||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide elements with x-cloak until Alpine.js is loaded */
|
|
||||||
[x-cloak] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add keyboard focus styling for keyboard navigation */
|
|
||||||
.keyboard-focus {
|
|
||||||
outline: 2px solid var(--color-zag-accent-dark);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Literata Variable";
|
font-family: "Literata Variable";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user