This commit is contained in:
2026-06-22 05:51:46 +03:00
commit 03f9319b3a
32 changed files with 11008 additions and 0 deletions

2
.envrc Normal file
View File

@@ -0,0 +1,2 @@
use nix
dotenv_if_exists .env.local

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
node_modules/
dist/
.pnp
.pnp.js
.env
.env.*
!.env.example
*.tsbuildinfo
.DS_Store
**/.gitkeep
.cache
/.astro/
/.direnv/

53
AGENTS.md Normal file
View File

@@ -0,0 +1,53 @@
# Portfolio Website — Linda Adel
Oil painting artist portfolio. Astro 6 SSG with Keystatic CMS.
## Stack
- **Astro 6.4.8** — static output (`output: "static"`)
- **Keystatic** — local git-backed CMS, loaded only in dev via `NODE_ENV=development`
- **React** — only for Keystatic admin UI (`@astrojs/react`)
- **TypeScript**, **plain CSS** (no Tailwind)
- Pastel pink-purple palette (see `global.css` vars)
## Key Files
| File | Purpose |
|------|---------|
| `astro.config.mjs` | Astro config — react + conditional Keystatic integration |
| `keystatic.config.ts` | CMS schema — paintings collection + about singleton |
| `src/content.config.ts` | Custom loaders for both collections (reads `.yaml` directly) |
| `src/site-config.ts` | All site strings: name, email, tagline, location, etc. |
| `src/styles/global.css` | All styles — pastel palette, responsive breakpoints |
## Content Layer — Pure YAML, No Frontmatter
Both collections use **pure `.yaml` files** (no `---` frontmatter). Keystatic's `format: "yaml"` (the default) expects the entire file to be valid YAML. Astro uses custom loaders that parse YAML directly.
**Paintings** (`src/content/paintings/*.yaml`): `title`, `image`, `year`, `medium`, `dimensions`, `featured`, `description` — all top-level YAML keys. Custom `yamlGlobLoader` discovers `.yaml` files, parses each, stores by filename stem as entry ID.
**About** (`src/content/about/index.yaml`): `photo`, `bio` as top-level YAML keys. Custom `about-loader` reads the single file. Bio paragraphs split on `\n\n`.
## Why Custom Loaders
Keystatic's `format: "md"` passes entire files to js-yaml, which **throws on `---` frontmatter delimiters** ("expected a single document"). Using `format: "yaml"` avoids this, but Astro's built-in loaders don't support `.yaml` files. Custom loaders bridge the gap.
Shared `yamlGlobLoader(base, pattern, ctx)` function in `src/content.config.ts` handles discovery, parsing, and hot reload for any YAML-based collection.
## Dev Workflow
```sh
npm run dev # NODE_ENV=development astro dev
npm run build # static build (Keystatic excluded)
npm run preview # preview static build
```
Keystatic admin at `/keystatic` — only available in dev mode.
## Important
- Restart dev server after changing `keystatic.config.ts` (Vite caches it). If things break, delete `node_modules/.vite/` and `.astro/`.
- All content files are pure YAML — **no `---` frontmatter**. Don't add it.
- Don't use Keystatic `fields.document()` — Astro's content layer doesn't support `.mdoc` extension.
- All artist-facing strings in `src/site-config.ts` — edit there, not in templates.
- Dev command: `NODE_ENV=development astro dev` — required for Keystatic integration to load.

22
Makefile Normal file
View File

@@ -0,0 +1,22 @@
.PHONY: dev build preview check lint clean install
install:
npm install
dev:
npm run dev
build:
npm run build
preview:
npm run preview
check:
npm run check
lint:
npm run check
clean:
rm -rf dist/ .astro/ node_modules/

12
astro.config.mjs Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
export default defineConfig({
output: "static",
integrations: [
react(),
...(process.env.NODE_ENV === "development"
? [(await import("@keystatic/astro")).default()]
: []),
],
});

