mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-16 18:55:37 +02:00
✨ feat: Add qwik-react (#103)
This adds the following pages: The landing page (/) The pricing page (/pricing) The contact page (/contact) The changelog page (/changelog) Terms Of Service page (/terms) Privacy Policy (/privacy)
This commit is contained in:
105
packages/ui/src/image/basic-image-loader.tsx
Normal file
105
packages/ui/src/image/basic-image-loader.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/* eslint-disable qwik/no-use-visible-task */
|
||||
import { cn } from '@/design';
|
||||
import { component$, useSignal, useTask$, useStyles$, useVisibleTask$, $ } from '@builder.io/qwik';
|
||||
|
||||
interface ImageLoaderProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const BasicImageLoader = component$((props: ImageLoaderProps) => {
|
||||
const imageLoaded = useSignal(false);
|
||||
const hasError = useSignal(false);
|
||||
const imgRef = useSignal<HTMLImageElement>();
|
||||
|
||||
useStyles$(`
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.loading-animation {
|
||||
animation: gradientShift 1.5s infinite linear;
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
`);
|
||||
|
||||
useTask$(({ track }) => {
|
||||
track(() => props.src);
|
||||
imageLoaded.value = false;
|
||||
hasError.value = false;
|
||||
});
|
||||
|
||||
|
||||
useVisibleTask$(async ({ cleanup }) => {
|
||||
const img = imgRef.value;
|
||||
if (!img) return;
|
||||
// const imageData = await imageGetter();
|
||||
|
||||
const checkImageLoaded = async () => {
|
||||
if (img.complete && img.naturalHeight !== 0) {
|
||||
imageLoaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately in case the image is already loaded
|
||||
await checkImageLoaded();
|
||||
|
||||
// Set up event listeners
|
||||
const loadHandler = async () => {
|
||||
imageLoaded.value = true;
|
||||
};
|
||||
const errorHandler = () => {
|
||||
hasError.value = true;
|
||||
};
|
||||
|
||||
img.addEventListener('load', loadHandler);
|
||||
img.addEventListener('error', errorHandler);
|
||||
|
||||
// Use MutationObserver to detect src changes
|
||||
const observer = new MutationObserver(checkImageLoaded);
|
||||
observer.observe(img, { attributes: true, attributeFilter: ['src'] });
|
||||
|
||||
cleanup(() => {
|
||||
img.removeEventListener('load', loadHandler);
|
||||
img.removeEventListener('error', errorHandler);
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{!imageLoaded.value && !hasError.value && (
|
||||
<div
|
||||
class={cn("relative x-[20] inset-0 h-full loading-animation bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-800 dark:via-gray-900 dark:to-gray-800", props.class)}
|
||||
style={{
|
||||
height: props.height,
|
||||
aspectRatio: props.width && props.height ? `${props.width} / ${props.height}` : 'auto'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={props.src}
|
||||
draggable={false}
|
||||
alt={props.alt}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
ref={imgRef}
|
||||
class={{
|
||||
'z-[5] relative': imageLoaded.value,
|
||||
'hidden': !imageLoaded.value && !hasError.value,
|
||||
'w-full h-full': imageLoaded.value,
|
||||
'w-16 h-16 text-red-500': hasError.value,
|
||||
[props.class || '']: !!props.class,
|
||||
}}
|
||||
/>
|
||||
{hasError.value && (
|
||||
<p class="text-red-500 text-sm" >
|
||||
Error loading image
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
195
packages/ui/src/image/image-loader.tsx
Normal file
195
packages/ui/src/image/image-loader.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/* eslint-disable qwik/no-use-visible-task */
|
||||
import { cn } from '@/design';
|
||||
import { component$, useSignal, useTask$, useStyles$, useVisibleTask$, $ } from '@builder.io/qwik';
|
||||
|
||||
interface ImageLoaderProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
interface Color {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export const ImageLoader = component$((props: ImageLoaderProps) => {
|
||||
const imageLoaded = useSignal(false);
|
||||
const hasError = useSignal(false);
|
||||
const imgRef = useSignal<HTMLImageElement>();
|
||||
const shadowColor = useSignal<string>('');
|
||||
const imageUrl = `http://localhost:8787/image/cover/${props.src}.avif?width=${props.width}&height=${props.height}&quality=100`;
|
||||
|
||||
useStyles$(`
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.loading-animation {
|
||||
animation: gradientShift 1.5s infinite linear;
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
`);
|
||||
|
||||
useTask$(({ track }) => {
|
||||
track(() => props.src);
|
||||
imageLoaded.value = false;
|
||||
hasError.value = false;
|
||||
shadowColor.value = '';
|
||||
});
|
||||
|
||||
const analyzeImage = $((img: HTMLImageElement) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
|
||||
img.crossOrigin = "anonymous"
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const sampleSize = 20;
|
||||
const colors: Color[] = [];
|
||||
|
||||
for (let x = 0; x < sampleSize; x++) {
|
||||
for (let y = 0; y < sampleSize; y++) {
|
||||
const px = Math.floor((x / sampleSize) * canvas.width);
|
||||
const py = Math.floor((y / sampleSize) * canvas.height);
|
||||
const pixelData = ctx.getImageData(px, py, 1, 1).data;
|
||||
colors.push({ r: pixelData[0], g: pixelData[1], b: pixelData[2] });
|
||||
}
|
||||
}
|
||||
|
||||
// Function to calculate color saturation
|
||||
const calculateSaturation = (color: Color) => {
|
||||
const max = Math.max(color.r, color.g, color.b);
|
||||
const min = Math.min(color.r, color.g, color.b);
|
||||
return max === 0 ? 0 : (max - min) / max;
|
||||
};
|
||||
|
||||
// Function to calculate color brightness
|
||||
const calculateBrightness = (color: Color) => {
|
||||
return (color.r * 299 + color.g * 587 + color.b * 114) / 1000;
|
||||
};
|
||||
|
||||
// Find the color with high saturation and brightness
|
||||
const vibrantColor = colors.reduce((mostVibrant, color) => {
|
||||
const saturation = calculateSaturation(color);
|
||||
const brightness = calculateBrightness(color);
|
||||
const currentSaturation = calculateSaturation(mostVibrant);
|
||||
const currentBrightness = calculateBrightness(mostVibrant);
|
||||
|
||||
// Prefer colors with high saturation and high brightness
|
||||
if (saturation > 0.5 && brightness > 100 && (saturation + brightness * 0.01) > (currentSaturation + currentBrightness * 0.01)) {
|
||||
return color;
|
||||
}
|
||||
return mostVibrant;
|
||||
}, colors[0]);
|
||||
|
||||
// Increase the brightness of the selected color
|
||||
const enhancedColor = {
|
||||
r: Math.min(255, vibrantColor.r * 1.2),
|
||||
g: Math.min(255, vibrantColor.g * 1.2),
|
||||
b: Math.min(255, vibrantColor.b * 1.2)
|
||||
};
|
||||
|
||||
shadowColor.value = `rgb(${Math.round(enhancedColor.r)},${Math.round(enhancedColor.g)},${Math.round(enhancedColor.b)})`;
|
||||
});
|
||||
|
||||
useVisibleTask$(async ({ cleanup }) => {
|
||||
const img = imgRef.value;
|
||||
if (!img) return;
|
||||
// const imageData = await imageGetter();
|
||||
|
||||
const checkImageLoaded = async () => {
|
||||
if (img.complete && img.naturalHeight !== 0) {
|
||||
imageLoaded.value = true;
|
||||
await analyzeImage(img);
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately in case the image is already loaded
|
||||
await checkImageLoaded();
|
||||
|
||||
// Set up event listeners
|
||||
const loadHandler = async () => {
|
||||
imageLoaded.value = true;
|
||||
await analyzeImage(img);
|
||||
};
|
||||
const errorHandler = () => {
|
||||
hasError.value = true;
|
||||
};
|
||||
|
||||
img.addEventListener('load', loadHandler);
|
||||
img.addEventListener('error', errorHandler);
|
||||
|
||||
// Use MutationObserver to detect src changes
|
||||
const observer = new MutationObserver(checkImageLoaded);
|
||||
observer.observe(img, { attributes: true, attributeFilter: ['src'] });
|
||||
|
||||
cleanup(() => {
|
||||
img.removeEventListener('load', loadHandler);
|
||||
img.removeEventListener('error', errorHandler);
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: props.width ? `${props.width}px` : '100%',
|
||||
height: props.height ? `${props.height}px` : 'auto',
|
||||
"--shadow-color": shadowColor.value ? shadowColor.value : 'none',
|
||||
transition: 'box-shadow 0.3s ease-in-out',
|
||||
aspectRatio: props.width && props.height ? `${props.width} / ${props.height}` : 'auto'
|
||||
}}
|
||||
class={cn("relative overflow-hidden", props.class, "dark:shadow-[var(--shadow-color)]")}>
|
||||
{!imageLoaded.value && !hasError.value && (
|
||||
<div
|
||||
class={cn("relative x-[20] inset-0 h-full loading-animation bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-800 dark:via-gray-900 dark:to-gray-800", props.class)}
|
||||
style={{
|
||||
height: props.height,
|
||||
aspectRatio: props.width && props.height ? `${props.width} / ${props.height}` : 'auto'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* {imageLoaded.value && (
|
||||
<div
|
||||
class="dark:block hidden k w-full h-full absolute z-0 inset-0 blur-lg left-0 right-0 bottom-0 top-0 scale-105 opacity-50"
|
||||
style={{
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
)} */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
draggable={false}
|
||||
alt={props.alt}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
ref={imgRef}
|
||||
style={{
|
||||
transition: 'box-shadow 0.3s ease-in-out'
|
||||
}}
|
||||
class={{
|
||||
'z-[5] relative': imageLoaded.value,
|
||||
'hidden': !imageLoaded.value && !hasError.value,
|
||||
'w-full h-full': imageLoaded.value,
|
||||
'w-16 h-16 text-red-500': hasError.value,
|
||||
[props.class || '']: !!props.class,
|
||||
'dark:shadow-[var(--shadow-color)]': shadowColor.value
|
||||
}}
|
||||
/>
|
||||
{hasError.value && (
|
||||
<p class="text-red-500 text-sm" >
|
||||
Error loading image
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
77
packages/ui/src/image/image-prefetcher.ts
Normal file
77
packages/ui/src/image/image-prefetcher.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { $, useVisibleTask$ } from '@builder.io/qwik';
|
||||
|
||||
export const setupImageLoader = $(() => {
|
||||
const imageCache = new Map();
|
||||
|
||||
const loadImage = async (img: HTMLImageElement) => {
|
||||
const src = img.getAttribute('data-src');
|
||||
console.log('src', src);
|
||||
if (!src) return;
|
||||
|
||||
// Check if the image is already in the cache
|
||||
if (imageCache.has(src)) {
|
||||
img.src = imageCache.get(src);
|
||||
img.classList.add('loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the image is in the browser's cache
|
||||
const cache = await caches.open('image-cache');
|
||||
console.log('cache', cache);
|
||||
const cachedResponse = await cache.match(src);
|
||||
|
||||
if (cachedResponse) {
|
||||
const blob = await cachedResponse.blob();
|
||||
const objectURL = URL.createObjectURL(blob);
|
||||
img.src = objectURL;
|
||||
imageCache.set(src, objectURL);
|
||||
img.classList.add('loaded');
|
||||
} else {
|
||||
// If not in cache, load the image
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
const blob = await response.blob();
|
||||
const objectURL = URL.createObjectURL(blob);
|
||||
|
||||
img.src = objectURL;
|
||||
imageCache.set(src, objectURL);
|
||||
img.classList.add('loaded');
|
||||
|
||||
// Cache the image for future use
|
||||
cache.put(src, new Response(blob));
|
||||
} catch (error) {
|
||||
console.error('Error loading image:', error);
|
||||
img.classList.add('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
loadImage(entry.target as HTMLImageElement);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: '50px' }
|
||||
);
|
||||
|
||||
const setupImages = () => {
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
images.forEach((img) => {
|
||||
observer.observe(img);
|
||||
});
|
||||
};
|
||||
|
||||
return setupImages;
|
||||
});
|
||||
|
||||
export const useImageLoader = () => {
|
||||
// eslint-disable-next-line qwik/no-use-visible-task
|
||||
useVisibleTask$(async () => {
|
||||
const setup = await setupImageLoader();
|
||||
setup();
|
||||
});
|
||||
};
|
||||
2
packages/ui/src/image/index.ts
Normal file
2
packages/ui/src/image/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./image-loader.tsx"
|
||||
export * from "./basic-image-loader.tsx"
|
||||
Reference in New Issue
Block a user