想着既然选择 Astro,就得写一些有自己风格的东西,思来想去,写个时间盒子吧!
其实就是一个展示页面啦,在本站你可以看到展示效果。
[!NOTE] 参考于友链页面,借鉴了一下写法
具体步骤 在 src\content\spec
目录下新建文件 chronobox.md
。名字随意哈 在 src\types\config.ts
文件内添加以下内容
title export enum LinkPreset { Home = 0 , Archive = 1 , About = 2 , Friends = 3 , Series = 4 , ChronoBox = 5 , }
在 src\i18n\i18nKey.ts
文件内添加以下内容
title author = "author" , publishedAt = "publishedAt" , license = "license" , friends = "friends" , series = "series" , chronobox = "chronobox" ,
然后在对应的语言文件里继续添加 key,不多赘述 在 src\constants\link-presets.ts
文件内添加以下内容
title [LinkPreset .Series ]: { name : i18n (I18nKey.series ), url : "/series/" , }, [LinkPreset .ChronoBox ]: { name : i18n (I18nKey.chronobox ), url : "/chronobox/" , },
在 src\components
目录下,选择合适的位置,新建三个卡片组件
AnimeList. astro 组件 新建 AnimeList.astro
组件
title --- // AnimeList.astro export interface Props { timelineItems: { type: string; title: string; cover: string; date: string; desc?: string; url: string; episodes: string | number; rating: number; tags: string[]; }[]; } const { timelineItems } = Astro.props; --- <div class ="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-6" > {timelineItems .filter(item => item.type === 'anime') .map((item) => ( <div class ="anime-card group relative overflow-hidden rounded-lg shadow-md transition-shadow" > <div class ="anime-cover-container w-full bg-gray-200" > <img src ={item.cover} alt ={item.title} class ="w-full h-full object-fill transition-transform group-hover:scale-105" /> </div > <div class ="absolute bottom-0 left-0 right-0 h-0 overflow-hidden transition-all group-hover:h-40" > <div class ="bg-gray-500/20 dark:bg-gray-800/40 p-4 flex flex-col justify-end h-full rounded-t-lg backdrop-blur-[3px]" > <h3 class ="title text-white text-base font-semibold mb-1 line-clamp-2" > {item.title} </h3 > <time class ="date text-white/90 text-xs mb-2" > {new Date(item.date).toLocaleDateString()} </time > <div class ="flex justify-between items-center" > <div class ="flex items-center gap-1.5 text-white/90" > <svg class ="w-3.5 h-3.5 text-primary" fill ="none" stroke ="currentColor" viewBox ="0 0 24 24" > <path stroke-linecap ="round" stroke-linejoin ="round" stroke-width ="2" d ="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" > </path > </svg > <span class ="text-xs font-medium" > {item.episodes}</span > </div > <div class ="flex items-center gap-1 text-white/90" > ⭐ <span class ="text-xs font-medium" > {item.rating.toFixed(1)}分</span > </div > </div > <div class ="tag-cloud flex flex-wrap gap-1.5 mt-2" > {item.tags.map(tag => ( <span class ="tag bg-white/25 dark:bg-black/25 px-3 py-1 rounded-full text-xs font-medium" > {tag} </span > ))} </div > </div > </div > <a href ={item.url} class ="absolute inset-0" target ="_blank" rel ="noopener noreferrer" > </a > </div > ))} </div > <style > .anime-card { @apply transition-shadow duration-300 ; transform : perspective (1000px ); will-change : transform, box-shadow; } .anime-card :hover { @apply shadow-2 xl; transform : scale (1.02 ); } .anime-cover-container { aspect-ratio: 3 /4 ; position : absolute; top : 0 ; left : 0 ; width : 100% ; height : 100% ; } .tag { color : white; } </style >
MusicList. astro 组件 新建 MusicList.astro
组件
title --- // MusicList.astro export interface Props { timelineItems: { type: string; title: string; artist: string; cover: string; album: string; date: string; platform: { [key: string]: string; }; }[]; } const { timelineItems } = Astro.props; --- <div class ="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-6" > {timelineItems .filter(item => item.type ==='music') .map((item) => ( <div class ="music-card group relative overflow-hidden rounded-2xl shadow-md transition-transform duration-300 hover:shadow-lg" > <div class ="aspect-[1/1] w-full bg-gray-200 relative" > <img src ={item.cover} alt ={ `${item.title } - ${item.artist }`} class ="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" /> <div class ="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-black/30" > <svg class ="w-12 h-12 text-white/90 drop-shadow-lg" fill ="none" stroke ="currentColor" viewBox ="0 0 24 24" > <path stroke-linecap ="round" stroke-linejoin ="round" stroke-width ="2" d ="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /> </svg > </div > </div > <div class ="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent transition-all duration-300" > <h3 class ="text-white font-semibold truncate" > {item.title}</h3 > <p class ="text-white font-semibold truncate " > {item.artist}</p > <div class ="hidden group-hover:block" > <p class ="text-gray-300 text-250 mt-1" > {item.album} • {new Date(item.date).getFullYear()}</p > <div class ="flex gap-2 mt-3" > {Object.entries(item.platform).map(([platform, link]) => ( link && ( <a href ={link} target ="_blank" rel ="noopener noreferrer" class ="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/10 hover:bg-white/20 transition-colors" > {/* 内联Spotify图标 */} {platform ==='spotify' && ( <svg class ="w-4 h-4" fill ="none" stroke ="currentColor" viewBox ="0 0 24 24" > <path d ="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.6 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.56 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" /> </svg > )} {/* 内联网易云图标 */} {platform === 'netease' && ( <svg class ="w-4 h-4" fill ="none" stroke ="currentColor" viewBox ="0 0 24 24" > <path stroke-linecap ="round" stroke-linejoin ="round" stroke-width ="2" d ="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8zm3.5-11.5h-7c-.276 0-.5.224-.5.5v7c0 .276.224.5.5.5h7c.276 0 .5-.224.5-.5v-7c0-.276-.224-.5-.5-.5z" /> </svg > )} {/* 内联QQ音乐图标 */} {platform === 'qqmusic' && ( <svg class ="w-4 h-4" fill ="none" stroke ="currentColor" viewBox ="0 0 24 24" > <path stroke-linecap ="round" stroke-linejoin ="round" stroke-width ="2" d ="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8zm-2.5-8h5c.276 0 .5-.224.5-.5v-4c0-.276-.224-.5-.5-.5h-5c-.276 0-.5.224-.5.5v4c0 .276.224.5.5.5z" /> </svg > )} <span class ="text-white/90 text-xs" > {platform ==='spotify' ? 'Spotify' : platform === 'netease' ? '网易云音乐' : 'QQ音乐'} </span > </a > ) ))} </div > </div > </div > </div > ))} </div >
PhotoList.astro组件 新建 PhotoList.astro
组件
title --- // PhotoList.astro export interface Props { timelineItems: { type: string; title: string; image: string; date: string; location: string; camera: string; tags: string[]; }[]; } const { timelineItems } = Astro.props; --- <div class ="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4" > {timelineItems .filter(item => item.type === 'photo') .map((item) => ( <div class ="photo-card relative overflow-hidden rounded-xl shadow-lg transition-shadow duration-300 group cursor-pointer" data-image ={item.image} data-title ={item.title} data-camera ={item.camera} data-tags ={JSON.stringify(item.tags)} > <div class ="aspect-w-2 aspect-h-3 w-full" > <img src ={item.image} alt ={item.title} class ="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" /> </div > <div class ="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4" > <div class ="flex flex-col justify-end h-full" > <h3 class ="text-white text-sm font-semibold mb-1" > {item.title}</h3 > <div class ="flex items-center gap-1 text-gray-300 text-xs" > <svg class ="w-3 h-3" fill ="none" stroke ="currentColor" viewBox ="0 0 24 24" > <path stroke-linecap ="round" stroke-linejoin ="round" stroke-width ="2" d ="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" > </path > <path stroke-linecap ="round" stroke-linejoin ="round" stroke-width ="2" d ="M15 11a3 3 0 11-6 0 3 3 0 016 0z" > </path > </svg > <span > {item.location}</span > </div > <time class ="text-gray-400 text-xs" > {new Date(item.date).toLocaleDateString()}</time > </div > <div class ="absolute bottom-4 right-4 flex flex-col items-end space-y-2 opacity-0 pointer-events-none transition-opacity duration-300 group-hover:opacity-100 group-hover:pointer-events-auto" > <div class ="flex items-center gap-1.5 text-white/80 text-xs" > <svg class ="w-3 h-3" fill ="none" stroke ="currentColor" viewBox ="0 0 24 24" > <path stroke-linecap ="round" stroke-linejoin ="round" stroke-width ="2" d ="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" > </path > <path stroke-linecap ="round" stroke-linejoin ="round" stroke-width ="2" d ="M15 13a3 3 0 11-6 0 3 3 0 016 0z" > </path > </svg > <span > {item.camera}</span > </div > <div class ="flex flex-wrap gap-2 justify-end" > {item.tags.map(tag => ( <span class ="px-2 py-1 bg-white/10 backdrop-blur-sm rounded-full text-white/90 text-xs font-medium transition-colors hover:bg-white/20" > {tag} </span > ))} </div > </div > </div > </div > ))} </div > <script is:inline > (function ( ) { function openModal (imageUrl, title, camera, tags ) { let modal = document .getElementById ('modal' ); if (!modal) { modal = createModalElement (); document .body .appendChild (modal); setupGlobalListeners (); } document .getElementById ('modal-image' ).src = imageUrl; document .getElementById ('modal-title' ).textContent = title; document .getElementById ('modal-camera' ).textContent = camera; const tagsContainer = document .getElementById ('modal-tags' ); tagsContainer.innerHTML = tags.map (tag => ` <span class="px-3 py-1 bg-white rounded-full text-sm shadow-sm border" style="background:var(--btn-regular-bg-active);"> ${tag} </span> ` ).join ('' ); modal.classList .remove ('hidden' ); document .body .style .overflow = 'hidden' ; } function createModalElement ( ) { const modal = document .createElement ('div' ); modal.id = 'modal' ; modal.className = 'fixed inset-0 bg-black/80 backdrop-blur-sm hidden flex items-center justify-center z-50' ; const rootStyles = getComputedStyle (document .documentElement ); const primaryColor = rootStyles.getPropertyValue ('--primary' ).trim (); modal.innerHTML = ` <div class="rounded-xl max-w-6xl w-full md:max-w-5xl lg:max-w-4xl max-h-[90vh] overflow-y-auto overflow-x-hidden shadow-2xl relative" style="background:var(--page-bg);"> <button id="modal-close-btn" class="absolute top-4 right-4 text-white bg-black/20 hover:bg-black/30 rounded-full p-2 transition-colors backdrop-blur-sm z-10" > <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> </svg> </button> <div class="p-6 border-b border-gray-200"> <h2 id="modal-title" class="text-2xl font-bold text-gray-900 mb-2" style="color: ${primaryColor} ;"></h2> <div class="flex items-center gap-4 text-gray-600"> <div class="flex items-center gap-1"> <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /> </svg> <span id="modal-camera"></span> </div> <div id="modal-tags" class="flex flex-wrap gap-2"></div> </div> </div> <div class="relative p-4"> <img id="modal-image" class="w-full h-auto max-h-[70vh] object-contain" loading="lazy" /> </div> </div> ` ; return modal; } function closeModal ( ) { const modal = document .getElementById ('modal' ); if (modal) { modal.classList .add ('hidden' ); document .body .style .overflow = '' ; } } function setupGlobalListeners ( ) { document .getElementById ('modal-close-btn' )?.addEventListener ('click' , closeModal); document .getElementById ('modal' )?.addEventListener ('click' , (e ) => { if (e.target === document .getElementById ('modal' )) { closeModal (); } }); document .addEventListener ('keydown' , (e ) => { if (e.key === 'Escape' && !document .getElementById ('modal' )?.classList .contains ('hidden' )) { closeModal (); } }); } document .addEventListener ('click' , (e ) => { const card = e.target .closest ('.photo-card' ); if (card) { e.preventDefault (); openModal ( card.dataset .image , card.dataset .title , card.dataset .camera , JSON .parse (card.dataset .tags ) ); } }); })(); </script > <style > .photo-card { @apply relative overflow-hidden rounded-xl shadow-lg transition-shadow duration-300 ; } .photo-card img { @apply w-full h-full object-cover transition-transform duration-300 group-hover :scale-105 ; } .photo-card .right-info { opacity : 0 ; pointer-events : none; transition : opacity 0.3s ease; } .photo-card :hover .right-info { opacity : 1 ; pointer-events : auto; } @media (min-width : 640px ) { .grid-cols-2 { grid-template-columns : repeat (2 , 1 fr); } .md :grid-cols-3 { grid-template-columns : repeat (3 , 1 fr); } .lg :grid-cols-4 { grid-template-columns : repeat (4 , 1 fr); } } #modal :not (.hidden ) { display : flex; align-items : center; justify-content : center; } #modal .hidden { display : none !important ; } #modal-title { color : var (--primary)!important ; @media (max-width : 768px ) { font-size : 1.25rem !important ; } } #modal > div { margin : auto; } </style >
父组件 在 src\pages
目录下,新建一个 chronobox.astro
文件 ```html title=”chronobox.astro” import { getEntry } from “astro:content”; import AnimeList from “@/components/mine/Lists/AnimeList.astro”; import MusicList from “@/components/mine/Lists/MusicList.astro”; import PhotoList from “@/components/mine/Lists/PhotoList.astro”; import I18nKey from “@/i18n/i18nKey”; import { i18n } from “@/i18n/translation”; import MainGridLayout from “@/layouts/MainGridLayout.astro”;
import Markdown from “@components/misc/Markdown.astro”; const chronoboxPost = await getEntry(“spec”, “chronobox”); const { Content } = await chronoboxPost.render();