49
keystatic.config.ts Normal file
View File

@@ -0,0 +1,49 @@
import { config, collection, fields, singleton } from "@keystatic/core";
export default config({
storage: {
kind: "local",
},
collections: {
paintings: collection({
label: "Paintings",
slugField: "title",
path: "src/content/paintings/*",
schema: {
title: fields.slug({ name: { label: "Title" } }),
image: fields.image({
label: "Image",
description: "Photograph of the painting",
}),
year: fields.integer({ label: "Year" }),
medium: fields.text({ label: "Medium" }),
dimensions: fields.text({ label: "Dimensions" }),
description: fields.text({
label: "Description",
multiline: true,
}),
featured: fields.checkbox({
label: "Featured",
defaultValue: false,
}),
},
}),
},
singletons: {
about: singleton({
label: "About",
path: "src/content/about/index",
format: "yaml",
schema: {
photo: fields.image({
label: "Photo",
description: "Artist portrait",
}),
bio: fields.text({
label: "Bio",
multiline: true,
}),
},
}),
},
});

9829
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "portfolio-website",
"type": "module",
"version": "0.0.1",
"private": true,
"packageManager": "npm@11.2.0",
"scripts": {
"dev": "NODE_ENV=development astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"keystatic": "keystatic"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/react": "^5.0.7",
"@keystatic/astro": "^5.0.4",
"@keystatic/core": "^0.5.40",
"astro": "^6.4.8",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"typescript": "^5.7.3"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3"
}
}

