Enable CMS-managed site images
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
---
|
||||
import { site } from '../lib/site-data.js';
|
||||
const { image = site.hero.image, imageAlt = site.hero.imageAlt } = Astro.props;
|
||||
---
|
||||
<section class="hero">
|
||||
<div class="shell hero-grid">
|
||||
<div class:list={['shell hero-grid', { 'hero-grid--text-only': !image }]}>
|
||||
<div class="hero-copy"><p class="eyebrow">{site.hero.eyebrow}</p><h1>{site.hero.headline}</h1><p class="lede">{site.hero.copy}</p><a class="button" href={site.hero.ctaHref}>{site.hero.cta} <span aria-hidden="true">→</span></a></div>
|
||||
<div class="hero-image"><img src={site.hero.image} alt={site.hero.imageAlt} fetchpriority="high" /><div class="image-note">Kind care. Every day.</div></div>
|
||||
{image && <div class="hero-image"><img src={image} alt={imageAlt} fetchpriority="high" /><div class="image-note">Kind care. Every day.</div></div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const { eyebrow, title, lede, image, imageAlt } = Astro.props;
|
||||
---
|
||||
<section class="page-intro">
|
||||
<div class="shell page-intro-grid">
|
||||
<div class:list={['shell page-intro-grid', { 'page-intro-grid--text-only': !image }]}>
|
||||
<div><p class="eyebrow">{eyebrow}</p><h1>{title}</h1><p class="lede">{lede}</p></div>
|
||||
{image && <img src={image} alt={imageAlt} loading="eager" />}
|
||||
</div>
|
||||
|
||||
+25
-2
@@ -1,6 +1,11 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const optionalCmsImage = z.preprocess(
|
||||
(value) => value === '' || value === null ? undefined : value,
|
||||
z.string().regex(/^\/images\/uploads\/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$/).optional(),
|
||||
);
|
||||
|
||||
const blog = defineCollection({
|
||||
loader: glob({ base: './sites/blog/content/posts', pattern: '**/*.md' }),
|
||||
schema: z.object({
|
||||
@@ -40,10 +45,28 @@ const team = defineCollection({
|
||||
schema: z.object({
|
||||
name: z.string().min(3).max(80),
|
||||
role: z.string().min(3).max(80),
|
||||
image: z.string().startsWith('/images/'),
|
||||
image: optionalCmsImage,
|
||||
order: z.number().int().min(0).max(99).default(0),
|
||||
draft: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog, faq, services, team };
|
||||
const pageImages = defineCollection({
|
||||
loader: glob({ base: './sites/www/content/page-images', pattern: '**/*.md' }),
|
||||
schema: z.object({
|
||||
homeHeroImage: optionalCmsImage,
|
||||
homeHeroImageAlt: z.string().min(8).max(180),
|
||||
homeStoryImage: optionalCmsImage,
|
||||
homeStoryImageAlt: z.string().min(8).max(180),
|
||||
aboutHeroImage: optionalCmsImage,
|
||||
aboutHeroImageAlt: z.string().min(8).max(180),
|
||||
aboutStoryImage: optionalCmsImage,
|
||||
aboutStoryImageAlt: z.string().min(8).max(180),
|
||||
servicesHeroImage: optionalCmsImage,
|
||||
servicesHeroImageAlt: z.string().min(8).max(180),
|
||||
contactHeroImage: optionalCmsImage,
|
||||
contactHeroImageAlt: z.string().min(8).max(180),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog, faq, services, team, pageImages };
|
||||
|
||||
@@ -6,5 +6,6 @@ import { isBlog, site } from '../lib/site-data.js';
|
||||
if (isBlog) return Astro.redirect('/');
|
||||
const team = (await getCollection('team', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order);
|
||||
const teamCards = await Promise.all(team.map(async (member) => ({ member, Content: (await render(member)).Content })));
|
||||
const pageImages = (await getCollection('pageImages'))[0]?.data;
|
||||
---
|
||||
<BaseLayout title={`About | ${site.name}`}><PageIntro eyebrow="Our pack" title="We know that great care starts with knowing your dog." lede="The Happy Tails team brings patience, playfulness, and real experience to every visit." image="/images/happy-tails-team.jpg" imageAlt="Dogs and the Happy Tails team enjoying the park" /><section class="section"><div class="shell story-grid"><div><p class="eyebrow">How we work</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><p>Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.</p></div><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /></div></section><section class="section surface"><div class="shell"><div class="section-heading"><p class="eyebrow">Meet the team</p><h2>Good people for good dogs.</h2></div><div class="team-grid">{teamCards.map(({ member, Content }) => <article class="team-card"><div class="team-avatar" aria-hidden="true">{member.data.name.split(' ').map((part) => part[0]).join('')}</div><div><p class="eyebrow">{member.data.role}</p><h3>{member.data.name}</h3><Content /></div></article>)}</div></div></section></BaseLayout>
|
||||
<BaseLayout title={`About | ${site.name}`}><PageIntro eyebrow="Our pack" title="We know that great care starts with knowing your dog." lede="The Happy Tails team brings patience, playfulness, and real experience to every visit." image={pageImages?.aboutHeroImage} imageAlt={pageImages?.aboutHeroImageAlt} /><section class="section"><div class:list={['shell story-grid', { 'story-grid--text-only': !pageImages?.aboutStoryImage }]}><div><p class="eyebrow">How we work</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><p>Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.</p></div>{pageImages?.aboutStoryImage && <img src={pageImages.aboutStoryImage} alt={pageImages.aboutStoryImageAlt} loading="lazy" />}</div></section><section class="section surface"><div class="shell"><div class="section-heading"><p class="eyebrow">Meet the team</p><h2>Good people for good dogs.</h2></div><div class="team-grid">{teamCards.map(({ member, Content }) => <article class="team-card">{member.data.image ? <img class="team-photo" src={member.data.image} alt={`${member.data.name}, ${member.data.role}`} loading="lazy" /> : <div class="team-avatar" aria-hidden="true">{member.data.name.split(' ').map((part) => part[0]).join('')}</div>}<div><p class="eyebrow">{member.data.role}</p><h3>{member.data.name}</h3><Content /></div></article>)}</div></div></section></BaseLayout>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import PageIntro from '../components/PageIntro.astro';
|
||||
import LocationMap from '../components/LocationMap.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isBlog, site } from '../lib/site-data.js';
|
||||
if (isBlog) return Astro.redirect('/');
|
||||
const pageImages = (await getCollection('pageImages'))[0]?.data;
|
||||
---
|
||||
<BaseLayout title={`Contact | ${site.name}`}><PageIntro eyebrow="Get in touch" title="Let's make your dog's day." lede="Reach out to ask a question or start planning the right care routine." image="/images/dog-walking.jpg" imageAlt="A happy dog out for a walk" /><section class="section"><div class="shell contact-grid"><div><p class="eyebrow">Happy Tails Pet Care</p><h2>We would love to hear from you.</h2><address class="contact-details"><p>{site.contact.address}</p><p><a href={`tel:${site.contact.phoneHref}`}>{site.contact.phone}</a></p><p><a href={`mailto:${site.contact.email}`}>{site.contact.email}</a></p><p>{site.contact.hours}</p></address></div><form class="contact-form"><label>Name<input type="text" autocomplete="name" disabled /></label><label>Email<input type="email" autocomplete="email" disabled /></label><label>How can we help?<textarea rows="5" disabled></textarea></label><button class="button" type="button" disabled>Contact route coming soon</button><p>This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.</p></form></div></section>{site.contact.showMap && <section class="section surface"><div class="shell"><LocationMap address={site.contact.address} label={site.name} /></div></section>}</BaseLayout>
|
||||
<BaseLayout title={`Contact | ${site.name}`}><PageIntro eyebrow="Get in touch" title="Let's make your dog's day." lede="Reach out to ask a question or start planning the right care routine." image={pageImages?.contactHeroImage} imageAlt={pageImages?.contactHeroImageAlt} /><section class="section"><div class="shell contact-grid"><div><p class="eyebrow">Happy Tails Pet Care</p><h2>We would love to hear from you.</h2><address class="contact-details"><p>{site.contact.address}</p><p><a href={`tel:${site.contact.phoneHref}`}>{site.contact.phone}</a></p><p><a href={`mailto:${site.contact.email}`}>{site.contact.email}</a></p><p>{site.contact.hours}</p></address></div><form class="contact-form"><label>Name<input type="text" autocomplete="name" disabled /></label><label>Email<input type="email" autocomplete="email" disabled /></label><label>How can we help?<textarea rows="5" disabled></textarea></label><button class="button" type="button" disabled>Contact route coming soon</button><p>This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.</p></form></div></section>{site.contact.showMap && <section class="section surface"><div class="shell"><LocationMap address={site.contact.address} label={site.name} /></div></section>}</BaseLayout>
|
||||
|
||||
@@ -6,12 +6,15 @@ import Hero from '../components/Hero.astro';
|
||||
import { isBlog, site } from '../lib/site-data.js';
|
||||
const posts = isBlog ? (await getCollection('blog', ({ data }) => !data.draft)).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) : [];
|
||||
const faqs = !isBlog ? (await getCollection('faq', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order) : [];
|
||||
const pageImages = !isBlog ? (await getCollection('pageImages'))[0]?.data : undefined;
|
||||
const heroImage = isBlog ? undefined : (pageImages?.homeHeroImage ?? null);
|
||||
const heroImageAlt = isBlog ? undefined : pageImages?.homeHeroImageAlt;
|
||||
---
|
||||
<BaseLayout>
|
||||
<Hero />
|
||||
<Hero image={heroImage} imageAlt={heroImageAlt} />
|
||||
{isBlog ? <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">The journal</p><h2>Small notes for happier dog days.</h2></div><div class="post-grid">{posts.map((post) => <article class="post-card"><p class="tag">{post.data.tags.join(' · ')}</p><h3><a href={`/posts/${post.id}/`}>{post.data.title}</a></h3><p>{post.data.description}</p><footer>{post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}</footer></article>)}</div><p class="phase-note">This static sample now uses Markdown with validated frontmatter. Draft entries stay out of the journal and sitemap.</p></div></section> : <>
|
||||
<section class="section"><div class="shell"><div class="section-heading split"><div><p class="eyebrow">The Happy Tails difference</p><h2>A familiar face, a thoughtful routine, a very happy dog.</h2></div><p>We keep our pack intentionally small, so every dog gets the calm attention and consistent care they deserve.</p></div><div class="feature-grid">{site.features.map((feature) => <article class="feature"><span>{feature.icon}</span><h3>{feature.title}</h3><p>{feature.description}</p></article>)}</div></div></section>
|
||||
<section class="section surface"><div class="shell story-grid"><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /><div><p class="eyebrow">Our story</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><a class="text-link" href="/about">Meet the team →</a></div></div></section>
|
||||
<section class="section surface"><div class:list={['shell story-grid', { 'story-grid--text-only': !pageImages?.homeStoryImage }]}>{pageImages?.homeStoryImage && <img src={pageImages.homeStoryImage} alt={pageImages.homeStoryImageAlt} loading="lazy" />}<div><p class="eyebrow">Our story</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><a class="text-link" href="/about">Meet the team →</a></div></div></section>
|
||||
<section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">Loved by local families</p><h2>Five-star care, shared in their own words.</h2></div><div class="review-grid">{site.reviews.map((review) => <blockquote class="review"><div aria-label={`${review.rating} out of 5 stars`} class="stars">★★★★★</div><p>“{review.quote}”</p><footer>{review.author} <span>Google review</span></footer></blockquote>)}</div><p class="review-disclosure">Review cards are a presentation pattern. Before launch, display only reviews approved for reuse and preserve their source, date, rating, and attribution under the business’s Google review policy.</p></div></section><Faq entries={faqs} />
|
||||
</>}
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,5 +7,6 @@ import { isBlog, site } from '../lib/site-data.js';
|
||||
if (isBlog) return Astro.redirect('/');
|
||||
const services = (await getCollection('services', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order);
|
||||
const serviceCards = await Promise.all(services.map(async (service) => ({ service, Content: (await render(service)).Content })));
|
||||
const pageImages = (await getCollection('pageImages'))[0]?.data;
|
||||
---
|
||||
<BaseLayout title={`Services | ${site.name}`}><PageIntro eyebrow="Services" title="Everything your dog needs for a brighter day." lede="Flexible, personal care—with no one-size-fits-all routines." image="/images/dog-grooming.jpg" imageAlt="A dog receiving gentle grooming care" /><section class="section"><div class="shell service-grid">{serviceCards.map(({ service, Content }) => <article class:list={['service-card', { featured: service.data.featured }]}><p class="eyebrow">{service.data.price}</p><h2>{service.data.title}</h2><Content /><ul>{service.data.items.map((item) => <li>{item}</li>)}</ul><a class="text-link" href="/contact">Ask about {service.data.title.toLowerCase()} →</a></article>)}</div></section><CallToAction eyebrow="A simple start" heading="Let's meet your dog first." copy="Tell us a little about their routine, personality, and what would make their day better." href="/contact" label="Book a meet and greet" /></BaseLayout>
|
||||
<BaseLayout title={`Services | ${site.name}`}><PageIntro eyebrow="Services" title="Everything your dog needs for a brighter day." lede="Flexible, personal care—with no one-size-fits-all routines." image={pageImages?.servicesHeroImage} imageAlt={pageImages?.servicesHeroImageAlt} /><section class="section"><div class="shell service-grid">{serviceCards.map(({ service, Content }) => <article class:list={['service-card', { featured: service.data.featured }]}><p class="eyebrow">{service.data.price}</p><h2>{service.data.title}</h2><Content /><ul>{service.data.items.map((item) => <li>{item}</li>)}</ul><a class="text-link" href="/contact">Ask about {service.data.title.toLowerCase()} →</a></article>)}</div></section><CallToAction eyebrow="A simple start" heading="Let's meet your dog first." copy="Tell us a little about their routine, personality, and what would make their day better." href="/contact" label="Book a meet and greet" /></BaseLayout>
|
||||
|
||||
@@ -20,6 +20,7 @@ nav a[aria-current='page'], nav a:hover { color: var(--ink); }
|
||||
.menu-button { display: none; font: inherit; font-weight: 600; color: var(--ink); background: transparent; border: 0; }
|
||||
.hero { padding: clamp(4rem, 9vw, 8rem) 0; overflow: hidden; background: linear-gradient(110deg, var(--surface) 0%, var(--surface) 68%, #fff 68%); }
|
||||
.hero-grid,.story-grid,.contact-grid { display: grid; grid-template-columns: 1.02fr .98fr; align-items: center; gap: clamp(3rem, 7vw, 8rem); }
|
||||
.hero-grid--text-only,.story-grid--text-only { grid-template-columns: minmax(0, 720px); }
|
||||
.hero-copy { padding: 1rem 0; }
|
||||
.eyebrow,.tag { color: color-mix(in srgb, var(--accent), var(--ink) 25%); text-transform: uppercase; letter-spacing: .13em; font-size: .73rem; font-weight: 700; margin-bottom: 1rem; }
|
||||
.lede { color: #52616b; font-size: clamp(1.1rem, 2vw, 1.32rem); max-width: 48ch; }
|
||||
@@ -45,7 +46,9 @@ nav a[aria-current='page'], nav a:hover { color: var(--ink); }
|
||||
.review footer { font-weight: 700; }.review footer span { display: block; margin-top: .15rem; color: #75828c; font-size: .82rem; font-weight: 400; }
|
||||
.review-disclosure,.phase-note { margin: 1.8rem auto 0; color: #66747d; font-size: .86rem; max-width: 75ch; }
|
||||
.page-intro { padding: clamp(3rem, 5vw, 4.75rem) 0; background: var(--surface); overflow: hidden; }.page-intro-grid { display: grid; grid-template-columns: 1.2fr .8fr; align-items: center; gap: clamp(2rem, 6vw, 6rem); }.page-intro h1 { max-width: 15ch; font-size: clamp(2.75rem, 5vw, 5rem); }.page-intro .lede { max-width: 55ch; }.page-intro img { width: 100%; aspect-ratio: 1.25 / 1; object-fit: cover; border-radius: 1rem; box-shadow: 1rem 1rem 0 color-mix(in srgb, var(--accent), transparent 80%); }
|
||||
.page-intro-grid--text-only { grid-template-columns: minmax(0, 820px); }
|
||||
.team-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }.team-card { display: grid; grid-template-columns: 120px 1fr; overflow: hidden; background: #fff; border: 1px solid #e8ecea; border-radius: .85rem; transition: transform .25s ease, box-shadow .25s ease; }.team-card:hover { transform: translateY(-.35rem); box-shadow: 0 1rem 2rem #102a4312; }.team-avatar { display: grid; place-items: center; margin: 1.2rem 0 1.2rem 1.2rem; border-radius: .6rem; color: var(--ink); background: linear-gradient(145deg, var(--surface), color-mix(in srgb, var(--accent), #fff 75%)); font-family: 'DM Serif Display', Georgia, serif; font-size: 2rem; letter-spacing: -.08em; }.team-card > div:last-child { padding: 1.5rem; }
|
||||
.team-photo { width: 120px; height: 100%; min-height: 180px; object-fit: cover; }
|
||||
.service-card { padding: 2rem; border: 1px solid #dce4e1; border-radius: .85rem; background: #fff; transition: transform .25s ease, box-shadow .25s ease, border-color .25s ease; }.service-card:hover { transform: translateY(-.35rem); border-color: color-mix(in srgb, var(--accent), #fff 65%); box-shadow: 0 1rem 2rem #102a4312; }.service-card.featured { border: 2px solid var(--accent); transform: translateY(-.5rem); }.service-card.featured:hover { transform: translateY(-.7rem); }.service-card h2 { font-size: 2.2rem; }.service-card ul { padding: 0; list-style: none; }.service-card li { padding: .4rem 0; border-top: 1px solid #e8ecea; }.service-card li::before { content: '✓'; color: var(--accent); font-weight: 700; margin-right: .55rem; }
|
||||
.centered { text-align: center; }.centered .lede { margin-inline: auto; }
|
||||
.contact-grid { align-items: start; }.contact-details { margin-top: 2rem; font-style: normal; color: #52616b; }.contact-details p { margin-bottom: .8rem; }.contact-details a { color: var(--ink); font-weight: 700; }.contact-form { display: grid; gap: 1rem; padding: 2rem; background: var(--surface); border-radius: .85rem; }.contact-form label { display: grid; gap: .4rem; font-size: .9rem; font-weight: 700; }.contact-form input,.contact-form textarea { width: 100%; padding: .8rem; font: inherit; border: 1px solid #cbd5d1; border-radius: .35rem; background: #fff; }.contact-form button:disabled { cursor: not-allowed; opacity: .6; }.contact-form p { margin-bottom: 0; color: #66747d; font-size: .85rem; }
|
||||
|
||||
Reference in New Issue
Block a user