| ---
|
| import { Picture } from "astro:assets";
|
| import * as path from "node:path";
|
| import type { ImageMetadata } from "astro";
|
| import { coverImageConfig } from "@/config/coverImageConfig";
|
| import type { ImageFormat, ResponsiveImageLayout } from "@/types/config";
|
| import {
|
| getFallbackFormat,
|
| getImageFormats,
|
| getImageQuality,
|
| } from "@/utils/image-utils";
|
| import { url } from "@/utils/url-utils";
|
|
|
| interface Props {
|
| id?: string;
|
| src: string;
|
| class?: string;
|
| alt?: string;
|
| position?: string;
|
| basePath?: string;
|
| preview?: boolean;
|
| layout?: ResponsiveImageLayout;
|
| formats?: ImageFormat[];
|
| loading?: "lazy" | "eager";
|
| }
|
|
|
| const {
|
| id,
|
| src,
|
| alt,
|
| position = "center",
|
| basePath = "/",
|
| preview = false,
|
| layout = "constrained",
|
| formats = getImageFormats(),
|
| loading = "lazy",
|
| } = Astro.props;
|
|
|
| const configQuality = getImageQuality();
|
| const fallbackFormat = getFallbackFormat();
|
| const className = Astro.props.class;
|
|
|
|
|
| const isLocal = !(
|
| src.startsWith("/") ||
|
| src.startsWith("http") ||
|
| src.startsWith("https") ||
|
| src.startsWith("data:")
|
| );
|
| const isPublic = src.startsWith("/");
|
|
|
|
|
| let img: ImageMetadata | null = null;
|
| if (isLocal) {
|
| const files = import.meta.glob<ImageMetadata>(
|
| "../../**/*.{png,jpg,jpeg,webp,avif}",
|
| {
|
| import: "default",
|
| },
|
| );
|
| const normalizedPath = path
|
| .normalize(path.join("../../", basePath, src))
|
| .replace(/\\/g, "/");
|
| const file = files[normalizedPath];
|
| if (file) {
|
| img = await file();
|
| } else {
|
| console.error(
|
| `[ERROR] Image not found: ${normalizedPath.replace("../../", "src/")}`,
|
| );
|
| }
|
| }
|
|
|
|
|
| let fallbackImg: ImageMetadata | null = null;
|
| const fallbackPath = coverImageConfig.randomCoverImage.fallback;
|
| if (fallbackPath && !isLocal) {
|
| const files = import.meta.glob<ImageMetadata>(
|
| "../../**/*.{png,jpg,jpeg,webp,avif}",
|
| {
|
| import: "default",
|
| },
|
| );
|
| const normalizedFallbackPath = path
|
| .normalize(path.join("../../", fallbackPath))
|
| .replace(/\\/g, "/");
|
| const file = files[normalizedFallbackPath];
|
| if (file) {
|
| fallbackImg = await file();
|
| }
|
| }
|
|
|
|
|
| const imageClass = "w-full h-full object-cover";
|
| const imageStyle = `object-position: ${position};`;
|
|
|
|
|
| const widths = preview ? [320, 480, 640, 960] : [800, 1200, 1600, 2000];
|
| const sizes = preview
|
| ? "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 320px"
|
| : "(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px";
|
| const quality = preview ? Math.round(configQuality * 0.9) : configQuality;
|
|
|
|
|
| const showLoading = coverImageConfig.randomCoverImage.showLoading ?? true;
|
| ---
|
|
|
| <div
|
| id={id}
|
| class:list={[className, "cover-image-container overflow-hidden relative"]}
|
| data-loading={showLoading ? "true" : "false"}
|
| >
|
|
|
| {showLoading && (
|
| <div class="loading-spinner absolute inset-0 flex items-center justify-center z-10" style="background-color: var(--card-bg);">
|
| <div class="spinner"></div>
|
| </div>
|
| )}
|
|
|
|
|
| <div class="error-message absolute inset-0 flex items-center justify-center z-20 hidden pointer-events-none">
|
| <span class="text-white text-sm px-3 py-1 rounded bg-black/50">Image API Error</span>
|
| </div>
|
|
|
|
|
| {fallbackImg && (
|
| <div class="fallback-image absolute inset-0 hidden">
|
| <Picture
|
| src={fallbackImg}
|
| alt="Fallback cover"
|
| class={imageClass}
|
| style={imageStyle}
|
| width={preview ? 400 : 1200}
|
| height={preview ? 300 : 800}
|
| loading="lazy"
|
| formats={formats}
|
| fallbackFormat={fallbackFormat}
|
| quality={quality}
|
| widths={widths}
|
| sizes={sizes}
|
| layout={layout}
|
| />
|
| </div>
|
| )}
|
|
|
|
|
| {isLocal && img && (
|
| <Picture
|
| src={img}
|
| alt={alt || ""}
|
| class={imageClass}
|
| style={imageStyle}
|
| width={preview ? 400 : 1200}
|
| height={preview ? 300 : 800}
|
| loading={loading}
|
| formats={formats}
|
| fallbackFormat={fallbackFormat}
|
| quality={quality}
|
| widths={widths}
|
| sizes={sizes}
|
| layout={layout}
|
| data-cover-img
|
| />
|
| )}
|
|
|
|
|
| {!isLocal && (
|
| <img
|
| src={isPublic ? url(src) : src}
|
| alt={alt || ""}
|
| class={imageClass}
|
| style={imageStyle}
|
| loading={loading}
|
| decoding="async"
|
| data-cover-img
|
| data-remote="true"
|
| />
|
| )}
|
| </div>
|
|
|
| <style>
|
| .cover-image-container {
|
| min-height: 150px;
|
| }
|
|
|
| @media (min-width: 768px) {
|
| .cover-image-container {
|
| min-height: 0;
|
| }
|
| }
|
|
|
|
|
| .loading-spinner {
|
| transition: opacity 0.3s ease-out;
|
| }
|
|
|
|
|
| .cover-image-container[data-loading="false"] .loading-spinner {
|
| opacity: 0;
|
| pointer-events: none;
|
| }
|
|
|
|
|
| .cover-image-container[data-error="true"] .loading-spinner {
|
| display: none;
|
| }
|
|
|
|
|
| .cover-image-container[data-error="true"] .error-message {
|
| display: flex;
|
| }
|
|
|
| .cover-image-container[data-error="true"] .fallback-image {
|
| display: block;
|
| }
|
|
|
|
|
| .cover-image-container[data-error="true"] img[data-remote="true"] {
|
| display: none;
|
| }
|
|
|
|
|
| .spinner {
|
| width: 40px;
|
| height: 40px;
|
| border: 3px solid oklch(0.9 0.05 var(--hue));
|
| border-top-color: oklch(0.6 0.15 var(--hue));
|
| border-radius: 50%;
|
| animation: spin 0.8s linear infinite;
|
| }
|
|
|
| @keyframes spin {
|
| to {
|
| transform: rotate(360deg);
|
| }
|
| }
|
|
|
|
|
| .cover-image-container img {
|
| width: 100%;
|
| height: 100%;
|
| object-fit: cover;
|
| }
|
| </style>
|
|
|
| <script>
|
| function initCoverImages() {
|
|
|
| const containers = document.querySelectorAll('.cover-image-container');
|
| containers.forEach((container) => {
|
|
|
| if (container.hasAttribute('data-initialized')) return;
|
| container.setAttribute('data-initialized', 'true');
|
|
|
| const img = container.querySelector('img[data-cover-img]') as HTMLImageElement | null;
|
| if (!img) return;
|
|
|
| const hideLoading = () => {
|
| container.setAttribute('data-loading', 'false');
|
| };
|
|
|
| const showError = () => {
|
| container.setAttribute('data-loading', 'false');
|
| container.setAttribute('data-error', 'true');
|
| };
|
|
|
| if (img.complete) {
|
| if (img.naturalWidth > 0) {
|
| hideLoading();
|
| } else if (img.dataset.remote === 'true') {
|
| showError();
|
| }
|
| } else {
|
| img.addEventListener('load', hideLoading, { once: true });
|
| img.addEventListener('error', () => {
|
| if (img.dataset.remote === 'true') {
|
| showError();
|
| }
|
| }, { once: true });
|
| }
|
| });
|
| }
|
|
|
|
|
| initCoverImages();
|
|
|
|
|
| document.addEventListener('astro:page-load', initCoverImages);
|
| </script>
|
|
|