6
public/favicon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#f8f6f3" rx="2"/>
<circle cx="16" cy="16" r="10" fill="#8b4513" opacity="0.3"/>
<circle cx="16" cy="16" r="6" fill="#8b4513" opacity="0.5"/>
<circle cx="16" cy="16" r="3" fill="#8b4513" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" fill="none">
<rect width="300" height="300" fill="#E8E0D8" rx="150"/>
<ellipse cx="150" cy="130" rx="55" ry="60" fill="#D0C0B0" opacity="0.6"/>
<ellipse cx="150" cy="200" rx="80" ry="70" fill="#C0B0A0" opacity="0.5"/>
<circle cx="135" cy="120" r="5" fill="#8B7B6B" opacity="0.4"/>
<circle cx="165" cy="120" r="5" fill="#8B7B6B" opacity="0.4"/>
<path d="M135 145 Q150 155 165 145" stroke="#9B8B7B" stroke-width="2" fill="none" opacity="0.5"/>
<ellipse cx="150" cy="100" rx="50" ry="20" fill="#B0A090" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" fill="none">
<rect width="600" height="800" fill="#D4E4E0"/>
<rect x="0" y="0" width="600" height="320" fill="#E8EEF0"/>
<ellipse cx="200" cy="80" rx="120" ry="40" fill="#F5F8FA" opacity="0.6"/>
<rect x="0" y="320" width="600" height="480" fill="#4A7C8C"/>
<rect x="0" y="320" width="600" height="460" fill="#3A6B7A" opacity="0.6"/>
<rect x="0" y="340" width="600" height="440" fill="#2B5A6A" opacity="0.4"/>
<rect x="0" y="380" width="600" height="400" fill="#1A4A5A" opacity="0.3"/>
<ellipse cx="200" cy="330" rx="200" ry="30" fill="#B8D4D0" opacity="0.4"/>
<rect x="0" y="600" width="600" height="200" fill="#5A7A5A" opacity="0.5"/>
<rect x="0" y="620" width="600" height="180" fill="#4A6A4A" opacity="0.6"/>
<path d="M0 640 Q50 620 100 635 Q150 650 200 630 Q250 615 300 640 Q350 660 400 635 Q450 615 500 640 Q550 660 600 635 L600 800 L0 800 Z" fill="#3A5A3A" opacity="0.4"/>
<rect x="50" y="610" width="3" height="30" fill="#3A5A3A" opacity="0.5" rx="1"/>
<rect x="120" y="615" width="2" height="25" fill="#4A6A4A" opacity="0.4" rx="1"/>
<rect x="200" y="608" width="3" height="32" fill="#3A5A3A" opacity="0.5" rx="1"/>
<rect x="350" y="612" width="2" height="28" fill="#4A6A4A" opacity="0.4" rx="1"/>
<ellipse cx="450" cy="200" rx="60" ry="30" fill="#F5F8FA" opacity="0.3"/>
<ellipse cx="450" cy="195" rx="40" ry="20" fill="#FAFCFF" opacity="0.2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" fill="none">
<rect width="600" height="800" fill="#F5F0EB"/>
<ellipse cx="450" cy="200" rx="180" ry="120" fill="#E8A87C" opacity="0.8"/>
<ellipse cx="460" cy="180" rx="140" ry="90" fill="#F4C7A0" opacity="0.6"/>
<rect x="0" y="480" width="600" height="320" fill="#8B7355" opacity="0.3"/>
<rect x="0" y="520" width="600" height="280" fill="#6B5340" opacity="0.4"/>
<rect x="0" y="560" width="600" height="240" fill="#4A3B2F" opacity="0.5"/>
<circle cx="480" cy="160" r="50" fill="#FFE0B2" opacity="0.5"/>
<ellipse cx="480" cy="165" r="40" fill="#FFF3E0" opacity="0.4"/>
<rect x="100" y="440" width="80" height="100" fill="#3D2B1F" opacity="0.6" rx="2"/>
<rect x="200" y="420" width="60" height="120" fill="#3D2B1F" opacity="0.4" rx="2"/>
<rect x="380" y="430" width="70" height="110" fill="#3D2B1F" opacity="0.5" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 912 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" fill="none">
<rect width="600" height="800" fill="#E8E6E1"/>
<rect x="0" y="0" width="240" height="800" fill="#C5C0B6" opacity="0.5"/>
<rect x="0" y="0" width="20" height="800" fill="#A0998F" opacity="0.6"/>
<rect x="240" y="0" width="360" height="800" fill="#F0EDE8"/>
<polygon points="240,200 360,200 300,80" fill="#D0CBC0" opacity="0.4"/>
<rect x="300" y="280" width="8" height="200" fill="#7A7062"/>
<rect x="270" y="480" width="60" height="40" fill="#6B6358" rx="2"/>
<rect x="280" y="490" width="40" height="100" fill="#5A5247" rx="1"/>
<circle cx="310" cy="340" r="60" fill="#B8B0A4" opacity="0.15"/>
<rect x="260" y="550" width="20" height="20" fill="#4A7C59" rx="2" opacity="0.8"/>
<rect x="285" y="540" width="16" height="30" fill="#4A7C59" rx="2" opacity="0.6"/>
<rect x="310" y="545" width="14" height="25" fill="#3A6B49" rx="2" opacity="0.7"/>
<ellipse cx="340" cy="200" rx="120" ry="80" fill="#E8DCC8" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

12
shell.nix Normal file
View File

@@ -0,0 +1,12 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_26
gnumake
];
shellHook = ''
export COREPACK_ENABLE_STRICT=0
'';
}

View File

@@ -0,0 +1,9 @@
---
import { SITE } from "../site-config";
---
<footer class="footer">
<div class="container">
&copy; {new Date().getFullYear()} {SITE.name}. All rights reserved.
</div>
</footer>

View File

@@ -0,0 +1,28 @@
---
import { SITE } from "../site-config";
const currentPath = Astro.url.pathname;
const links = [
{ href: "/", label: "Home" },
{ href: "/gallery", label: "Gallery" },
{ href: "/about", label: "About" },
{ href: "/contact", label: "Contact" },
];
---
<header class="header">
<div class="container header-inner">
<div class="header-title">
<a href="/">{SITE.name}</a>
</div>
<nav class="header-nav">
{links.map(({ href, label }) => (
<a
href={href}
aria-current={currentPath === href ? "page" : undefined}
>
{label}
</a>
))}
</nav>
</div>
</header>

26
src/components/Hero.astro Normal file
View File

@@ -0,0 +1,26 @@
---
import { SITE } from "../site-config";
export interface Props {
image: string;
title: string;
subtitle: string;
}
const { image, title, subtitle } = Astro.props;
---
<section class="hero">
<div class="container hero-grid">
<div class="hero-image">
<img src={image} alt={title} width="600" height="800" loading="eager" />
</div>
<div class="hero-content">
<h1>{title}</h1>
<p class="subtitle">{subtitle}</p>
<p>
{SITE.tagline} Based in {SITE.location}.
</p>
</div>
</div>
</section>

View File

@@ -0,0 +1,33 @@
---
export interface Props {
slug: string;
title: string;
image: string;
year: number;
medium: string;
dimensions: string;
}
const { slug, title, image, year, medium, dimensions } = Astro.props;
---
<article class="painting-card fade-in">
<a href={`/paintings/${slug}`}>
<div class="painting-card-image">
<img
src={image}
alt={title}
width="600"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="painting-card-body">
<h2 class="painting-card-title">{title}</h2>
<p class="painting-card-meta">
{year} &middot; {medium} &middot; {dimensions}
</p>
</div>
</a>
</article>

124
src/content.config.ts Normal file
View File

@@ -0,0 +1,124 @@
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { defineCollection, z } from "astro:content";
async function parseYAML(content) {
const { load } = await import("js-yaml");
return load(content);
}
async function yamlGlobLoader(base, pattern, { store, parseData, config, logger, watcher }) {
const { glob: tinyglobby } = await import("tinyglobby");
let baseDir = new URL(base, config.root);
if (!baseDir.pathname.endsWith("/")) baseDir.pathname = `${baseDir.pathname}/`;
const files = await tinyglobby(pattern, { cwd: fileURLToPath(baseDir) });
const ids = new Set();
for (const file of files) {
const fileUrl = new URL(file, baseDir);
const filePath = fileURLToPath(fileUrl);
const content = fs.readFileSync(filePath, "utf-8");
let data;
try { data = await parseYAML(content); } catch (e) { logger.error(`Error parsing ${file}: ${e.message}`); continue; }
if (!data || typeof data !== "object" || Array.isArray(data)) { logger.warn(`Skipping ${file}: not a YAML object`); continue; }
const id = file.replace(/\.\w+$/, "");
ids.add(id);
try {
const parsedData = await parseData({ id, data, filePath });
store.set({ id, data: parsedData });
} catch (e) { logger.error(`Schema validation error for ${file}: ${e.message}`); }
}
for (const existingId of store.keys()) {
if (!ids.has(existingId)) store.delete(existingId);
}
if (watcher) {
watcher.add(fileURLToPath(baseDir));
const rescan = async () => {
const fresh = await tinyglobby(pattern, { cwd: fileURLToPath(baseDir) });
const freshIds = new Set();
for (const file of fresh) {
const filePath = fileURLToPath(new URL(file, baseDir));
const content = fs.readFileSync(filePath, "utf-8");
try {
const data = await parseYAML(content);
if (data && typeof data === "object" && !Array.isArray(data)) {
const id = file.replace(/\.\w+$/, "");
freshIds.add(id);
const parsedData = await parseData({ id, data, filePath });
store.set({ id, data: parsedData });
}
} catch (e) { logger.error(`Error reloading ${file}: ${e.message}`); }
}
for (const existingId of store.keys()) {
if (!freshIds.has(existingId)) store.delete(existingId);
}
};
watcher.on("change", rescan);
watcher.on("add", rescan);
watcher.on("unlink", rescan);
}
}
const paintings = defineCollection({
loader: {
name: "paintings-loader",
load: (ctx) => yamlGlobLoader("src/content/paintings", "**/*.yaml", ctx),
},
schema: z.object({
title: z.string(),
image: z.string(),
year: z.number(),
medium: z.string(),
dimensions: z.string(),
featured: z.boolean().default(false),
description: z.string().optional(),
}),
});
const about = defineCollection({
loader: {
name: "about-loader",
load: async ({ store, parseData, config, logger, watcher }) => {
const url = new URL("src/content/about/index.yaml", config.root);
const filePath = fileURLToPath(url);
if (!fs.existsSync(filePath)) {
logger.warn("About file not found at src/content/about/index.yaml");
return;
}
const content = fs.readFileSync(filePath, "utf-8");
let data;
try { data = await parseYAML(content); } catch (e) {
logger.error(`Error parsing about file: ${e.message}`);
return;
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
logger.error("About file must contain a YAML object");
return;
}
const parsedData = await parseData({ id: "index", data });
store.set({ id: "index", data: parsedData });
if (watcher) {
watcher.add(filePath);
const onChange = async (changedPath) => {
if (changedPath !== filePath) return;
const newContent = fs.readFileSync(filePath, "utf-8");
try {
const newData = await parseYAML(newContent);
if (newData && typeof newData === "object" && !Array.isArray(newData)) {
const newParsedData = await parseData({ id: "index", data: newData });
store.set({ id: "index", data: newParsedData });
logger.info("Reloaded about data");
}
} catch (e) { logger.error(`Error reloading about file: ${e.message}`); }
};
watcher.on("change", onChange);
watcher.on("add", onChange);
}
},
},
schema: z.object({
photo: z.string().optional(),
bio: z.string().optional(),
}),
});
export const collections = { paintings, about };

View File

@@ -0,0 +1,4 @@
bio: |-
Your local awesome red head with the unique and messy style :3
She said she wanna fuck and I was like... "really? :3"

View File

@@ -0,0 +1,11 @@
title: Coastal Breeze
image: /images/coastal-breeze.svg
year: 2023
medium: Oil on panel
dimensions: 18" x 24"
description: >-
A windswept coastal vista painted en plein air. Layers of sea green, teal, and
cerulean build the restless ocean, while the sky shifts from pale blue to soft
grey at the horizon. The foreground grasses are rendered with quick, gestural
marks that echo the movement of the breeze.
featured: true

View File

@@ -0,0 +1,7 @@
title: Golden Hour
image: /images/golden-hour.svg
year: 2024
medium: Oil on canvas
dimensions: 30" x 40"
description: best ever
featured: false

View File

@@ -0,0 +1,7 @@
title: "Morning Light"
image: "/images/morning-light.svg"
year: 2024
medium: "Oil on linen"
dimensions: "24\" x 30\""
featured: false
description: "Soft morning light filters through a window, casting cool blue shadows across a simple interior scene. The palette is restrained — pale greys, muted blues, and warm whites — punctuated by the deep green of a potted plant. Painted from life in a single sitting."

View File

@@ -0,0 +1,27 @@
---
interface Props {
title?: string;
description?: string;
}
const { title = "Portfolio", description = "Oil paintings" } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/src/styles/global.css" />
</head>
<body>
<slot name="header" />
<main>
<slot />
</main>
<slot name="footer" />
</body>
</html>

46
src/pages/about.astro Normal file
View File

@@ -0,0 +1,46 @@
---
import { SITE, siteTitle } from "../site-config";
import BaseLayout from "../layouts/BaseLayout.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import { getEntry } from "astro:content";
const about = await getEntry("about", "index");
const bio = about?.data?.bio;
---
<BaseLayout title={siteTitle("About")} description="About the artist">
<Fragment slot="header">
<Header />
</Fragment>
<section class="about-page">
<div class="container">
<div class="about-layout">
<div class="about-photo">
<img
src={about?.data?.photo ?? "/images/artist-photo.svg"}
alt={SITE.name}
width="300"
height="300"
loading="lazy"
/>
</div>
<div class="about-bio">
<h1>About</h1>
{bio ? (
bio.split("\n\n").map((p) => <p>{p}</p>)
) : (
<p>
{SITE.tagline} Based in {SITE.location}.
</p>
)}
</div>
</div>
</div>
</section>
<Fragment slot="footer">
<Footer />
</Fragment>
</BaseLayout>

27
src/pages/contact.astro Normal file
View File

@@ -0,0 +1,27 @@
---
import { SITE, siteTitle } from "../site-config";
import BaseLayout from "../layouts/BaseLayout.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
---
<BaseLayout title={siteTitle("Contact")} description="Get in touch">
<Fragment slot="header">
<Header />
</Fragment>
<section class="contact-page">
<h1>Contact</h1>
<p>
For inquiries about commissions, exhibitions, or purchasing original works,
please reach out via email.
</p>
<a class="contact-email" href={`mailto:${SITE.email}`}>
{SITE.email}
</a>
</section>
<Fragment slot="footer">
<Footer />
</Fragment>
</BaseLayout>

56
src/pages/gallery.astro Normal file
View File

@@ -0,0 +1,56 @@
---
import { SITE, siteTitle } from "../site-config";
import BaseLayout from "../layouts/BaseLayout.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import PaintingCard from "../components/PaintingCard.astro";
import { getCollection } from "astro:content";
const paintings = await getCollection("paintings");
---
<BaseLayout title={siteTitle("Gallery")} description={SITE.description}>
<Fragment slot="header">
<Header />
</Fragment>
<section style="padding: 2rem 0 4rem;">
<div class="container">
<div class="gallery-header">
<h1>Gallery</h1>
<p>A selection of recent works</p>
</div>
<div class="gallery-grid">
{paintings.map((entry) => (
<PaintingCard
slug={entry.id}
title={entry.data.title}
image={entry.data.image}
year={entry.data.year}
medium={entry.data.medium}
dimensions={entry.data.dimensions}
/>
))}
</div>
</div>
</section>
<Fragment slot="footer">
<Footer />
</Fragment>
</BaseLayout>
<script>
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
observer.unobserve(entry.target);
}
}
},
{ threshold: 0.1 }
);
document.querySelectorAll(".fade-in").forEach((el) => observer.observe(el));
</script>

65
src/pages/index.astro Normal file
View File

@@ -0,0 +1,65 @@
---
import { SITE, siteTitle } from "../site-config";
import BaseLayout from "../layouts/BaseLayout.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import Hero from "../components/Hero.astro";
import PaintingCard from "../components/PaintingCard.astro";
import { getCollection } from "astro:content";
const paintings = await getCollection("paintings");
const featured = paintings.find((p) => p.data.featured);
---
<BaseLayout title={siteTitle(SITE.title)} description={SITE.description}>
<Fragment slot="header">
<Header />
</Fragment>
{featured && (
<Hero
image={featured.data.image}
title={featured.data.title}
subtitle={`${featured.data.title}, ${featured.data.year}`}
/>
)}
<section style="padding: 1rem 0 4rem;">
<div class="container">
<div style="text-align: center; margin-bottom: 2.5rem;">
<h2 style="font-family: var(--font-serif); font-size: 1.75rem; font-weight: 400;">All Paintings</h2>
</div>
<div class="gallery-grid">
{paintings.map((entry) => (
<PaintingCard
slug={entry.id}
title={entry.data.title}
image={entry.data.image}
year={entry.data.year}
medium={entry.data.medium}
dimensions={entry.data.dimensions}
/>
))}
</div>
</div>
</section>
<Fragment slot="footer">
<Footer />
</Fragment>
</BaseLayout>
<script>
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
observer.unobserve(entry.target);
}
}
},
{ threshold: 0.1 }
);
document.querySelectorAll(".fade-in").forEach((el) => observer.observe(el));
</script>

View File

@@ -0,0 +1,56 @@
---
import { siteTitle } from "../../site-config";
import BaseLayout from "../../layouts/BaseLayout.astro";
import Header from "../../components/Header.astro";
import Footer from "../../components/Footer.astro";
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const paintings = await getCollection("paintings");
return paintings.map((entry) => ({
params: { slug: entry.id },
props: { entry },
}));
}
const { entry } = Astro.props;
const { data } = entry;
---
<BaseLayout
title={siteTitle(data.title)}
description={`${data.title}, ${data.year}, ${data.medium}`}
>
<Fragment slot="header">
<Header />
</Fragment>
<article class="painting-detail">
<div class="container">
<div class="painting-detail-image">
<img
src={data.image}
alt={data.title}
width="800"
height="1067"
loading="eager"
/>
</div>
<div class="painting-detail-info">
<h1>{data.title}</h1>
<div class="painting-detail-meta">
<span>{data.year}</span>
<span>{data.medium}</span>
<span>{data.dimensions}</span>
</div>
{data.description && (
<div class="painting-detail-description">{data.description}</div>
)}
</div>
</div>
</article>
<Fragment slot="footer">
<Footer />
</Fragment>
</BaseLayout>

13
src/site-config.ts Normal file
View File

@@ -0,0 +1,13 @@
export const SITE = {
name: "Linda Adel",
title: "Oil Paintings",
email: "laladel0@gmail.com",
description: "Portfolio of oil paintings by Linda Adel",
tagline: "Exploring the shifting qualities of natural light through oil painting.",
location: "Alexandria, Egypt",
url: "/",
};
export function siteTitle(...parts: string[]) {
return parts.length ? `${parts.join(" — ")}${SITE.name}` : SITE.name;
}

379
src/styles/global.css Normal file
View File

@@ -0,0 +1,379 @@
:root {
--bg: #f0e0ea;
--surface: #ffffff;
--text: #1a1120;
--text-muted: #7a5a75;
--border: #e8d0e4;
--accent: #d4a0c0;
--accent-light: #e8bcd4;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
--radius: 4px;
--max-width: 1200px;
--content-width: 720px;
--font-sans: system-ui, -apple-system, sans-serif;
--font-serif: Georgia, "Times New Roman", serif;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
}
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
img {
max-width: 100%;
height: auto;
display: block;
}
a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s;
}
a:hover { color: var(--accent-light); }
h1, h2, h3, h4 {
font-family: var(--font-serif);
font-weight: 400;
line-height: 1.3;
}
.container {
width: 100%;
max-width: var(--max-width);
margin: 0 auto;
padding: 0 1.5rem;
}
/* Header */
.header {
padding: 1.5rem 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.header-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.header-title {
font-family: var(--font-serif);
font-size: 1.5rem;
color: var(--text);
letter-spacing: -0.02em;
}
.header-title a { color: var(--text); }
.header-title a:hover { color: var(--accent); }
.header-nav {
display: flex;
gap: 2rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.header-nav a { color: var(--text-muted); }
.header-nav a:hover,
.header-nav a[aria-current="page"] {
color: var(--text);
}
/* Footer */
.footer {
padding: 3rem 0;
border-top: 1px solid var(--border);
text-align: center;
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 6rem;
}
/* Hero */
.hero {
padding: 4rem 0 3rem;
}
.hero-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
align-items: center;
}
.hero-image {
aspect-ratio: 3 / 4;
overflow: hidden;
background: var(--border);
border-radius: var(--radius);
}
.hero-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s ease;
}
.hero-image:hover img { transform: scale(1.03); }
.hero-content h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.hero-content .subtitle {
font-family: var(--font-serif);
font-size: 1.125rem;
color: var(--text-muted);
font-style: italic;
margin-bottom: 1.5rem;
}
.hero-content p {
font-size: 1rem;
line-height: 1.7;
color: var(--text-muted);
}
/* Gallery grid */
.gallery-header {
padding: 3rem 0 2rem;
text-align: center;
}
.gallery-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.gallery-header p {
color: var(--text-muted);
font-size: 0.9375rem;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
}
/* Painting card */
.painting-card {
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: transform 0.3s ease, box-shadow 0.3s ease;
cursor: pointer;
}
.painting-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.painting-card-image {
aspect-ratio: 3 / 4;
overflow: hidden;
background: var(--border);
}
.painting-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.painting-card:hover .painting-card-image img {
transform: scale(1.06);
}
.painting-card-body {
padding: 1.25rem;
}
.painting-card-title {
font-family: var(--font-serif);
font-size: 1.125rem;
margin-bottom: 0.25rem;
}
.painting-card-meta {
font-size: 0.8125rem;
color: var(--text-muted);
}
/* Painting detail */
.painting-detail {
padding: 3rem 0;
}
.painting-detail-image {
max-width: 800px;
margin: 0 auto 2rem;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-md);
}
.painting-detail-info {
max-width: var(--content-width);
margin: 0 auto;
text-align: center;
}
.painting-detail-info h1 {
font-size: 2rem;
margin-bottom: 0.75rem;
}
.painting-detail-meta {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 1.5rem;
margin-bottom: 2rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.painting-detail-meta span {
white-space: nowrap;
}
.painting-detail-description {
font-size: 1rem;
line-height: 1.8;
color: var(--text-muted);
max-width: 600px;
margin: 0 auto;
}
/* About */
.about-page {
padding: 3rem 0;
}
.about-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 3rem;
align-items: start;
max-width: var(--content-width);
margin: 0 auto;
}
.about-photo {
aspect-ratio: 1;
overflow: hidden;
border-radius: 50%;
background: var(--border);
}
.about-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.about-bio {
font-size: 1rem;
line-height: 1.8;
color: var(--text-muted);
}
.about-bio h1 {
font-size: 2rem;
margin-bottom: 1.5rem;
color: var(--text);
}
/* Contact */
.contact-page {
padding: 3rem 0;
max-width: var(--content-width);
margin: 0 auto;
text-align: center;
}
.contact-page h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
.contact-page p {
color: var(--text-muted);
margin-bottom: 2rem;
font-size: 1rem;
line-height: 1.7;
}
.contact-email {
display: inline-block;
font-family: var(--font-serif);
font-size: 1.5rem;
color: var(--accent);
padding: 1rem 2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
transition: all 0.2s;
}
.contact-email:hover {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* Fade-in animation */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* Responsive */
@media (max-width: 768px) {
.hero-grid {
grid-template-columns: 1fr;
gap: 2rem;
}
.hero-content h1 { font-size: 2rem; }
.hero-image { aspect-ratio: 4 / 3; }
.gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.5rem;
}
.about-layout {
grid-template-columns: 1fr;
text-align: center;
}
.about-photo {
max-width: 200px;
margin: 0 auto;
}
.painting-detail-image {
max-width: 100%;
}
.painting-detail-meta {
gap: 1rem;
}
.header-inner {
flex-direction: column;
align-items: center;
text-align: center;
}
.header-nav {
gap: 1.25rem;
font-size: 0.8125rem;
}
.contact-email {
font-size: 1.125rem;
padding: 0.75rem 1.5rem;
}
}
@media (max-width: 480px) {
.hero { padding: 2rem 0; }
.hero-content h1 { font-size: 1.75rem; }
.gallery-grid {
grid-template-columns: 1fr;
gap: 1.25rem;
}
.container { padding: 0 1rem; }
.painting-detail-meta {
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
}

8
tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "preserve"
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}